Domain Driven Design

simplifying the complicated



Dmytro Naumenko

Yii core team, HiQDev

  • The topic is very wide. I will show the basics in 40 minutes.
  • You will see much code on the screen.
  • Some things are simplified a lot.
  • Timing — 20 seconds per slide.

Domain

Real-world entities and actions, that should be automated.

Invoice

Domain Driven Design

A set of approaches to reflect domain in code.

Domain study starts with the subject area analysis and requirements drafting.

Domain experts

  • Know, how the domain and its business processes work.
  • Can skip "obvious" details.
  • May not be IT guys.
Create an invoice, add goods to it and bill the client.

Our tasks:

  • Dig into the domain.
  • Generalize the particulars.
  • Remove redundant complexity.
  • Teach experts how to speak our language.
  • Learn, how to speak their language.

Ubiquitous language

A set of terms, used in domain model to explain actions and relations.

Why?

  • To call a spade a spade.
  • To have ubiquitous language for a team and a business.
Create an invoice, add goods to it and bill the client.

Extract the hidden meaning:

  • What does the invoice consist of?
  • What can be a good?
  • What good characteristics are important?
  • What does it mean "to bill the client"?
  • What do we know about the client?
  • ...

Let's start quite simple:

class InvoiceController
{
    public function actionCreate($client_id, array $item_ids)
    {
        $client = Client::find()->byId($client_id)->one();
        if (!$client) { throw new NotFoundException('No client'); }

        $items = Item::find()->byId($item_ids)->all();
        if (!$items) { throw new NotFoundException('No items'); }

        $invoice = new Invoice();
        $invoice->client = $client;
        $invoice->status = 'new';
        $invoice->items = $items;
        $invoice->save();

        $this->notifyInvoiceCreated($client->email, $invoice);

        return $this->render('invoice', compact('invoice')]);
    }
}
Of course, we can add and remove the invoice lines, if the client is not billed yet.
class InvoiceController
{
    public function actionAddItem($invoice_id, $item_id = null)
    {
        $invoice = Invoice::find()->byId($invoice_id)->one();
        if (!$invoice) { throw new NotFoundException('No invoice'); }

        $item = Item::find()->byId($item_id)->one();
        if (!$item) { throw new NotFoundException('No item'); }

        if ($invoice->status !== 'new') { throw new HttpException(403); }

        $invoice->items[] = $item;
        $invoice->save();

        return $this->render('invoice', ['invoice' => $invoice]);
    }
}

Pros

  • All the logic is on hands, easy to understand what's going on.
  • Nothing redundant.
  • OK for beginners.
  • Easy to add a couple more if's.
  • It's a Transaction script pattern :)

Cons

  • Everything is mixed up.
  • Hard to reuse.
  • Tests? Lolwhat?
  • Add a couple more if's and get a mess.

Fat controller & Slim model

VS

Slim controller & Fat model

Table Driven Domain model

  • Domain model = data model
  • Each model object is represented by a table
  • Objects describe DB relations

a.k.a

ActiveRecord

Pros:

  • Easy to start with
  • Super simple interface
  • OK for beginners
  • No redundant data transformations
  • Perfect for simple CRUD model
  • Easy to work with relations

Cons:

  • Hard coupling to the DB table
  • Table changes → code changes
  • Data types are bound to DBMS types
  • Harder to make it work with different data sources
  • More logic — harder to support
  • The Single Responsibility principle violation

MVC

M ≠ ActiveRecord model

M — is a big world!

We need to go deeper

Forget about the controllers and DBMS for some time

Domain Driven Design

A set of approaches to reflect domain in code.

The terms:

  • Domain
  • Ubiquitous Language
  • Domain Experts
  • ...
  • Design patterns:
    • Entity
    • Value Object
    • Aggregate
    • Repository
    • Service

Objects and their interaction first.

The DB – later.

Onion architecture

Domain Layer

  • Is a domain knowledge representation in code.
  • Knows nothing about the DBMS or framework.

Entity

  • Describes independently existing domain elements
  • Is distinguished by unique identity, not by the attributes values
  • Is continuously and uniquely identified
class Client
{
    private $id;
    private $name;
    private $address;
    private $phone;

