Domain Driven Design

стоит ли игра свеч?



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

Yii core team, HiQDev

Disclaimer

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

План на сегодня

  • Узнать о понятии DDD
  • Познакомиться с элементами DDD
  • Итеративно отрефакторить приложение
  • Рассмотреть преимущества и недостатки
  • Сделать выводы

Сложность

С какой сложностью мы чаще всего сталкиваемся?

Сложность понимания → сложность поддержки

Откуда берётся сложность?

  • Сложность реального мира
  • Гибкость программы
  • Проблема описания поведения системы
  • Работа над проектом с изменяющимися требованиями

Как часто изменяются требования?

Часто

Постоянно

Было бы круто отображать бизнес-процессы в архитектуре и реагировать на это без существенного увеличения сложности.

DDD – Domain Driven Design

Набор подходов для организации кода в системах со сложной предметной областью

Ключевые концепции:

  • Domain
  • Ubiquitous Language (Единый язык домена)
  • Model-Driven Design (Проектирование по модели)
  • Шаблоны проектирования:
    • Entity
    • Value Object
    • Aggregate
    • Repository
    • Service

Домен

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

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

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

Domain experts

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

Наши задачи:

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

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

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

Зачем?

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

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

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

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

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

        $bill = new Bill();
        $bill->client = $client;
        $bill->status = 'new';

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

        $bill->items = $items;
        foreach ($bill->items as $item) {
            $bill->sum += $item->price;
        }
        $bill->save();
        $this->notifyBillCreated($client->email, $bill);
        $this->log->info('Created bill ' . $bill->id);

        return $this->render('bill', ['bill' => $bill]);
    }
}
Само собой, товары в счёт можно добавлять и удалять, если счёт еще не выставлен
class BillController
{
    public function actionAddItem($bill_id, $item_id = null)
    {
        $bill = Bill::find()->byId($bill_id)->one();
        if (!$bill) { throw new NotFoundException('No bill'); }
        if ($bill->status !== 'new') { throw new HttpException(403); }

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

        $bill->items[] = $item;
        $bill->calculateSum();

        $this->log->info('Updated bill ' . $bill->id);

        $bill->save();

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

Ок :)

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

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

Недостатки:

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

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 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->country = $this->validateCountry($country);
        $this->city = $this->validateCity($city);
        $this->zip = $this->validatePhone($zip);
        $this->lines = $this->validateLines($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;
    }

    public function getId() {};

    public function getDescription() {};
    public function setDescription($description) {};

    public function getPrice() {};
    public function setPrice(Money $price) {};

    public function getName() {};
    public function setName() {};
}
$item = new Item(UUID::create(), 'Silver bullet', Money::USD(1000));
$item->setDescription('This bullet is a killing feature!');

Good practices

  • В конструкторе обязательные свойства
  • Необязательные — в сеттеры
  • Закрыто для изменения всё, что не изменяется по логике домена

Entity – Position

class Position
{
    protected $item;
    protected $quantity;

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

    public function getItem() {}

    public function getQuantity() {}
    public function setQuantity($quantity) {}

    public function getPrice() {}
    public function getAmount() {}
}

Агрегат – Bill

class Bill
{
    protected $id;
    protected $client;
    protected $positions;
    protected $status;

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

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

    public function getPositions() {}
    public function setPositions() {}

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

$bill = new Bill(UUID::create(), $client);
$bill->setStatus('new');
$bill->setPositions([new Position($item, $quantity)]);
// Заменим позицию в счёте
$anotherPosition = new Position(
    new Item(UUID::create(), 'Rage cup', Money::USD(500))
);
$bill->setPositions([$anotherPosition]);
$bill->setPositions([$anotherPosition]);
$bill->setStatus('processing');

// VS

$bill->positions = [$anotherPosition];
$bill->status = 'processing';

???

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

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

Value Object – Status

abstract class Status {
    protected $name;
    public function getName() {
        return $this->name;
    }

    protected $next = [];
    public function canBeChangedTo(self $status) {
        return in_array(get_class($status), $this->next, true);
    }

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

        $this->status = $status;
    }

    public function addPosition(Position $position) {
        if (!$this->status->allowsModification()) {
            throw new ModificationProhibitedException();
        }

        $this->positions->push($position);
    }

    public function removePosition(Position $position) {
        // ...
    }
}
            
abstract class Status
{
    // ...

    public function ensureCanBeChangedTo(self $status)
    {
        if (!$this->canBeChangedTo($status)) {
            throw new WrongStatusChangeDirectionException();
        }
    }

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

    public function addPosition(Position $position)
    {
        $this->status->ensureAllowsModification();
        $this->positions->push($position);
    }

    public function removePosition(Position $position)
    {
        $this->status->ensureAllowsModification();
        // ...
    }
}
$bill = new Bill(UUID::create(), $client);

$bill->addPosition(new Position($item, $quantity));
$bill->addPosition($anotherPosition);
$bill->changeStatus(new ProcessingStatus());
// save...

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

ActiveRecord || DDD way?

Repository

Плюсы:

  • Описывается интерфейсом
  • Объекты не зависят от структуры БД и типов полей
  • В рамках доменного слоя реализация не важна
  • Может быть реализован для разных хранилищ
  • Удобно тестировать
  • Хорошо ложится в DDD

Repository

Минусы:

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

