Event Design
Events are the most permanent thing you write. Aggregate handlers can be refactored, projections rebuilt, read models dropped. Events live forever in the store. Designing them well is the highest-leverage decision in an event-sourced system.
This page covers what an event is, what it should carry, and what does not belong inside it. Commands & Events covers naming; this page covers payload doctrine.
The mental model: facts the aggregate decided
Business logic in event sourcing has a name. The Decider:
decide : (state, command) → events // pure: what facts result from this request?
evolve : (state, event) → state // pure: how does state change given this fact?State is the input to a decision. Events are the decided facts. The flow is one-way: command in, event out, state next-time-round.
The single most important rule. An event captures the fact the aggregate decided was true at this moment, not the state that resulted from applying it. Anything that is computable from earlier events plus this event does not belong in this event.
One-line test
If you can delete a field from the event and evolve still reconstructs the right state from the stream, the field shouldn't be in the event.
What belongs in an event
A useful event payload is the smallest description that, combined with all earlier events, lets evolve reconstruct state correctly. Concretely:
- The intent of the decision being recorded. "An order was placed." "A book was borrowed."
- Identifiers for related entities (
OrderId,CustomerId,BookId,BorrowerId). IDs are immutable and never wrong to include. - Scalar values that are part of the decision itself: the price at which the order was placed, the quantity ordered, the borrow date.
- Actor and timestamp: who did it (or which system), when it happened.
That's roughly the entire list. Most things people are tempted to include don't belong.
What doesn't belong in an event
Computed or derived state
If a value can be reconstructed by evolve-ing earlier events, it shouldn't be in the new event.
// ❌ Bad. TotalAmount is a fold of earlier ItemAdded events
public record CartCheckedOut(
string PaymentMethod,
decimal TotalAmount,
int ItemCount,
string CartStatus) : CartEvent;
// ✅ Good. Only the decision being recorded
public record CartCheckedOut(string PaymentMethod) : CartEvent;The bad version freezes today's calculation into the event log. If TotalAmount ever has a bug (a tax rule change, a rounding bug), the event log carries the wrong totals forever, and replay produces values that disagree with reality. The good version pushes the calculation into evolve, where you can fix bugs by replaying.
State the aggregate already knows
If an event handler can read it from current state, the new event doesn't need to carry it.
// ❌ Bad. Title is set by BookAdded; carrying it again on every borrow is noise
public record BookBorrowed(
string BorrowerId,
DateTimeOffset BorrowedAt,
string Title,
string Author) : BookEvent;
// ✅ Good. Just the new fact
public record BookBorrowed(string BorrowerId, DateTimeOffset BorrowedAt) : BookEvent;Old/new pairs for UI audit trails
A common temptation is to emit AddressChanged(string OldAddress, string NewAddress) so the audit-trail screen can render "changed from X to Y." This couples event design to a presentation concern.
// ❌ Bad. Old value is in service of the audit UI, not the decision
public record AddressChanged(string OldAddress, string NewAddress) : CustomerEvent;
// ✅ Good. Only the decided fact. The projection holds prior state and computes the diff
public record AddressChanged(string Address) : CustomerEvent;The projection, being a single-threaded left-fold, already has the previous address in hand when it processes the new event. Compute the audit row in the projection, where it belongs.
Status / lifecycle flags
The aggregate's status is implied by the event itself. Don't write it again.
// ❌ Bad. OrderShipped means status is now "Shipped". Saying it twice invites drift
public record OrderShipped(string TrackingNumber, string NewStatus) : OrderEvent;
// ✅ Good. The event name is the status transition
public record OrderShipped(string TrackingNumber) : OrderEvent;Internal aggregate computations
Counters, rolling sums, last-N caches, anything evolve can keep in State. Aggregate state can grow as wide as it needs to inside the aggregate. Only the events themselves are durable.
Fat events: when redundancy is the point
A "Fat Event" carries attributes beyond the decision being recorded. The pattern is sometimes legitimate, sometimes an anti-pattern. The deciding factor is who reads it and what they need.
Legitimate carry-forward
When a downstream consumer (especially in another bounded context) cannot easily fetch a piece of context, perhaps because it would race the producer's projection on first boot, or because the consumer doesn't have a read path back, duplicate that data into the event payload deliberately and document why.
// Legitimate: the integration event publisher in Library context carries the
// book title so the Reporting context can render it without a callback into
// Library's read model. The title was a fact at borrow time.
public record BookBorrowedIntegrationEvent(
string BookId,
string Title, // carry-forward, saves a cross-context read
string BorrowerId,
DateTimeOffset BorrowedAt);The rule: immutable values are safe to duplicate, mutable values are not. An ID is immutable; a customer name is not. Carrying a customer name in an event means six months later the event log says one name and the customer record says another, and consumers cannot tell which is canonical.
When it becomes an anti-pattern
- The carried data changes after the event is emitted. Consumers downstream now hold stale values they think are authoritative.
- The carried data accumulates over time as more consumers ask for "just one more field."
- It becomes unclear which fields are still relied on by which consumers, so producer changes get harder.
If a Fat Event has grown organically over a year, that's the symptom. The cure is to split into a thinner event plus a deliberate query-side read for the rest.
Audit snapshots
A legitimate carry-forward case: when the event records a decision a human or system made based on a presentation (a rendered explanation, a recommendation, a UI screen), the audit requirement is "show me what they actually saw." Re-rendering the underlying data with current code later would produce different text, which is exactly what audit replay must avoid.
Carry the structured input and the rendered output, plus a renderer-version field that signals the value is historical:
public record BookApproved(
string BookId,
string ApproverId,
// Snapshot of the recommender output the approver saw. Structured scores
// plus rendered summary plus renderer version: a future reader can read
// the historical text, re-render with current logic, or compare both.
RecommendationSnapshot? Recommendation = null) : BookEvent;
public record RecommendationSnapshot(
ContentScores Scores,
string RenderedSummary,
string RendererVersion);Distinguish from the Fat Event anti-pattern by asking: does the consumer want the current value or the as-of-then value? Current, carry an ID and look it up. As-of-then, carry-forward is the whole point.
Domain vs integration events
A useful split: domain events (internal to the aggregate's context) stay thin. Integration events (the public contract crossing a boundary) can carry-forward selectively, because the consumer's alternative is a synchronous callback into your context. See Messaging for the mapper pattern.
Granularity: one decision, one event
A command can produce zero, one, or many events. Each event represents one atomic fact. Resist the urge to bundle.
// ❌ Bad. One fat event covering three decisions
public record OrderProcessed(
decimal AmountCharged,
string PaymentTransactionId,
string ShipmentTrackingNumber,
DateTimeOffset ShippedAt) : OrderEvent;
// ✅ Good. Three facts, three events. Each can be subscribed to independently
public record PaymentReceived(decimal Amount, string TransactionId) : OrderEvent;
public record OrderShipped(string TrackingNumber, DateTimeOffset ShippedAt) : OrderEvent;
public record OrderCompleted() : OrderEvent;Then.PersistAll([...]) exists for exactly this case: emit several thin events from one command rather than one fat one.
One decision, not one field
The opposite mistake is Property Sourcing: an event per field change.
// ❌ Bad. Property Sourcing. No business meaning, just CRUD with extra steps
public record FirstNameChanged(string FirstName) : CustomerEvent;
public record LastNameChanged(string LastName) : CustomerEvent;
public record EmailChanged(string Email) : CustomerEvent;
// ✅ Good. The business operation
public record PersonalDetailsUpdated(
string? FirstName,
string? LastName,
string? Email) : CustomerEvent;The thin per-field events have no business meaning. The domain expert doesn't say "the first name was changed." They say "the customer updated their personal details." The events should match the language. Use nullable fields to indicate "this field was untouched."
If your events are field-shaped rather than operation-shaped, you have CRUD with extra ceremony.
Versioning events
Events live forever. So does the schema you wrote them with. Two rules:
- Prefer additive changes. Adding a nullable field to an event is non-breaking. Old events read with the new field defaulted to
null; new events fill it in. Most evolution can be done this way. - For breaking changes, use upcasters. When a field's meaning genuinely changed (rename, type change, semantic shift), keep the old event tag in the store and write an upcaster that transforms old payloads to the new shape on read. See Event Versioning.
What does not work:
- Mutating persisted events. They are immutable, full stop.
- Replacing the event type with a new one of the same name. Future readers cannot tell which version they have.
- Deleting old events. The fold becomes wrong.
Quick audit questions
For each event in your aggregate, ask:
- Is the payload the smallest set of facts needed to record this decision? If you can compute a field from earlier events, drop it.
- Are all carried fields immutable at the time of emission? Mutable values risk going stale relative to the producer.
- Does the event name describe a business operation, not a field change?
- Could this be split into multiple thin events?
- Is anything in the payload only there for the read model or UI? Move it to the projection.
If a payload carries a value that lives in State already, that's a signal. Either the event shouldn't carry it, or State is bigger than it needs to be.