В большинстве проектов бизнес-логика постепенно размазывается по сервисам, контроллерам и вспомогательным классам. Через год становится непонятно, где живут правила, кто за что отвечает и почему изменение в одном месте ломает другое.
Тактические паттерны DDD дают структуру для организации этой логики. Каждый паттерн отвечает на конкретный вопрос: что является объектом с уникальной идентичностью, что — просто значением, где граница консистентности, как части системы общаются через события.
Entity — объект с идентичностью
Представьте пользователя в системе. У него есть имя и email — они могут меняться. Но пользователь остаётся тем же пользователем, даже если поменял всё. Его идентичность — это идентификатор, а не набор полей.
Entity — объект, у которого есть уникальный идентификатор. Два объекта с одинаковыми полями, но разными ID — разные сущности.
Ключевые свойства:
- Есть стабильный ID (не меняется в течение жизни объекта).
- Состояние может меняться — но только через методы, которые проверяют бизнес-правила.
- Равенство по ID, не по полям.
- Содержит поведение, а не только данные.
Частые примеры: User, Order, Invoice, Shipment, Contract.
Типичная ошибка — «анемичная» Entity: структура с геттерами и сеттерами без правил. Тогда бизнес-логика утекает в сервисы, а объект становится структурой данных.
var ErrInactiveUser = errors.New("inactive user cannot change email")
type User struct {
id uuid.UUID
email Email
name string
active bool
}
func NewUser(id uuid.UUID, email Email, name string) (*User, error) {
if id == uuid.Nil {
return nil, errors.New("id is required")
}
if email.IsZero() {
return nil, errors.New("email is required")
}
if strings.TrimSpace(name) == "" {
return nil, errors.New("name is required")
}
return &User{id: id, email: email, name: name, active: true}, nil
}
func (u *User) ChangeEmail(newEmail Email) error {
if !u.active {
return ErrInactiveUser
}
u.email = newEmail
return nil
}
func (u *User) Deactivate() { u.active = false }
func (u *User) Equals(other *User) bool {
return other != nil && u.id == other.id
}
Метод ChangeEmail — не просто сеттер. Он проверяет бизнес-правило: неактивный пользователь не может менять email. Это ключевое отличие от простой записи.
Value Object — объект без идентичности
Деньги — хороший пример. Две купюры по 100 рублей одинаковы — нас не интересует, «та же» ли это купюра или другая. Главное — сумма и валюта.
Value Object — объект, у которого нет собственного ID. Равенство определяется по значениям полей. После создания не меняется (иммутабельный).
Ключевые свойства:
- Нет ID.
- Иммутабельный: вместо изменения создаётся новый объект.
- Равенство по всем значимым полям.
- Проверяет свои инварианты при создании.
Частые примеры: Money, Email, PhoneNumber, Address, DateRange.
Зачем нужны Value Object: они убирают «примитивную одержимость» — когда вместо типа Email везде ходит строка string. Правила формата, валидация и операции живут внутри типа, а не разбросаны по коду.
var ErrCurrencyMismatch = errors.New("currency mismatch")
type Money struct {
amount decimal.Decimal
currency Currency
}
func NewMoney(amount decimal.Decimal, currency Currency) (Money, error) {
if currency == "" {
return Money{}, errors.New("currency is required")
}
return Money{amount: amount.Round(2), currency: currency}, nil
}
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, ErrCurrencyMismatch
}
return Money{amount: m.amount.Add(other.amount), currency: m.currency}, nil
}
func (m Money) Equals(other Money) bool {
return m.amount.Equal(other.amount) && m.currency == other.currency
}
Метод Add возвращает новый объект — исходный не меняется: получатель у метода значение, а не указатель. Проверка совместимости валют встроена в операцию, а не лежит где-то снаружи.
Aggregate — граница консистентности
Вот реальная проблема: у заказа есть строки. Можно ли изменить строку напрямую, минуя заказ? Формально — да. Но тогда заказ оказывается в некорректном состоянии: например, сумма не пересчитана, или добавили строку в уже подтверждённый заказ.
Aggregate — это кластер Entity и Value Object с единой границей консистентности. Снаружи виден только корень (Aggregate Root) — только через него можно менять состояние агрегата.
Правила:
- Все изменения внутри агрегата идут через корень.
- Нельзя хранить прямые ссылки на внутренние объекты другого агрегата — только ID.
- Агрегат должен быть небольшим. «Бог-агрегат» с десятками объектов внутри — антипаттерн.
var (
ErrOrderNotEditable = errors.New("order is not editable")
ErrOrderHasNoLines = errors.New("order has no lines")
)
type Order struct {
id OrderID
customerID CustomerID
lines []OrderLine
status OrderStatus
}
func (o *Order) AddLine(productID ProductID, qty int, price Money) error {
if err := o.ensureDraft(); err != nil {
return err
}
if qty <= 0 {
return errors.New("qty must be > 0")
}
o.lines = append(o.lines, NewOrderLine(productID, qty, price))
return nil
}
func (o *Order) Confirm() error {
if err := o.ensureDraft(); err != nil {
return err
}
if len(o.lines) == 0 {
return ErrOrderHasNoLines
}
o.status = OrderStatusConfirmed
return nil
}
func (o *Order) Total() Money {
total := ZeroMoney(CurrencyRUB)
for _, line := range o.lines {
total, _ = total.Add(line.Subtotal()) // валюты совпадают — инвариант агрегата
}
return total
}
func (o *Order) ensureDraft() error {
if o.status != OrderStatusDraft {
return ErrOrderNotEditable
}
return nil
}
OrderLine — внутренний объект агрегата. Снаружи его напрямую не трогают — только через методы Order.
Domain Event — что произошло в домене
Когда заказ оплачен, нужно: отправить письмо, уведомить склад, начислить бонусы. Один способ — вызвать всё это прямо в методе Pay(). Но тогда Order знает про email, склад и бонусы — это нарушает границы ответственности.
Domain Event — объект, который фиксирует факт: что-то значимое произошло в домене. Агрегат публикует событие, а другие части системы реагируют самостоятельно.
Характеристики:
- Иммутабельный — факт в прошлом нельзя изменить.
- Именование в прошедшем времени:
OrderPaid,UserRegistered, неPayOrder. - Несёт контекст:
orderId,amount,paidAt— не просто маркер. - Агрегат накапливает события и отдаёт их после сохранения.
type DomainEvent interface {
EventID() uuid.UUID
OccurredAt() time.Time
}
type BaseEvent struct {
eventID uuid.UUID
occurredAt time.Time
}
func NewBaseEvent() BaseEvent {
return BaseEvent{eventID: uuid.New(), occurredAt: time.Now()}
}
func (e BaseEvent) EventID() uuid.UUID { return e.eventID }
func (e BaseEvent) OccurredAt() time.Time { return e.occurredAt }
type OrderPaid struct {
BaseEvent
orderID uuid.UUID
amount decimal.Decimal
}
func NewOrderPaid(orderID uuid.UUID, amount decimal.Decimal) OrderPaid {
return OrderPaid{BaseEvent: NewBaseEvent(), orderID: orderID, amount: amount}
}
type AggregateRoot struct {
domainEvents []DomainEvent
}
func (a *AggregateRoot) RegisterEvent(event DomainEvent) {
a.domainEvents = append(a.domainEvents, event)
}
func (a *AggregateRoot) DomainEvents() []DomainEvent {
return slices.Clone(a.domainEvents)
}
func (a *AggregateRoot) ClearEvents() {
a.domainEvents = nil
}
Агрегат вызывает RegisterEvent(NewOrderPaid(...)) при изменении состояния. После сохранения агрегата репозиторий забирает накопленные события и публикует их — это гарантирует, что события уходят только после успешной записи.
Внутренние события живут внутри одного bounded context, часто в одной транзакции. Внешние пересекают границы через брокер сообщений (Kafka, RabbitMQ) и требуют сериализации.
Repository — доступ к агрегатам
Как правило, когда нужно сохранить заказ, пишут SQL прямо в сервисе. Запросы выходят за пределы бизнес-логики, привязываются к конкретной БД, и поменять хранилище становится дорого.
Repository — абстракция доступа к хранилищу агрегатов. Он создаёт иллюзию коллекции объектов в памяти: найти, сохранить. Интерфейс живёт в доменном пакете, реализация — в инфраструктуре.
Ключевые правила:
- Один репозиторий — один агрегат.
- Загружает и сохраняет агрегат целиком.
- Методы в терминах домена:
FindByID,Save— не SQL. - Интерфейс в домене, реализация в инфраструктуре (инверсия зависимостей).
// Интерфейс — в доменном пакете internal/domain
package domain
var ErrOrderNotFound = errors.New("order not found")
type OrderRepository interface {
FindByID(ctx context.Context, id OrderID) (*Order, error)
Save(ctx context.Context, order *Order) error
}
// Реализация — в инфраструктуре internal/adapter/postgres,
// публикует события после сохранения
package postgres
type OrderRepository struct {
pool *pgxpool.Pool
eventPublisher EventPublisher
}
func (r *OrderRepository) Save(ctx context.Context, order *domain.Order) error {
if err := r.insertOrder(ctx, order); err != nil {
return err
}
for _, event := range order.DomainEvents() {
if err := r.eventPublisher.Publish(ctx, event); err != nil {
return err
}
}
order.ClearEvents()
return nil
}
Разница с DAO: Repository работает с агрегатом целиком и думает на языке домена. DAO работает с таблицей и думает на языке хранилища.
Domain Service — логика между агрегатами
Иногда операция не принадлежит ни одному агрегату. Перевод денег между счетами: нужно снять с одного и зачислить на другой. Ни Account-от-отправителя, ни Account-получателя не должны знать друг о друге.
Domain Service — место для бизнес-логики, которая координирует несколько агрегатов или объектов домена.
Правило: сначала попробуй поместить логику в Entity или Aggregate Root. Domain Service — если логика действительно не принадлежит ни одному из них.
type TransferService struct{}
func (s TransferService) Transfer(from, to *Account, amount Money) error {
if err := from.Withdraw(amount); err != nil {
return err
}
return to.Deposit(amount)
}
Domain Service живёт в доменном пакете и знает только про доменные объекты. Не путать с Application Service — тот живёт в слое приложения и оркестрирует: загрузить агрегат, вызвать доменную логику, сохранить.
Factory — создание агрегатов
Иногда создание агрегата — это не просто вызов конструктора. Нужно проверить бизнес-правила, сгенерировать ID, собрать объект из нескольких частей.
Factory инкапсулирует логику создания агрегата. Если конструктор NewX справляется — фабрика не нужна.
var ErrCustomerBlocked = errors.New("blocked customer cannot place orders")
type OrderFactory struct{}
func (f OrderFactory) CreateNew(customer *Customer) (*Order, error) {
if customer.IsBlocked() {
return nil, ErrCustomerBlocked
}
return NewOrder(GenerateOrderID(), customer.ID()), nil
}
Здесь проверяется бизнес-правило («заблокированный клиент не может оформить заказ») до создания агрегата. Сам конструктор NewOrder не знает про Customer — это разделение обязанностей.
Коротко
- Entity — объект с уникальным ID; равенство по ID; содержит поведение и проверяет инварианты.
- Value Object — нет ID, иммутабельный, равенство по значениям; убирает «примитивную одержимость».
- Aggregate — кластер объектов с единой границей консистентности; снаружи виден только корень.
- Domain Event — иммутабельный факт в прошедшем времени; агрегат накапливает события, репозиторий публикует их после сохранения.
- Repository — абстракция хранилища агрегатов; интерфейс в домене, реализация в инфраструктуре.
- Domain Service — бизнес-логика, которая не принадлежит ни одному агрегату; крайнее средство, не первый выбор.
- Factory — создание агрегата со сложной логикой; если конструктор справляется — не нужна.
- Не нужно применять все паттерны сразу. Начни с Entity и Value Object, добавляй остальное по необходимости.
Что почитать дальше
- Что такое DDD и зачем он нужен — стратегический уровень: bounded context, ubiquitous language.
- Стратегические паттерны DDD — как делить систему на контексты и выстраивать отношения между ними.
- Интеграционные паттерны DDD — как bounded contexts общаются между собой.