Hard📖Теория6 min

DDD Tactical Patterns

Entity, Value Object, Aggregate, Repository, Domain Event, Domain Service, Bounded Context -- тактические паттерны Domain-Driven Design

DDD Tactical Patterns

Что такое DDD

Domain-Driven Design (DDD) -- подход к разработке сложного ПО, предложенный Эриком Эвансом (Eric Evans) в 2003 году. DDD делит паттерны на:

  • Стратегические -- как разделить систему на части (Bounded Context, Context Map)
  • Тактические -- как организовать код внутри каждой части (Entity, VO, Aggregate)

В этой статье -- тактические паттерны, применяемые на уровне кода.

Карта тактических паттернов

┌─────────────────────────────────────────────────────┐
│                 BOUNDED CONTEXT                     │
│                                                     │
│  ┌───────────────────────────────────────────────┐  │
│  │              AGGREGATE ROOT                   │  │
│  │                                               │  │
│  │  ┌──────────┐  ┌──────────────┐              │  │
│  │  │  Entity  │  │ Value Object │              │  │
│  │  │  (Order) │  │   (Money)    │              │  │
│  │  └──────────┘  └──────────────┘              │  │
│  │                                               │  │
│  │  ┌──────────┐  ┌──────────────┐              │  │
│  │  │  Entity  │  │ Value Object │              │  │
│  │  │(OrderLine│  │  (Address)   │              │  │
│  │  └──────────┘  └──────────────┘              │  │
│  │                                               │  │
│  └─────────────────────┬─────────────────────────┘  │
│                        │                             │
│  ┌─────────────┐  ┌────▼──────┐  ┌───────────────┐  │
│  │  Domain     │  │  Domain   │  │  Repository   │  │
│  │  Service    │  │  Event    │  │  Interface    │  │
│  └─────────────┘  └───────────┘  └───────────────┘  │
│                                                     │
└─────────────────────────────────────────────────────┘

Entity (сущность)

Entity -- объект с уникальной идентичностью, которая сохраняется на протяжении всего жизненного цикла. Два объекта с одинаковыми атрибутами, но разными ID -- разные сущности.

Признаки Entity

Признак Описание
Идентичность Имеет уникальный ID (UUID)
Жизненный цикл Создаётся, изменяется, удаляется
Мутабельность Состояние может меняться
Сравнение по ID Равенство определяется идентификатором

Пример на PHP

<?php

declare(strict_types=1);

namespace App\Domain\Entity;

use App\Domain\ValueObject\OrderId;
use App\Domain\ValueObject\Money;
use App\Domain\ValueObject\OrderStatus;

final class Order
{
    /** @var OrderLine[] */
    private array $lines = [];
    private OrderStatus $status;

    public function __construct(
        private readonly OrderId $id,
        private readonly string $customerId,
        private readonly \DateTimeImmutable $createdAt,
    ) {
        $this->status = OrderStatus::Draft;
    }

    public function addLine(string $productId, int $quantity, Money $price): void
    {
        if ($this->status !== OrderStatus::Draft) {
            throw new \DomainException('Can only add lines to draft orders');
        }

        $this->lines[] = new OrderLine(
            productId: $productId,
            quantity: $quantity,
            unitPrice: $price,
        );
    }

    public function totalAmount(): Money
    {
        return array_reduce(
            $this->lines,
            fn(Money $total, OrderLine $line) => $total->add($line->lineTotal()),
            Money::zero('RUB'),
        );
    }

    public function confirm(): void
    {
        if ($this->status !== OrderStatus::Draft) {
            throw new \DomainException('Only draft orders can be confirmed');
        }

        if (empty($this->lines)) {
            throw new \DomainException('Cannot confirm empty order');
        }

        $this->status = OrderStatus::Confirmed;
    }

    // Equality by identity, NOT by attributes
    public function equals(self $other): bool
    {
        return $this->id->equals($other->id);
    }

    public function getId(): OrderId { return $this->id; }
    public function getStatus(): OrderStatus { return $this->status; }
}

Value Object (объект-значение)

Value Object -- объект без идентичности, определяемый только своими атрибутами. Два VO с одинаковыми атрибутами -- идентичны.

Признаки Value Object

Признак Описание
Нет ID Определяется только значениями
Иммутабельность Нельзя изменить после создания
Самовалидация Проверяет инварианты в конструкторе
Сравнение по значениям Два VO с одинаковыми данными равны
Заменяемость Вместо изменения -- создание нового

Пример на PHP

<?php

declare(strict_types=1);

namespace App\Domain\ValueObject;

// Value Object: immutable, self-validating, compared by value
final readonly class Money
{
    public function __construct(
        private int $amount,      // Amount in minor units (kopecks)
        private string $currency, // ISO 4217 code
    ) {
        if ($amount < 0) {
            throw new \InvalidArgumentException('Amount cannot be negative');
        }

        if (!in_array($currency, ['RUB', 'USD', 'EUR'], true)) {
            throw new \InvalidArgumentException("Unsupported currency: {$currency}");
        }
    }

    public static function zero(string $currency): self
    {
        return new self(0, $currency);
    }

    public static function fromAmount(int $amount, string $currency): self
    {
        return new self($amount, $currency);
    }

    // Returns NEW object (immutable)
    public function add(self $other): self
    {
        $this->assertSameCurrency($other);
        return new self($this->amount + $other->amount, $this->currency);
    }

    public function multiply(float $factor): self
    {
        return new self((int) round($this->amount * $factor), $this->currency);
    }

    public function isGreaterThan(self $other): bool
    {
        $this->assertSameCurrency($other);
        return $this->amount > $other->amount;
    }

    // Equality by VALUE, not by reference
    public function equals(self $other): bool
    {
        return $this->amount === $other->amount
            && $this->currency === $other->currency;
    }

    public function getAmount(): int { return $this->amount; }
    public function getCurrency(): string { return $this->currency; }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new \DomainException('Cannot operate on different currencies');
        }
    }
}