    public function __construct($id, $name, $address, $phone)
    {
        if (!preg_match('/^\d+$/', $phone)) {
            throw new ValidationException($phone);
        }
        $this->phone = $phone;

        // ... name, address
    }
}
new Client(
    UUID::create(),
    'John Doe',
    'Big Road 51, 11000, Kyiv',
    '3810112433886'
);

Name, address, phone

  • Consistency
  • Validation
  • Type control

Value Object

  • Does not have a conceptual identity
  • Describes domain elements by their attributes values
  • Immutable after the creation
  • Good for typification and data structuring
class Address
{
    private $country;
    private $city;
    private $zip;
    private $lines;

    public function __construct($country, $city, $zip, $lines)
    {
        $this->zip = $this->validateZip($zip);
        // TODO: city, zip, lines
    }

    private function validateZip($zip)
    {
        if (!preg_match('/^\d+$/', $zip) {
            throw new ZipValidationException($zip);
        }

        return $zip;
    }
}
new Client(
    UUID::create(),
    new Name('John', 'Doe'),
    new Address('Ukraine', 'Kyiv', '11000', ['51 Big Road str.']),
    new Phone('381', '0', '112433886')
);

Entity vs Value Object

Do we need to distinguish to bank notes of the same value?

  • Cashier does not — Value Object
  • Crime expert does — Entity

Aggregate

  • Draws a boundary around Entities & VO.
  • Has a global identity.
  • Aggregate change is transactional.
  • Nothing outside the Aggregate boundary can hold a reference to anything inside.

Aggregate draws a boundary around one or more Entities

  • Entities inside the boundary have local identity.
  • When an Entity needs a global identity, it should become another Aggregate.

Client is an Aggregate

We can have more than one item of the same good in an invoice!


Clarify details:

  • Do we have different currencies?
  • Can we sell less than one item?
  • The measurement unit — pcs, meters, liters?
  • ...

Aggregate – Item

class Item
{
    private $id;
    private $name;
    private $description;
    private $price;

    public function __construct($id, $name, Money $price) {
        $this->id = $id;
        $this->name = $name;
        $this->price = $price;
    }

    // TODO: Getters and setters
}
$item = new Item(UUID::create(), 'Silver bullet', Money::USD(1000));
$item->setDescription('This bullet is a killing feature!');

Value Object — LineItem

class LineItem
{
    protected $item;
    protected $quantity;

    public function __construct(Item $item, $quantity = 1) {
        $this->item = $item;
        $this->quantity = $quantity;
    }

    // TODO: Getters (no setters in VO)
}

Aggregate – Invoice

class Invoice
{
    protected $id;
    protected $client;
    protected $lineItems = [];
    protected $status;

    public function __construct($id, Client $client)
    {
        $this->id = $id;
        $this->client = $client;
    }

    public function getId() {}
    public function getClient() {}

    public function getLineItems() {}
    public function setLineItems() {}

    public function getStatus() {}
    public function setStatus($status) {}
}      
$item = new Item(UUID::create(), 'Silver bullet', Money::USD(1000));
$quantity = 2;

$invoice = new Invoice(UUID::create(), $client);
$invoice->setStatus('new');
$invoice->setLineItems([new LineItem($item, $quantity)]);
// Change the line item
$anotherLine = new LineItem(
    new Item(UUID::create(), 'Rage cup', Money::USD(500))
);

$invoice->setLineItems([$anotherLine]);
$invoice->setLineItems([$anotherLineItem]);
$invoice->setStatus('processing');

// VS

$invoice->lineItems = [$anotherLineItem];
$invoice->status = 'processing';

???

Anemic model

  • The model shows references.
  • There are getters and setters for properties.
  • Does not describe domain logic and actions.

Value Object – Status

abstract class Status
{
    /**
     * @property array Class names of next possible statuses
     */
    protected $next = [];

    public function canBeChangedTo(self $status): bool
    {
        $className = get_class($status);

        return in_array($className, $this->next, true);
    }

    public function allowsModification(): bool
    {
        return true;
    }
}
class NewStatus extends Status
{
    protected $next = [ProcessingStatus::class, RejectedStatus::class];
}
class Invoice
{
    public function changeStatus(Status $status)
    {
        if (!$this->status->canBeChangedTo($status)) {
            throw new WrongStatusChangeDirectionException();
        }

        $this->status = $status;
    }
}
class Invoice
{
    public function addLineItem(LineItem $line)
    {
        if (!$this->status->allowsModification()) {
            throw new ModificationProhibitedException();
        }

        $this->line->push($line);
    }

