Domain Driven Design

просто о сложном



Дмитрий Науменко

Yii core team, HiQDev

Disclaimer

  • Тема очень глубокая, я расскажу базовые вещи за 40 минут
  • Будет много кусочков кода
  • Многое упрощено для ясности
  • Тайминг — 20 секунд на слайд

Domain

Сущности и действия реального мира, которые нужно автоматизировать

Счёт на оплату

Domain Driven Design

Набор подходов для перенесения знаний о домене в код

Работа с доменом начинается с анализа предметной области и составления требований

Domain experts

  • Знают, как работает домен и его бизнес-процессы
  • Могут упускать "очевидные" детали
  • Могут не быть техническими специалистами
Создаём счёт, добавляем в него товары и выставляем его клиенту.

Наши задачи:

  • Добыть знания о домене
  • Выделить общие случаи из частных
  • Понять, что из сказанного — лишнее
  • Научить их изъясняться на нашем языке
  • Научиться изъясняться на их языке

Единый язык домена

Набор терминов, применяемых в модели домена для объяснения связей и действий

Зачем?

  • называть вещи своими именами
  • иметь в команде единую терминологию
Создаём счёт, добавляем в него товары и выставляем его клиенту.

Извлечение скрытого смысла

  • Какие есть характеристики счёта?
  • Что может быть товаром?
  • Какие характеристики товара важны?
  • Что значит "выставить счёт"?
  • Что мы знаем о клиенте?
  • ...

Начнём по-простому

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')]);
    }
}
Само собой, товары в счёт можно добавлять и удалять, если счёт еще не выставлен
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]);
    }
}

Ок :)

Преимущества

  • Вся логика рядом, легко понять что происходит
  • Нет ничего лишнего
  • Низкий входной порог
  • Легко дописать еще пару-тройку if-ов
  • Это – паттерн Transaction script :)

Недостатки

  • Всё перемешано
  • Сложно переиспользовать
  • Тесты? Лолшто?
  • Дописать еще пару if'ов и будет месиво

Fat controller & Slim model

VS

Slim controller & Fat model

Table Driven Domain model

  • Модель домена = модель данных
  • Каждый объект модели представлен таблицей
  • Объекты описывают связи БД

a.k.a

ActiveRecord

Плюсы:

  • Нужно минимум для старта
  • Волшебно простой интерфейс
  • Низкий порог вхождения
  • Нет лишних преобразований
  • Хорош для простого CRUD
  • Легкая работа со связями (relations)

Минусы:

  • Жесткая связь с таблицей в БД
  • Изменение таблицы → изменение кода
  • Привязка типов данных к типам столбцов
  • Сложность использования разных источников данных
  • Больше логики — сложнее поддерживать
  • Нарушение Single Responsibility

MVC

M ≠ Модель ActiveRecord

M — это целый Мир!

We need to go deeper

Забудем на время о контроллерах и БД

Domain Driven Design

Набор подходов для перенесения знаний о домене в код

Описанные концепции:

  • Domain
  • Ubiquitous Language
  • Domain Experts
  • ...
  • Шаблоны проектирования:
    • Entity
    • Value Object
    • Aggregate
    • Repository
    • Service

Сначала объекты и их взаимодействие.

БД будет потом.

Onion architecture

Domain Layer

  • Является представлением предметной области в коде
  • Ничего не знает о БД или фреймворке

Entity

  • Описывает индивидуально существующие элементы домена
  • Определяется по идентификатору, а не по значению атрибутов
  • Непрерывно и однозначно определяется на всём протяжении существования
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(),
    'Фамилия Имя Отчество',
    'ул. Кодеров, д. 0xFF',
    '380441234567'
);

Name, address, phone

  • Целостность
  • Валидация
  • Типизация

Value Object

  • Не обладает идентификатором
  • Описывает элементы домена, полностью определяемые свойствами
  • Неизменяемый после создания
  • Используется для типизации и структурирования данных
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('Фамилия', 'Имя', 'Отчество'),
    new Address('Украина', 'Киев', '01001', ['ул. Кодеров, д. 0xFF']),
    new Phone('380', '44', '1234567')
);

Entity vs Value Object

Нужно ли отличать две купюры одного номинала?

  • Кассиру не нужно — Value Object
  • Эксперту-криминалисту нужно — Entity

Aggregate

  • Собирательная сущность которая считается единым целым
  • Состоит из VO и Entity
  • Определяется по идентификатору
  • Является границей транзакции при изменении данных
  • Другие элементы домена не могут ссылаться на внутренности агрегата