Другие примеры Value Objects

<?php

declare(strict_types=1);

// Email as Value Object
final readonly class Email
{
    public function __construct(
        private string $value,
    ) {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException("Invalid email: {$value}");
        }
    }

    public function equals(self $other): bool
    {
        return strtolower($this->value) === strtolower($other->value);
    }

    public function __toString(): string { return $this->value; }
}

// Address as Value Object
final readonly class Address
{
    public function __construct(
        private string $street,
        private string $city,
        private string $postalCode,
        private string $country,
    ) {
        if (empty($street) || empty($city)) {
            throw new \InvalidArgumentException('Street and city are required');
        }
    }

    public function equals(self $other): bool
    {
        return $this->street === $other->street
            && $this->city === $other->city
            && $this->postalCode === $other->postalCode
            && $this->country === $other->country;
    }
}

Aggregate (агрегат)

Aggregate -- кластер связанных объектов, которые обрабатываются как единое целое. Aggregate Root -- единственная точка входа.

Правила агрегатов

    ┌──────────────────────────────────────────────┐
    │              AGGREGATE                       │
    │         (граница транзакции)                  │
    │                                              │
    │  ┌────────────────────┐                      │
    │  │  AGGREGATE ROOT    │ ← Единственная точка │
    │  │    (Order)         │   доступа извне      │
    │  └────────┬───────────┘                      │
    │           │                                  │
    │     ┌─────┼──────┐                           │
    │     │     │      │                           │
    │  ┌──▼──┐ ┌▼────┐ ┌▼─────┐                   │
    │  │Line │ │Line │ │Line  │  ← Внутренние     │
    │  │  1  │ │  2  │ │  3   │    сущности       │
    │  └─────┘ └─────┘ └──────┘                   │
    │                                              │
    │  ПРАВИЛА:                                    │
    │  1. Внешний доступ ТОЛЬКО через Root         │
    │  2. Одна транзакция = один агрегат           │
    │  3. Ссылки между агрегатами -- только по ID  │
    │  4. Eventual consistency между агрегатами     │
    └──────────────────────────────────────────────┘
