Domain Event Skill
Scope: events as facts — immutable records of something that already happened in the domain. This skill covers naming, schema design, evolution, and publishing through the transactional outbox. Producing events is the job of aggregates invoked by a [[command-handler]]; consuming them safely is [[event-idempotency]] and [[event-projection]].
When to use
Designing the event a new use case will emit, adding an event for a feature, refactoring a status flag or CRUD "updated" notification into proper events, or evolving the schema of an event already in production.
Naming (normative)
- Past tense, domain-meaningful:
OrderPlaced, OrderShipped, PaymentApproved, ReservationCancelled.
- NEVER generic CRUD names:
OrderCreated says nothing a domain expert would say; OrderUpdated has no semantic meaning at all.
- NEVER imperative names:
PlaceOrder is a command (a request that can be rejected); OrderPlaced is a fact (it cannot be rejected by consumers — only reacted to).
- One event = one fact. If a name needs "And", split it.
Schema design
- Envelope (same for every event):
event_id (unique), event_type, event_version (default 1), aggregate_id, occurred_at, correlation_id, causation_id.
- Payload: only what subscribers need to react — not the entire aggregate state.
- Too thin (only an ID) forces consumers to query back, defeating async decoupling.
- Too fat (full aggregate dump) couples every consumer to your internal structure.
- Rule of thumb: the fields a domain expert would mention when describing the fact aloud.
- Self-contained: no references to internal services, no URLs to fetch the "real" data.
- No raw PII — events are retained long-term and replayed; encrypt or tokenize sensitive fields (see crypto-shredding in reference.md).
Schema evolution (normative)
| Change |
Allowed? |
How |
| Add optional field |
Yes |
Compatible; readers ignore unknown fields |
| Remove a field |
Never |
Events are immutable once produced; deprecate, stop writing it |
| Rename a field |
No |
Introduce a new event type (or version) and migrate producers |
| Restructure payload |
No |
New event type/version; upcasters translate old events at read |
Consumers MUST tolerate unknown fields. Producers MUST never break a published shape.
Publishing — transactional outbox (mandatory)
- The aggregate produces the event when its invariant-protected method succeeds.
- The [[command-handler]] persists aggregate state and the event into an
outbox table in the same database transaction.
- A separate relay process (poller or CDC) reads the outbox and publishes to the broker, marking rows as sent.
- This guarantees at-least-once delivery — never zero, possibly more than once. Consumers handle redelivery via [[event-idempotency]].
Never publish directly to the broker from the handler ("dual write"): if the transaction commits but the publish fails (or vice versa), state and stream diverge silently.
Anti-patterns to avoid
- Anemic events carrying only an ID — consumers must query back, recoupling everything synchronously.
- State-dump events (
OrderState) instead of change-expressing facts (OrderShipped).
- Mutable events — editing fields after publish; fix forward with a new event.
- Schema = aggregate internals — exposing private structure couples all consumers to your refactors.
- Dual writes — broker publish outside the state transaction.
- PII in clear text in long-retention streams.
Verification
Going deeper
- reference.md — outbox relay options in depth, fat vs thin event decision table, upcasters, crypto-shredding for PII, schema registries, local-broker tooling.
- examples.md —
OrderPlaced envelope + payload, raising from the aggregate, and the outbox write, in Go and TypeScript.
1---2name: domain-event3description: Domain Event Skill4---5# Domain Event Skill67> **Scope:** events as **facts** — immutable records of something that already happened in the domain. This skill covers naming, schema design, evolution, and publishing through the transactional outbox. Producing events is the job of aggregates invoked by a [[command-handler]]; consuming them safely is [[event-idempotency]] and [[event-projection]].89## When to use1011Designing the event a new use case will emit, adding an event for a feature, refactoring a status flag or CRUD "updated" notification into proper events, or evolving the schema of an event already in production.1213## Naming (normative)1415- **Past tense, domain-meaningful:** `OrderPlaced`, `OrderShipped`, `PaymentApproved`, `ReservationCancelled`.16- NEVER generic CRUD names: `OrderCreated` says nothing a domain expert would say; `OrderUpdated` has no semantic meaning at all.17- NEVER imperative names: `PlaceOrder` is a command (a request that can be rejected); `OrderPlaced` is a fact (it cannot be rejected by consumers — only reacted to).18- One event = one fact. If a name needs "And", split it.1920## Schema design21221. **Envelope** (same for every event): `event_id` (unique), `event_type`, `event_version` (default 1), `aggregate_id`, `occurred_at`, `correlation_id`, `causation_id`.232. **Payload**: only what subscribers need to **react** — not the entire aggregate state.24 - Too thin (only an ID) forces consumers to query back, defeating async decoupling.25 - Too fat (full aggregate dump) couples every consumer to your internal structure.26 - Rule of thumb: the fields a domain expert would mention when describing the fact aloud.273. **Self-contained**: no references to internal services, no URLs to fetch the "real" data.284. **No raw PII** — events are retained long-term and replayed; encrypt or tokenize sensitive fields (see crypto-shredding in [reference.md](reference.md)).2930## Schema evolution (normative)3132| Change | Allowed? | How |33| ------------------- | --------- | -------------------------------------------------------------- |34| Add optional field | Yes | Compatible; readers ignore unknown fields |35| Remove a field | **Never** | Events are immutable once produced; deprecate, stop writing it |36| Rename a field | No | Introduce a new event type (or version) and migrate producers |37| Restructure payload | No | New event type/version; upcasters translate old events at read |3839Consumers MUST tolerate unknown fields. Producers MUST never break a published shape.4041## Publishing — transactional outbox (mandatory)42431. The aggregate produces the event when its invariant-protected method succeeds.442. The [[command-handler]] persists aggregate state **and** the event into an `outbox` table **in the same database transaction**.453. A separate relay process (poller or CDC) reads the outbox and publishes to the broker, marking rows as sent.464. This guarantees **at-least-once** delivery — never zero, possibly more than once. Consumers handle redelivery via [[event-idempotency]].4748Never publish directly to the broker from the handler ("dual write"): if the transaction commits but the publish fails (or vice versa), state and stream diverge silently.4950## Anti-patterns to avoid5152- **Anemic events** carrying only an ID — consumers must query back, recoupling everything synchronously.53- **State-dump events** (`OrderState`) instead of change-expressing facts (`OrderShipped`).54- **Mutable events** — editing fields after publish; fix forward with a new event.55- **Schema = aggregate internals** — exposing private structure couples all consumers to your refactors.56- **Dual writes** — broker publish outside the state transaction.57- **PII in clear text** in long-retention streams.5859## Verification6061- [ ] Name is past tense and a domain expert would recognize it.62- [ ] A subscriber can react without querying the producer back.63- [ ] Envelope carries event_id, aggregate_id, occurred_at, correlation/causation IDs, version.64- [ ] Event is written via the outbox, in the same transaction as state.65- [ ] Reading the event ten years later still makes domain sense.6667## Going deeper6869- **[reference.md](reference.md)** — outbox relay options in depth, fat vs thin event decision table, upcasters, crypto-shredding for PII, schema registries, local-broker tooling.70- **[examples.md](examples.md)** — `OrderPlaced` envelope + payload, raising from the aggregate, and the outbox write, in Go and TypeScript.