Repository Interface Level

  • Только интерфейсы репозиториев без реализаций
  • Репозитории создаются только для агрегатов
interface BillRepositoryInterface
{
    /** @throws BillNotFoundException when no bill exists */
    public function findById($id);
    public function findByStatus(Status $status);
    public function insert(Bill $bill);
    public function update(Bill $bill);
}

Реализация BillRepositoryInterface

  • Относится к инфраструктурному слою
  • Описывает работу с хранилищем
  • Реализацией может быть несколько
class SqlBillRepository implements BillRepositoryInterface
{
    public function __construct(Connection $db) {
        $this->db = $db;
    }

    public function create(Bill $bill) {
        $this->db->transactionBegin();

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

        foreach ($bill->getPositions() as $position) {
            $this->db->insert('bill_position', [
                'item_id' => $position->getItem()->getId(),
                'quantity' => $position->getQuantity(),
            ]);
        }

        $this->db->transactionCommit();
    }
}
class BillController
{
    public function actionCreate($client_id)
    {
        $client = $this->clientRepository->findById($client_id);
        $bill = new Bill(UUID::create(), $client);
        $bill->changeStatus(new NewStatus());
        $this->billRepository->create($bill);

        $this->sendEmail(...);

        return $bill->getId();
    }
}

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

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

Domain Services Interfaces

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

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

  • Содержит реализации репозиториев и сервисов
  • Знает о БД
  • Работает с IoC контейнером
class BillService
{
    public function __construct(
        ClientRepositoryInterface $clientRepository,
        BillRepositoryInterface $billRepository
    ) {
        $this->clientRepository = $clientRepository;
        $this->billRepository = $billRepository;
    }

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

        $bill = new Bill(UUID::create(), $client);
        $bill->changeStatus(new NewStatus());

        $this->billRepository->create($bill);

        return $bill->getId();
    }
}
class BillController
{
    public function actionCreate($client_id)
    {
        $billId = $this->billService->create($client_id);

        return $billId;
    }
}

Что если метод сервиса получается сложным?

class BillService
{
    public function addPosition($billId, $itemId, $quantity)
    {
        $bill = $this->billRepository->findById($billId);
        $item = $this->itemRepository->findById($itemId);

        $bill->addPosition($item, $quantity);

        foreach ($bill->getPositions() as $position) {
            // Merge positions of the same item
        }

        $this->billRepository->update($bill);
    }
}

Можно создавать более специализированные сервисы

class BillOptimizerService
{
    public function mergePositions($bill)
    {
        $this->findSimilarPositions($bill);
    }

    private function findSimilarPositions($bill)
    {
        // ...
    }
}

Что если метод сервиса получается сложным?

class BillService
{
    public function addPosition($billId, $itemId, $quantity)
    {
        $bill = $this->billRepository->findById($billId);
        $item = $this->itemRepository->findById($itemId);

        $bill->addPosition($item, $quantity);
        $this->billOptimizerService->mergePositions($bill);

        $this->billRepository->update($bill);
    }
}

Или отдельные классы, которые хранят состояние для выполнения задачи

class BillPositionsMerger
{
    public function __construct(Bill $bill)
    {
        $this->bill = $bill;
    }

    public function run()
    {
        while ($pairs = $this->findSimilarPositions()) {
            foreach ($pairs as $pair) {
                $this->mergePositions($pair);
            }
        }
    }

    private function findSimilarPositions() { ... }
    private function mergePositions($pair) { ... }
}

Что если метод сервиса получается сложным?

class BillService
{
    public function addPosition($billId, $itemId, $quantity)
    {
        $bill = $this->billRepository->findById($billId);
        $item = $this->itemRepository->findById($itemId);

        $bill->addPosition($item, $quantity);
        (new BillPositionMerger($bill))->run();

        $this->billRepository->update($bill);
    }
}

Добавим немного проверок в BillService

class BillService
{
    public function reject($billId)
    {
        if (($bill = $this->billRepository->findById($id)) === null) {
            return null;
        }

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

        if ($this->billRepository->update($bill) !== false) {
            // аккуратно, там ^^ в случае ошибки возвращается false!
            return null;
        }

        return true;
    }
}

Exceptions

  • Метод либо выполняет работу, либо бросает исключение
  • Не забывать предупреждать об исключениях в PHPDoc
  • Название класса исключения максимально ясно описывает проблему
class BillService
{
    function reject()
    {
        $bill = $this->billRepository->findById($id);
        $bill->changeStatus(new ProcessingStatus());

        $this->billRepository->update($bill);

        return true;
    }
}

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

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

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

        $this->registerEvent(
            new BillStatusWasChangedEvent($bill, $status)
        );
    }

    public function registerEvent($event) { // TODO: можно вынести в trait
        $this->events[] = $event;
    }

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

        return $events;
    }
}
class BillService
{
    public function reject($id)
    {
        $bill = $this->billRepository->findById($id);
        $bill->changeStatus(new ProcessingStatus());

        $this->billRepository->update($bill);

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

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

class ClientService
{
    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 function load($data)
    {
        $this->country = $data['country'];
        // ...
    }

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

        return $client_id;
    }
}
class ClientService
{
    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 на 90% проектов — оверхед, который не стоит потраченных усилий

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

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

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

Даже c ActiveRecord

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

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

Почитать

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