Агрегат — это граница уникальности для Entity

  • Один Entity не может быть в двух агрегатах одновременно
  • Такой Entity должен стать агрегатом

Client — это Aggregate

Каждого товара может быть больше чем 1 единица в счёте!


Уточняем детали:

  • Нужно учитывать валюту?
  • Меньше целой единицы можно?
  • Единицы измерения — шт, метры, литры?
  • ...

Агрегат – 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)
}

Агрегат – 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)]);
// Заменим позицию в счёте
$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';

???

Анемия модели

  • Модель отображает связи
  • В модели есть геттеры и сеттеры для свойств
  • Модель не описывает действий и логики домена

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...?

Как это всё связать с БД?

ActiveRecord || DDD way?

Repository

Плюсы:

  • Описан интерфейсом
  • Интерфейс независим от БД
  • Удобно тестировать
  • Хорошо ложится в DDD

Repository

Минусы:

  • Со старта сложнее
  • Входной порог существенно выше
  • Нужно думать о гидрации

Repository Interface Level

  • Только интерфейсы репозиториев без реализаций
  • Репозитории создаются только для агрегатов
interface InvoiceRepositoryInterface
{
    /** @throws InvoiceNotFoundException when no invoice exists */
    public function findById($id);
    public function add(Invoice $invoice);
    public function update(Invoice $invoice);
}

Реализация InvoiceRepositoryInterface

  • На инфраструктурном слое
  • Реализация работы с БД
  • Реализацией может быть несколько
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();
    }
}

Есть некоторые проблемы

  • Мы перепрыгнули через несколько слоев
  • В контроллере всё еще есть логика
  • Что если будет несколько точек входа?
  • Тесты?

Domain Services Interfaces

  • Интерфейсы для операций над моделью домена
  • Stateless
  • Могут обращаться к репозиториям и другим сервисам
  • Уносят логику из контроллеров
class InvoiceServiceInterface
{
    public function create($clientId);
    public function addLineItem($id, $itemId, $quantity);
    public function reject($id);
    public function process($id);
}

Инфраструктурный слой

  • Реализации репозиториев и сервисов
  • Инфраструктурная логика
  • Работа с БД, IoC контейнером
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;
    }
}

Добавим немного проверок в 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

  • Сделай работу, либо брось исключение
  • Не забывать предупреждать в PHPDoc
  • Название класса описывает проблему
class InvoiceService
{
    function reject()
    {
        $invoice = $this->invoiceRepository->findById($id);
        $invoice->changeStatus(new ProcessingStatus());

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

        return true;
    }
}

Реакция на смену статуса — отправка письма

  • Записать событие при изменении модели
  • Событие — класс
  • Обработать события после сохранения изменений
trait EventTrait
{
    private $events = [];

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

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

        return $events;
    }
}

Смена статуса как событие

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());
    }
}

Что если действие требует много параметров?

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);
    }
}

Параметров многовато

Что делать?
  • Создавать объект Address в контролере
  • Передавать массив свойств
  • Написать еще один класс

DTO

Data Transfer Object

Data Transfer Object

  • Для передачи данных между слоями приложения
  • Не содержит логики
  • Типизирует набор данных
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);
    }
}

Что остаётся для Application?

  • Маршрутизация запросов
  • Конфигурация приложения
  • Конфигурация инфраструктурного слоя

Что у нас получилось?

  • Приложение разделено на слои
  • Модель домена не зависит от БД, фреймворка
  • Код выразительный
  • ООП
  • Каждый слои легко тестируется

Всё ли так хорошо?

Конечно, есть и проблемы

  • Сложно понять — входной порог выше
  • Работа с данными — сложнее
  • Есть много нюансов реализации
  • Cross-cutting concerns
  • Оверхед для простой бизнес-логики
  • Переабстрагирование
  • Сложно натянуть на "бедную" процессами модель

Каноничный DDD на 80% проектов — оверхед, который не стоит потраченных усилий

Но это не повод не учиться.

Think first. DDD — это рекомендации, а не правила.

Не бойтесь создавать много маленьких классов!

Идеи из DDD можно использовать по кусочкам

Даже c ActiveRecord

  • Entity, Aggregate — модель ActiveRecord с ясными методами
  • DTO — формы
  • Services — уносят сложность из контроллеров и моделей
  • Repository — можно заменить ActiveQuery с методами, которые ясно описыают условия

Начните с малого. И еще раз think first.

Почитать

Время вопросов