DDD Tactical Patterns: aggregates as transactional consistency boundaries, entities with identity, immutable value objects, domain events for cross-aggregate eventual consistency, repository pattern abstracting persistence, factories, application/domain/infrastructure layers. Order aggregate (root + items + Money/Quantity VOs) shows transactional unit; OrderPlaced event triggers Shipment aggregate update in separate TX. Includes scenarios for happy-path aggregate transaction, cross-aggregate via event, repository load/save, and god-aggregate anti-pattern.
Strategic DDD говорит где провести границы (bounded contexts). Tactical DDD отвечает на следующий вопрос: что и как строить внутри контекста. Набор building blocks — entity, value object, aggregate, repository, domain event, factory, domain service — позволяет выразить богатую бизнес-логику без скатывания в anemic domain (entity = DTO + сервис-помойка со всей логикой).
Главная ценность: инварианты живут в одном месте (внутри aggregate), транзакционные границы явные, persistence отделён от поведения, а домен можно тестировать без БД и HTTP.
"Aggregate — это transactional boundary (что меняется атомарно) и consistency boundary (где живут инварианты). Entity имеет identity и lifecycle. Value object не имеет identity и immutable. Domain event публикуется после commit'а аггрегата и используется для обновления других аггрегатов в отдельных транзакциях."
Три железных правила:
order.customerId, не order.customer.address. Иначе границы протекают, lock contention растёт, lazy loading начинает диктовать архитектуру.Три горизонтальных слоя:
Application layer (PlaceOrderUseCase, ConfirmPaymentUseCase) — оркестрация: загрузить aggregate, вызвать метод, сохранить, опубликовать события. Не содержит бизнес-логики, только координацию.
Domain layer — три aggregate'а как отдельные TX-границы:
Order (root) + OrderItem (внутренняя entity) + Money, Quantity (VO)Customer (root) + Address, Email (VO)Payment (root) + Money (VO)Плюс PricingService (domain service — логика, не помещающаяся в одну entity) и OrderFactory (сложное создание).
Infrastructure — OrderRepository, CustomerRepository, PaymentRepository (один repo на aggregate, не на entity) + Domain event bus.
Read side / другие aggregate'ы — ShippingHandler (создаёт Shipment в отдельной TX по событию OrderPlaced) и OrderSummary read model (проекция для запросов, CQRS).
Связи Order → Customer подписаны "by id, not ref" — это главное правило межаггрегатных ссылок. Связи к event bus подписаны "publish events" — обращайте внимание, что publishing происходит после save.
Канонический use case. Application service загружает Order через repository (полная hydration root'а вместе с items и VOs), вызывает domain service для расчёта цены, передаёт результат в order.addItem(...). Метод aggregate'а проверяет инварианты (status == draft, items < 100, валюта Money совпадает) перед мутацией. Затем repository сохраняет весь aggregate одной транзакцией, и только после успешного commit'а publish'ится событие. Порядок критичен: publish до commit = phantom events (событие ушло, а транзакция откатилась).
Eventual consistency между aggregate'ами. Order.place() фиксирует event OrderPlaced в собственном списке. TX 1 коммитит Order. После commit'а событие уходит в bus. ShippingHandler (асинхронный consumer) читает событие и создаёт Shipment в отдельной TX 2. Между TX 1 и TX 2 пользователь может увидеть промежуточное состояние ("заказ оплачен, shipment ещё не создан") — это нормально, UX должен это отражать ("обрабатывается"). Параллельно событие проецируется в OrderSummary read model для быстрых запросов.
Repository как collection-like interface. paymentRepo.findById(id) возвращает полный aggregate, не DTO и не QueryBuilder. У repository нет метода .where(...).join(...) — иначе вызывающий код начнёт писать запросы по внутренней структуре aggregate'а, и encapsulation потечёт. Implementation в infrastructure (Postgres / Mongo / in-memory для тестов), интерфейс — в domain. Один repository = один aggregate type.
Big aggregate kills concurrency. Order со всем графом (10K line items + audit + payments + refunds) грузится 200ms, лочится целиком при любой мутации, упирается в optimistic-lock конфликты на ровном месте. Fix: Order хранит только summary (total, status, customerId), OrderLine становится своим aggregate'ом со ссылкой по id, audit/payments/refunds — отдельные aggregate'ы, синхронизация через события.
Context. При моделировании bounded context "Sales" неизбежен вопрос: где провести границу Order aggregate? Big (Order содержит весь граф — items, payments, shipments, refunds, audit) или small (Order = root + items + VOs, остальное — отдельные aggregate'ы со ссылкой по id)?
Forces.
Decision matrix.
| Invariant | Решение |
|---|---|
Order.total = sum(items.price) — внутри заказа | Items внутри Order aggregate (small) |
Payment.amount = Order.total — между Order и Payment | Eventual через OrderPlaced event |
Cannot add item if status != draft — внутри Order | Items внутри Order (small) |
Refund.amount <= Payment.amount — внутри Payment | Refund внутри Payment aggregate |
Sum(refunds) <= Order.total — между aggregates | Reconcile job + alert, НЕ big |
Inventory >= sum(reserved) — глобально | Inventory aggregate + saga с резервациями |
Decision (default). Small aggregates. Cross-aggregate consistency — eventual через domain events. Big aggregate — только если invariant доказательно требует strong consistency (пакет билетов на мероприятие со seat assignment без race condition). Для UI-агрегации — CQRS read model, не загрузка big aggregate write-side.
Consequences. + Лучшая concurrency, быстрая загрузка, независимое владение командами (Conway), естественный путь к microservices. − Больше repository'ев, eventual consistency требует UX-поддержки ("обрабатывается"), reconcile jobs обязательны, нужен saga / process manager для multi-step flows.
Order = bag of getters/setters, вся логика — в OrderService.addItem(order, ...). Это не DDD, это процедурный код в декорациях.order.customer.address.city — должно быть order.customerId, а Customer грузится через свой repo. Иначе lazy loading начинает диктовать всё.order.findSimilarOrders() — это работа repository / read model, не aggregate.money.amount = 100 — VO должны быть immutable, операции возвращают новые экземпляры.OrderItemRepository — теряется boundary aggregate'а, OrderItem становится доступен в обход Order.PlaceOrderUC вызывает CreateShipmentUC синхронно в той же TX — это hidden big aggregate. Должно идти через event.@Entity @Table(...) прямо на доменной модели — persistence течёт в домен. Маппинг — отдельный слой.setEmail(value) с проверкой — теряется encapsulation. Методы выражают намерение (changeEmail(new Email(value))).