Правило Описание
Single Root Только Aggregate Root доступен извне
Транзакционная граница Один агрегат = одна транзакция
Ссылки по ID Между агрегатами -- только ID, не объекты
Маленький размер Агрегат должен быть как можно меньше
Eventual consistency Согласованность между агрегатами -- через события

Пример -- Order как Aggregate Root

<?php

declare(strict_types=1);

namespace App\Domain\Entity;

// Aggregate Root: Order manages OrderLines internally
final class Order
{
    /** @var OrderLine[] */
    private array $lines = [];
    private array $domainEvents = [];

    public function __construct(
        private readonly OrderId $id,
        private readonly string $customerId, // Reference by ID, not by object
        private OrderStatus $status = OrderStatus::Draft,
    ) {}

    // All operations go through the Root
    public function addLine(string $productId, int $quantity, Money $price): void
    {
        // Root enforces invariants
        if ($this->status !== OrderStatus::Draft) {
            throw new \DomainException('Cannot modify non-draft order');
        }

        if (count($this->lines) >= 50) {
            throw new \DomainException('Maximum 50 lines per order');
        }

        $this->lines[] = new OrderLine($productId, $quantity, $price);
    }

    public function removeLine(int $index): void
    {
        if ($this->status !== OrderStatus::Draft) {
            throw new \DomainException('Cannot modify non-draft order');
        }

        if (!isset($this->lines[$index])) {
            throw new \DomainException("Line {$index} not found");
        }

        array_splice($this->lines, $index, 1);
    }

    public function confirm(): void
    {
        if (empty($this->lines)) {
            throw new \DomainException('Cannot confirm empty order');
        }

        $this->status = OrderStatus::Confirmed;

        // Publish domain event for other aggregates
        $this->domainEvents[] = new OrderConfirmedEvent(
            orderId: (string) $this->id,
            customerId: $this->customerId,
            totalAmount: $this->totalAmount()->getAmount(),
        );
    }
}

Repository (репозиторий)

Repository -- абстракция доступа к коллекции агрегатов. Выглядит как in-memory коллекция, скрывая детали хранения.

Правила репозиториев

Правило Описание
Один Repository = один Aggregate Не делайте Repository для внутренних Entity
Интерфейс в Domain Реализация в Infrastructure
Коллекционная семантика add(), find(), remove() вместо insert(), select(), delete()
Возвращает Aggregate целиком Не возвращайте частичные данные
<?php

declare(strict_types=1);

namespace App\Domain\Port;

use App\Domain\Entity\Order;
use App\Domain\ValueObject\OrderId;

// Domain layer: interface only
interface OrderRepositoryInterface
{
    public function add(Order $order): void;

    public function findById(OrderId $id): ?Order;

    public function remove(Order $order): void;

    public function nextIdentity(): OrderId;
}

Domain Event (доменное событие)

Domain Event -- запись о том, что произошло в домене. Именуется в прошедшем времени: OrderPlaced, PaymentReceived.

    Aggregate                  Domain Events
    ┌─────────┐
    │  Order  │───confirm()──▶ OrderConfirmedEvent
    │         │                    │
    │         │                    ├──▶ InventoryService
    │         │                    │    (резервирует товар)
    │         │                    │
    │         │                    ├──▶ NotificationService
    │         │                    │    (отправляет email)
    │         │                    │
    │         │                    └──▶ AnalyticsService
    └─────────┘                         (считает метрики)
<?php

declare(strict_types=1);

namespace App\Domain\Event;

// Domain Event: immutable record of something that happened
final readonly class OrderConfirmedEvent
{
    public readonly \DateTimeImmutable $occurredAt;

    public function __construct(
        public string $orderId,
        public string $customerId,
        public int $totalAmount,
    ) {
        $this->occurredAt = new \DateTimeImmutable();
    }
}

