Skip to content

Modeling events well

Events are the foundation everything else is built on. Get them right and projections, reactors, and read models fall into place. Get them wrong and no amount of clever projection code will save you. This is the guide to getting them right.

The guiding principle behind all of it: an event is a fact — an immutable record of something that happened. Every rule below follows from taking that seriously.

An event states what happened, so its name is a past-tense verb phrase in the language of the domain: OrderPlaced, AddressChanged, PaymentCaptured, BookReturned. Not CreateOrder (that’s a command — an intent), not OrderState (that’s a model). If you can’t name it in the past tense, it isn’t an event yet.

// ✅ A fact that happened
[EventType]
public record AddressChanged(Address Address);
// ❌ An intent (that's a command) or a state blob (that's a read model)
[EventType]
public record UpdateAddress(Address Address);

Each event captures a single, meaningful change. Resist the “kitchen-sink” event that carries everything about an entity with most fields irrelevant on any given change. Multipurpose events force every consumer to figure out which change actually happened.

// ❌ One event trying to be everything — consumers must guess what changed
[EventType]
public record CustomerUpdated(string? Name, Address? Address, Email? Email, bool? Deactivated);
// ✅ Distinct facts — each consumer subscribes to exactly what it cares about
[EventType] public record CustomerRenamed(CustomerName Name);
[EventType] public record AddressChanged(Address Address);
[EventType] public record CustomerDeactivated(DeactivationReason Reason);

Never nullable — if it’s optional, you need a second event

Section titled “Never nullable — if it’s optional, you need a second event”

This is the rule that trips up newcomers most, and it’s the most important. An event records what was true at the moment it happened. A nullable property means “this fact sometimes didn’t happen” — which is a contradiction. If a value is sometimes present and sometimes not, that’s two different facts, so model two events.

// ❌ Nullable smell — "sometimes there's a discount, sometimes not"
[EventType]
public record OrderPlaced(OrderId Id, Money Total, Money? Discount);
// ✅ Two facts
[EventType] public record OrderPlaced(OrderId Id, Money Total);
[EventType] public record DiscountApplied(OrderId Id, Money Amount);

The temptation coming from CRUD is to mirror table columns: a Customer changed, so emit CustomerUpdated. But the value of event sourcing is in the meaning. AddressChanged tells you a customer moved; a generic update tells you nothing. Model the business decision or transition, and the audit trail, analytics, and reactions have something precise to work from.

Carry what was true then — and only that

Section titled “Carry what was true then — and only that”

An event holds the data that was true at the moment it occurred, captured by value. Don’t reference mutable state that might change later, and don’t enrich an event with data a consumer can derive itself. The event should be readable on its own, years from now, without joining against anything.

Before you commit an event type, ask: “Will this still make sense to someone reading it in five years, with no other context?” Clear domain naming and self-contained data are what make that a yes. Events are forever — they’re worth a minute of thought.

Events change by migration, never by editing

Section titled “Events change by migration, never by editing”

When an event type needs to evolve, you don’t rewrite history — you describe how to read old events as the new shape with an event type migration. Prefer adding a new event or field over overloading an existing event; it keeps each fact singular and clear. See the recipe: Evolve an event’s shape.