Опирается на правила: R-EVT-1R-EVT-5 и R-EVT-X1R-EVT-X4 из DDD Tactical Style Guide → раздел 4. Domain Event.

Важно знать

  • Domain Event — отдельный тип, реализует интерфейс shared.DomainEvent (EventID, OccurredAt, AggregateID).
  • Struct с приватными полями и только геттерами — поля снаружи не меняются после создания.
  • Имя глаголом в прошедшем времени: OrderConfirmed, ProductPriceChanged, не ConfirmOrderEvent.
  • Событие несёт бизнес-контекст значениями (id, сумма, статус) — не сам агрегат, не Entity.
  • Регистрируется внутри агрегата через registerEvent, публикуется после успешного Save через PullEvents().
  • Критичные side-эффекты (списание, уведомление) — через Outbox в той же транзакции, не горутиной после Commit.
  • R-EVT-X3: публикация из контроллера или хендлера напрямую — антипаттерн.

Domain Event фиксирует факт: «произошло что-то значимое в домене». Другие компоненты подписываются и реагируют, не знания про внутренности агрегата. Раскрытие раздела 4 гайда на Go-стеке.

Интерфейс DomainEvent

// core/shared/building_blocks.go
package shared

import (
    "time"
    "github.com/google/uuid"
)

type DomainEvent interface {
    EventID() uuid.UUID
    OccurredAt() time.Time
    AggregateID() uuid.UUID
}

Каждый конкретный тип события реализует этот интерфейс. Базовая информация достаточна для роутинга и идемпотентности.

Реализация события

R-EVT-1, R-EVT-2, R-EVT-3, R-EVT-4:

// core/order/event/order_confirmed.go
package event

import (
    "time"

    "github.com/google/uuid"
    "github.com/shopspring/decimal"
)

type OrderConfirmed struct {
    eventID    uuid.UUID
    occurredAt time.Time
    orderID    uuid.UUID
    customerID uuid.UUID
    total      decimal.Decimal
    currency   string
}

func NewOrderConfirmed(
    eventID uuid.UUID,
    occurredAt time.Time,
    orderID uuid.UUID,
    customerID uuid.UUID,
    total decimal.Decimal,
    currency string,
) OrderConfirmed {
    return OrderConfirmed{
        eventID:    eventID,
        occurredAt: occurredAt,
        orderID:    orderID,
        customerID: customerID,
        total:      total,
        currency:   currency,
    }
}

func (e OrderConfirmed) EventID() uuid.UUID     { return e.eventID }
func (e OrderConfirmed) OccurredAt() time.Time  { return e.occurredAt }
func (e OrderConfirmed) AggregateID() uuid.UUID { return e.orderID }
func (e OrderConfirmed) CustomerID() uuid.UUID  { return e.customerID }
func (e OrderConfirmed) Total() decimal.Decimal { return e.total }
func (e OrderConfirmed) Currency() string       { return e.currency }

Несколько ключевых моментов:

  • OrderConfirmed — value receiver на геттерах, struct не изменяется (R-EVT-3).
  • Конструктор NewOrderConfirmed заполняет все поля сразу; после этого — только чтение.
  • Нет ссылок на aggregate.Order или entity.OrderLine — только примитивы и ID (R-EVT-X2).
  • total и currency — значениями, не vo.Money (иначе событие зависит от VO-пакета агрегата).

Аналогичный паттерн для начального события:

// core/order/event/order_created.go
package event

import (
    "time"
    "github.com/google/uuid"
)

type OrderCreated struct {
    eventID    uuid.UUID
    occurredAt time.Time
    orderID    uuid.UUID
    customerID uuid.UUID
}

func NewOrderCreated(eventID uuid.UUID, occurredAt time.Time, orderID, customerID uuid.UUID) OrderCreated {
    return OrderCreated{
        eventID:    eventID,
        occurredAt: occurredAt,
        orderID:    orderID,
        customerID: customerID,
    }
}

func (e OrderCreated) EventID() uuid.UUID     { return e.eventID }
func (e OrderCreated) OccurredAt() time.Time  { return e.occurredAt }
func (e OrderCreated) AggregateID() uuid.UUID { return e.orderID }
func (e OrderCreated) CustomerID() uuid.UUID  { return e.customerID }

Регистрация и публикация

R-EVT-3, R-EVT-5: событие регистрируется внутри метода агрегата, публикуется после сохранения:

// Регистрация — в методе корня
func (o *Order) Confirm(clock func() time.Time) error {
    if len(o.lines) == 0 {
        return fmt.Errorf("cannot confirm empty order")
    }
    o.status = OrderStatusConfirmed
    total := o.total()
    o.registerEvent(event.NewOrderConfirmed(
        uuid.New(), clock(), o.ID(), o.customerID.Value(),
        total.Amount(), total.Currency(),
    ))
    return nil
}
// Публикация — в репозитории после Commit
func (r *pgOrderRepository) Save(ctx context.Context, order *aggregate.Order) error {
    tx, err := r.pool.Begin(ctx)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback(ctx)

    if err := upsertOrder(ctx, sqlcgen.New(tx), order); err != nil {
        return err
    }
    if err := tx.Commit(ctx); err != nil {
        return fmt.Errorf("commit: %w", err)
    }

    // После успешного commit — публикуем
    for _, ev := range order.PullEvents() {
        r.publisher.Publish(ctx, ev)
    }
    return nil
}

PullEvents() возвращает события и сбрасывает внутренний срез — повторный вызов даст пустой список, события не дублируются.

Outbox для критичных эффектов

R-EVT-X4: горутина с go publish(ev) после commit — антипаттерн. Если процесс упал между commit и publish — событие потеряно:

// ПЛОХО — горутина после commit
if err := tx.Commit(ctx); err != nil {
    return err
}
go r.publisher.Publish(context.Background(), ev)  // может не выполниться

Для критичных эффектов (финансовые транзакции, уведомления, изменения остатков) — Outbox в той же транзакции:

// ХОРОШО — Outbox-запись в той же транзакции
func (r *pgOrderRepository) Save(ctx context.Context, order *aggregate.Order) error {
    tx, err := r.pool.Begin(ctx)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback(ctx)

    q := sqlcgen.New(tx)
    if err := upsertOrder(ctx, q, order); err != nil {
        return err
    }

    for _, ev := range order.PullEvents() {
        payload, _ := json.Marshal(ev)
        if err := q.InsertOutboxEvent(ctx, sqlcgen.InsertOutboxEventParams{
            ID:        ev.EventID(),
            EventType: fmt.Sprintf("%T", ev),
            Payload:   payload,
        }); err != nil {
            return err
        }
    }

    return tx.Commit(ctx)
}

Отдельный воркер читает outbox и публикует в Kafka/HTTP. At-least-once delivery с идемпотентностью на стороне получателя.

Что запрещено

АнтипаттернПравилоЧто взамен
Публичные поля в struct событияR-EVT-X1Приватные поля + только геттеры
Ссылка на агрегат или Entity в событииR-EVT-X2Только примитивы, VO и uuid
publisher.Publish(ev) из контроллера или хендлераR-EVT-X3registerEvent в корне; публикация в Save через PullEvents
Горутина для публикации после commitR-EVT-X4Outbox-запись в той же транзакции
Имя в настоящем времени (OrderConfirm, ConfirmEvent)R-EVT-2Глагол в прошедшем: OrderConfirmed

Куда дальше