// Event handler in another Bounded Context
final readonly class ReserveInventoryOnOrderConfirmed
{
    public function __construct(
        private InventoryServiceInterface $inventoryService,
    ) {}

    public function __invoke(OrderConfirmedEvent $event): void
    {
        $this->inventoryService->reserve(
            orderId: $event->orderId,
            amount: $event->totalAmount,
        );
    }
}

Domain Service (доменный сервис)

Domain Service содержит бизнес-логику, которая не принадлежит ни одной Entity. Например, расчёт стоимости доставки зависит от заказа, адреса и тарифов перевозчика.

<?php

declare(strict_types=1);

namespace App\Domain\Service;

use App\Domain\Entity\Order;
use App\Domain\ValueObject\Address;
use App\Domain\ValueObject\Money;

// Domain Service: logic that doesn't belong to a single Entity
final readonly class ShippingCostCalculator
{
    public function calculate(Order $order, Address $destination): Money
    {
        $baseRate = match (true) {
            $destination->getCountry() === 'RU' => 500,  // 5 RUB base
            $destination->getCountry() === 'BY' => 1500, // 15 RUB base
            default => 3000,                              // 30 RUB base
        };

        $totalAmount = $order->totalAmount()->getAmount();

        // Free shipping for orders over 5000 RUB
        if ($totalAmount >= 500_000) {
            return Money::zero('RUB');
        }

        // Weight-based surcharge
        $weightSurcharge = count($order->getLines()) * 100;

        return Money::fromAmount($baseRate + $weightSurcharge, 'RUB');
    }
}

Bounded Context (ограниченный контекст)

Bounded Context -- граница, внутри которой термины домена имеют однозначное значение. Один и тот же "клиент" в контексте продаж и в контексте доставки -- разные модели.

    ┌──────────────────┐        ┌──────────────────┐
    │   SALES CONTEXT  │        │ SHIPPING CONTEXT  │
    │                  │        │                   │
    │  Customer:       │        │  Customer:        │
    │  - id            │        │  - id             │
    │  - name          │        │  - address        │
    │  - creditLimit   │        │  - phone          │
    │  - discount      │        │  - deliveryNotes  │
    │                  │        │                   │
    │  Order:          │  Event │  Shipment:        │
    │  - lines[]       │───────▶│  - packages[]     │
    │  - totalAmount   │        │  - trackingNumber │
    │  - payment       │        │  - estimatedDate  │
    │                  │        │                   │
    └──────────────────┘        └──────────────────┘
           │                            │
    Ubiquitous Language          Ubiquitous Language
    разная!                      разная!

Context Map -- связи между контекстами

Паттерн связи Описание Пример
Shared Kernel Общий код между контекстами Общие Value Objects
Customer/Supplier Один контекст зависит от другого Orders → Inventory
Conformist Полностью подчиняется upstream Интеграция с внешним API
Anti-Corruption Layer Защитный слой от внешней модели Legacy → New System
Published Language Общедоступный формат обмена OpenAPI, Protobuf

Entity vs Value Object -- как выбрать

Нужна ли уникальная идентичность?
├── ДА → Entity
│   Примеры: User, Order, Product, Account
│
└── НЕТ → Value Object
    Может ли объект быть заменён идентичным?
    ├── ДА → Value Object
    │   Примеры: Money, Email, Address, DateRange
    │
    └── НЕТ → Скорее всего Entity
Критерий Entity Value Object
Идентичность Есть (UUID) Нет
Мутабельность Мутабельный Иммутабельный
Сравнение По ID По значениям
Жизненный цикл Долгий Короткий
Примеры User, Order, Invoice Money, Email, Address

Кто использует DDD

Компания Контекст
Spotify Bounded Contexts для Squads
Zalando Platform-as-Product с DDD
ThoughtWorks Стандартный подход для enterprise
Volkswagen Digital Platform на DDD
ING Bank Banking domain на DDD + Event Sourcing

Проверь себя

🧪

Когда нужен Domain Service?

🧪

Почему Value Object должен быть иммутабельным?

🧪

Что такое Bounded Context?

🧪

Какое главное правило Aggregate?

🧪

Чем Entity отличается от Value Object?