Опирается на правила:
R-EVT-1…R-EVT-5иR-EVT-X1…R-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-X3 | registerEvent в корне; публикация в Save через PullEvents |
| Горутина для публикации после commit | R-EVT-X4 | Outbox-запись в той же транзакции |
Имя в настоящем времени (OrderConfirm, ConfirmEvent) | R-EVT-2 | Глагол в прошедшем: OrderConfirmed |
Куда дальше
- DDD Tactical → раздел 4. Domain Event — нормативные формулировки
R-EVT-*. - go/aggregate-root.md —
registerEventиPullEventsвAggregateBase. - go/repository.md —
Save, Outbox и публикация событий. - Distributed Patterns Style Guide — saga, когда одного события недостаточно.