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 |