    public function removeLineItem(LineItem $line)
    {
        // Copy-paste status check?
    }
}
abstract class Status
{
    public function ensureCanBeChangedTo(self $status): void
    {
        if (!$this->canBeChangedTo($status)) {
            throw new WrongStatusChangeDirectionException();
        }
    }

    public function ensureAllowsModification(): void
    {
        if (!$this->allowsModification()) {
            throw new ModificationProhibitedException();
        }
    }
}
class Invoice
{
    public function changeStatus(Status $status)
    {
        $this->status->ensureCanBeChangedTo($status);
        $this->status = $status;
    }

    public function addLineItem(LineItem $lineItem)
    {
        $this->status->ensureAllowsModification();
        $this->lineItmes->push($lineItem);
    }

    public function removeLineItem(LineItem $lineItem)
    {
        $this->status->ensureAllowsModification();
        // ...
    }
}
$invoice = new Invoice(UUID::create(), $client);

$invoice->addLineItem(new LineItem($item, $quantity));
$invoice->changeStatus(new NewStatus());

// save...?

But... how to put it to the DB?

ActiveRecord || DDD way?

Repository

Pros:

  • Described by an interface
  • Interface knows nothing about the DB
  • Testable
  • Widely used in DDD

Repository

Cons:

  • More complex to start with
  • Not so easy for beginners
  • Hydration coding

Repository Interface Level

  • Interfaces without actual implementations
  • Repositories must exist only for aggregates
interface InvoiceRepositoryInterface
{
    /** @throws InvoiceNotFoundException when no invoice exists */
    public function findById($id);
    public function add(Invoice $invoice);
    public function update(Invoice $invoice);
}

InvoiceRepositoryInterface implementation

  • On the infrastructure layer.
  • DB support implementation.
  • Can support more than one DB.
class SqlInvoiceRepository implements InvoiceRepositoryInterface
{
    public function __construct(Connection $db) {
        $this->db = $db;
    }

    public function create(Invoice $invoice)
    {
        $this->db->transactionBegin();

        $this->db->insert('invoice', [
            'id' => $invoice->getId(),
            'client_id' => $invoice->getClient()->getId(),
            'status' => $invoice->getStatus()
        ]);

        foreach ($invoice->getLineItems() as $lineItem) {
            $this->db->insert('invoice_lines', [
                'invoice_id' => $invoice->getId(),
                'item_id' => $lineItem->getItem()->getId(),
                'quantity' => $lineItem->getQuantity(),
            ]);
        }

        $this->db->transactionCommit();
    }
}
class InvoiceController
{
    public function actionCreate($client_id)
    {
        $client = $this->clientRepository->findById($client_id);

        $invoice = new Invoice(UUID::create(), $client);
        $invoice->changeStatus(new NewStatus());
        $this->invoiceRepository->add($invoice);

        return $invoice->getId();
    }
}

There are some problems:

  • We've jumped over a couple of layers.
  • We have logic in the controller.
  • What if we have multiple entry points?
  • Tests?

Domain Services Interfaces

  • Interfaces to work with domain model.
  • Stateless
  • Can use other repositories and services.
  • Move the logic away from controllers.
class InvoiceServiceInterface
{
    public function create($clientId);
    public function addLineItem($id, $itemId, $quantity);
    public function reject($id);
    public function process($id);
}

Infrastructure layer

  • Repositories and services implementation
  • Infrastructure logic
  • Work with the DB, IoC container
class InvoiceService implements InvoiceServiceInterface
{
    public function __construct(
        ClientRepositoryInterface $clientRepository,
        InvoiceRepositoryInterface $invoiceRepository
    ) {
        $this->clientRepository = $clientRepository;
        $this->invoiceRepository = $invoiceRepository;
    }

    public function create($clientId)
    {
        $client = $this->clientRepository->findById($clientId);

        $invoice = new Invoice(UUID::create(), $client);
        $invoice->changeStatus(new NewStatus());

        $this->invoiceRepository->create($invoice);

        return $invoice->getId();
    }
}
class InvoiceController
{
    public function actionCreate($client_id)
    {
        $invoiceId = $this->invoiceService->add($client_id);

        return $invoiceId;
    }
}

Add some invariant checks to InvoiceService

class InvoiceService
{
    public function reject($invoiceId)
    {
        $invoice = $this->invoiceRepository->findById($id);
        if ($invoice === null) {
            return null;
        }

        $invoice->changeStatus(new RejectedStatus());

        if ($this->invoiceRepository->update($invoice) === false) {
            return null;
        }

        return true;
    }
}

Exceptions

