Опирается на правила: R-AGG-1R-AGG-5 и R-AGG-X1R-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+ методами, несвязанными EntityR-AGG-X1Дробить по доменным границам
return o.lines — возврат внутреннего sliceR-AGG-X2Копия []entity.OrderLine
Изменение полей другого агрегата напрямуюR-AGG-X3registerEvent + подписчик в отдельной транзакции
registerEvent в хендлере, репозитории, контроллереR-AGG-X4Только в методах самого корня
Хранение *Customer внутри OrderR-AGG-5vo.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/ в структуре пакетов.