← назад к разделу

В большинстве проектов бизнес-логика постепенно размазывается по сервисам, контроллерам и вспомогательным классам. Через год становится непонятно, где живут правила, кто за что отвечает и почему изменение в одном месте ломает другое.

Тактические паттерны 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, добавляй остальное по необходимости.

Что почитать дальше