  • Do the job or throw an exception.
  • Remember about PHPDocs.
  • Class name must describe a problem.
class InvoiceService
{
    function reject()
    {
        $invoice = $this->invoiceRepository->findById($id);
        $invoice->changeStatus(new ProcessingStatus());

        $this->invoiceRepository->update($invoice);

        return true;
    }
}

Send email, when status gets changed

  • Register event on model change.
  • Event is an object.
  • Handle the event after data persistence.
trait EventTrait
{
    private $events = [];

    public function registerEvent($event)
    {
        $this->events[] = $event;
    }

    public function releaseEvents()
    {
        $events = $this->events;
        $this->events = [];

        return $events;
    }
}

Make status change an event

class Invoice
{
    use EventTrait;

    public function changeStatus(Status $status)
    {
        $this->status->ensureCanBeChangedTo($status);
        $this->status = $status;

        $this->registerEvent(
            new InvoiceStatusWasChangedEvent($this, $status)
        );
    }
}
class InvoiceService implements InvoiceServiceInterface
{
    public function reject($id)
    {
        $invoice = $this->invoiceRepository->findById($id);
        $invoice->changeStatus(new RejectedStatus());

        $this->invoiceRepository->update($invoice);

        $this->dispatcher->dispatchEvents($invoice->releaseEvents());
    }
}

What if an action has many parameters?

class ClientService implements ClientServiceInterface
{
    public function changeAddress($id, $country, $city, $zip, $lines)
    {
        $client = $this->clientRepository->findById($id);

        $address = new Address($country, $city, $zip, $lines);
        $client->changeAddress($address);

        $this->clientRepository->update($client);
    }
}

Too many params.

Any way to fix it?
  • Create the Address object in controller.
  • Pass array of parameters.
  • Create one more class.

DTO

Data Transfer Object

Data Transfer Object

  • To pass data between application layers.
  • Does not contain logic.
  • Typifies a data set.
class AddressDto
{
    public $country;
    public $city;
    public $zip;
    public $lines = [];

    public static function fromArray($data)
    {
        $self = new self();

        $self->country = $data['country'];
        $self->city = $data['city'];
        $self->zip = $data['zip'];
        $self->lines = $data['lines'];

        return $self;
    }
}
class ClientController
{
    public function actionChangeAddress($client_id)
    {
        $dto = AddressDto::fromRequest($this->request->post());
        $this->clientService->changeAddress($client_id, $dto);

        return $client_id;
    }
}
class ClientService implements ClientServiceInterface
{
    public function changeAddress($id, AddressDto $dto)
    {
        $client = $this->clientRepository->findById($id);

        $address = new Address(
            $dto->country,
            $dto->city,
            $dto->zip,
            $dto->lines
        );
        $client->changeAddress($address);

        $this->clientRepository->update($client);
    }
}

What is the Application responsibility?

  • Route requests.
  • General app configuration.
  • Infrastructure layer configuration.

What did we get?

  • The application is layered.
  • Domain model is DB and Framework-agnostic.
  • Code is more expressive.
  • OOP.
  • Each layer is testable.

Is is a good as it seems?

Of course not:

  • Too complex for beginners.
  • Work with data persistence is more complex.
  • Cross-cutting concerns.
  • Overhead for easy domain.
  • Over-abstraction.
  • Hard to implement with lean domain model.
X: project life time; Y: support complexity

Canonical DDD is an overhead for 80% of projects.

But it's bad excuse for not learning.

Think first. DDD — is a set of recommendations, not rules.

Don't be afraid to make a lot of small classes!

DDD ideas can be partially used

Даже c ActiveRecord

Even with ActiveRecord

  • Entity, Aggregate — simply ActiveRecord with clear methods.
  • DTO — forms.
  • Services — move complexity out of controllers and models.
  • Repository — can be implemented as ActiveQuery (scope) with good method names.

Start small. And always think first.

TLDR :)

ありがとう! Questions time