Опирается на правила:
R-AGG-1…R-AGG-5иR-AGG-X1…R-AGG-X4из DDD Tactical Style Guide → раздел 3. Aggregate Root.
Важно знать
- Корень агрегата встраивает
shared.AggregateBase[ID], который даётregisterEventиPullEvents.- Все внешние операции — через методы корня. Внутренние Entity (
OrderLine) наружу возвращаются копией ([]OrderLine, не[]*OrderLine).registerEventприватный — вызывается только методами самого корня. Хендлеры, репозиторий и контроллеры события не регистрируют.PullEvents()возвращает накопленные события и очищает внутренний срез. Вызывается в репозитории после успешногоCommit.- Транзакционная граница = граница агрегата. Один use case меняет один агрегат. Влияние на другие — через события.
- Ссылки на другие агрегаты — только по VO-обёртке над ID (
vo.CustomerID), не*Customer.
Aggregate — кластер Entity и VO, согласованных по инвариантам. Корень — единственная точка входа; внешний код ничего не знает о внутренних Entity напрямую. Раскрытие раздела 3 гайда на Go-стеке.
Embedding AggregateBase[ID]
R-AGG-1: корень встраивает shared.AggregateBase[ID], который в свою очередь встраивает EntityBase[ID] и добавляет срез событий:
// core/shared/building_blocks.go
type AggregateBase[ID comparable] struct {
EntityBase[ID]
events []DomainEvent
}
func NewAggregateBase[ID comparable](id ID) AggregateBase[ID] {
return AggregateBase[ID]{EntityBase: NewEntityBase[ID](id)}
}
func (a *AggregateBase[ID]) registerEvent(ev DomainEvent) {
a.events = append(a.events, ev)
}
func (a *AggregateBase[ID]) PullEvents() []DomainEvent {
evs := make([]DomainEvent, len(a.events))
copy(evs, a.events)
a.events = a.events[:0]
return evs
}
Агрегат Order:
// core/order/aggregate/order.go
package aggregate
import (
"fmt"
"time"
"github.com/google/uuid"
"example.com/svc/core/order/entity"
"example.com/svc/core/order/event"
"example.com/svc/core/order/vo"
"example.com/svc/core/shared"
)
type OrderStatus int
const (
OrderStatusNew OrderStatus = iota + 1
OrderStatusConfirmed
OrderStatusCancelled
)
type Order struct {
shared.AggregateBase[uuid.UUID]
customerID vo.CustomerID
status OrderStatus
lines []*entity.OrderLine
}
Конструктор корня
R-AGG-1, R-AGG-3: конструктор задаёт начальное состояние и регистрирует событие о создании:
func NewOrder(id uuid.UUID, customerID vo.CustomerID, clock func() time.Time) *Order {
o := &Order{
AggregateBase: shared.NewAggregateBase[uuid.UUID](id),
customerID: customerID,
status: OrderStatusNew,
}
o.registerEvent(event.NewOrderCreated(uuid.New(), clock(), id, customerID.Value()))
return o
}
Все операции — через методы корня
R-AGG-2: внешний код не трогает lines напрямую. Любое изменение — через метод Order:
func (o *Order) AddLine(line *entity.OrderLine) error {
if o.status != OrderStatusNew {
return fmt.Errorf("cannot modify order in status %d", o.status)
}
o.lines = append(o.lines, line)
return nil
}
func (o *Order) Confirm(clock func() time.Time) error {
if len(o.lines) == 0 {
return fmt.Errorf("cannot confirm empty order")
}
if o.status != OrderStatusNew {
return fmt.Errorf("order already processed, status %d", o.status)
}
o.status = OrderStatusConfirmed
total := o.total()
o.registerEvent(event.NewOrderConfirmed(
uuid.New(), clock(), o.ID(), o.customerID.Value(),
total.Amount(), total.Currency(),
))
return nil
}
func (o *Order) Cancel(reason string, clock func() time.Time) error {
if o.status == OrderStatusConfirmed {
return fmt.Errorf("confirmed order cannot be cancelled")
}
if o.status == OrderStatusCancelled {
return nil
}
o.status = OrderStatusCancelled
o.registerEvent(event.NewOrderCancelled(uuid.New(), clock(), o.ID(), reason))
return nil
}
Наружу — копия коллекции
R-AGG-X2: возвращать внутренний []*entity.OrderLine — антипаттерн. Клиент получил бы возможность дописывать в срез, обходя инкапсуляцию:
// ПЛОХО
func (o *Order) Lines() []*entity.OrderLine {
return o.lines // клиент может append или изменить элемент
}
// ХОРОШО — копия значениями
func (o *Order) Lines() []entity.OrderLine {
result := make([]entity.OrderLine, 0, len(o.lines))
for _, l := range o.lines {
result = append(result, *l)
}
return result
}
Если нужны геттеры скалярных полей:
func (o *Order) CustomerID() vo.CustomerID { return o.customerID }
func (o *Order) Status() OrderStatus { return o.status }
func (o *Order) total() vo.Money {
var result vo.Money
for _, l := range o.lines {
if m, err := result.Add(l.Subtotal()); err == nil {
result = m
}
}
return result
}
Транзакция = один агрегат
R-AGG-4: один UseCase меняет один агрегат. Хендлер в приложении:
// core/order/usecase/confirm_order_handler.go
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrder) error {
order, err := h.orders.ByID(ctx, cmd.OrderID)
if err != nil {
return err
}
if err := order.Confirm(h.clock); err != nil {
return err
}
return h.orders.Save(ctx, order)
// Save публикует OrderConfirmed через PullEvents
}
Customer, Inventory и другие агрегаты не трогаются в этом хендлере. Их изменения — через события в отдельных транзакциях.
Пример нарушения:
// ПЛОХО — два агрегата в одной транзакции
customer, _ := h.customers.ByID(ctx, order.CustomerID().Value())
customer.IncrementOrderCount() // R-AGG-X3
h.customers.Save(ctx, customer)
order.Confirm(h.clock)
h.orders.Save(ctx, order)
Правильно: OrderConfirmed обрабатывается подписчиком в контексте Customer-агрегата отдельной транзакцией.
Ссылки между агрегатами — только по ID
R-AGG-5:
// ПЛОХО
type Order struct {
shared.AggregateBase[uuid.UUID]
customer *customer.Customer // объект другого агрегата
}
// ХОРОШО
type Order struct {
shared.AggregateBase[uuid.UUID]
customerID vo.CustomerID // VO-обёртка над uuid.UUID
}
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Агрегат с 20+ методами, несвязанными Entity | R-AGG-X1 | Дробить по доменным границам |
return o.lines — возврат внутреннего slice | R-AGG-X2 | Копия []entity.OrderLine |
| Изменение полей другого агрегата напрямую | R-AGG-X3 | registerEvent + подписчик в отдельной транзакции |
registerEvent в хендлере, репозитории, контроллере | R-AGG-X4 | Только в методах самого корня |
Хранение *Customer внутри Order | R-AGG-5 | vo.CustomerID |
Куда дальше
- DDD Tactical → раздел 3. Aggregate Root — нормативные формулировки
R-AGG-*. - go/entity.md — внутренние Entity агрегата.
- go/domain-event.md — структура событий и публикация через
PullEvents. - go/repository.md — как
SaveвызываетPullEventsи публикует события. - go/module-structure.md — куда кладётся
aggregate/в структуре пакетов.