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.
Name events as facts, in the past tense
Section titled “Name events as facts, in the past tense”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.
using Cratis.Chronicle.Events;
public record ModelingEventsAddress(string Street, string City);
// A fact that happened[EventType]public record ModelingEventsAddressChanged(ModelingEventsAddress Address);
// An intent (that's a command) or a state blob (that's a read model) — not an event[EventType]public record ModelingEventsUpdateAddress(ModelingEventsAddress Address);import io.cratis.chronicle.events.EventType
data class ModelingEventsAddress(val street: String, val city: String)
// A fact that happened@EventType(id = "modeling-events-address-changed")data class ModelingEventsAddressChanged(val address: ModelingEventsAddress)
// An intent (that's a command) or a state blob (that's a read model) — not an event@EventType(id = "modeling-events-update-address")data class ModelingEventsUpdateAddress(val address: ModelingEventsAddress)import io.cratis.chronicle.events.EventType;
record ModelingEventsAddress(String street, String city) {}
// A fact that happened@EventType(id = "modeling-events-address-changed")record ModelingEventsAddressChanged(ModelingEventsAddress address) {}
// An intent (that's a command) or a state blob (that's a read model) — not an event@EventType(id = "modeling-events-update-address")record ModelingEventsUpdateAddress(ModelingEventsAddress address) {}defmodule MyApp.ModelingEventsAddress do defstruct [:street, :city]end
# A fact that happeneddefmodule MyApp.Events.ModelingEventsAddressChanged do use Chronicle.Events.EventType, id: "modeling-events-address-changed"
defstruct [:address]end
# An intent (that's a command) or a state blob (that's a read model) — not an eventdefmodule MyApp.Events.ModelingEventsUpdateAddress do use Chronicle.Events.EventType, id: "modeling-events-update-address"
defstruct [:address]endimport { eventType } from '@cratis/chronicle';
class ModelingEventsAddress { constructor( readonly street: string, readonly city: string ) {}}
// A fact that happened@eventType()class ModelingEventsAddressChanged { constructor(readonly address: ModelingEventsAddress) {}}
// An intent (that's a command) or a state blob (that's a read model) — not an event@eventType()class ModelingEventsUpdateAddress { constructor(readonly address: ModelingEventsAddress) {}}One event, one purpose
Section titled “One event, one purpose”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.
using Cratis.Chronicle.Events;
public record ModelingEventsCustomerName(string Value);public record ModelingEventsEmail(string Value);public record ModelingEventsDeactivationReason(string Value);public record ModelingEventsCustomerAddress(string Street, string City);
// One event trying to be everything — consumers must guess what changed[EventType]public record ModelingEventsCustomerUpdated( string? Name, ModelingEventsCustomerAddress? Address, ModelingEventsEmail? Email, bool? Deactivated);
// Distinct facts — each consumer subscribes to exactly what it cares about[EventType]public record ModelingEventsCustomerRenamed(ModelingEventsCustomerName Name);
[EventType]public record ModelingEventsCustomerAddressChanged(ModelingEventsCustomerAddress Address);
[EventType]public record ModelingEventsCustomerDeactivated(ModelingEventsDeactivationReason Reason);import io.cratis.chronicle.events.EventType
data class ModelingEventsCustomerName(val value: String)data class ModelingEventsEmail(val value: String)data class ModelingEventsDeactivationReason(val value: String)data class ModelingEventsCustomerAddress(val street: String, val city: String)
// One event trying to be everything — consumers must guess what changed@EventType(id = "modeling-events-customer-updated")data class ModelingEventsCustomerUpdated( val name: ModelingEventsCustomerName?, val address: ModelingEventsCustomerAddress?, val email: ModelingEventsEmail?, val deactivated: Boolean?)
// Distinct facts — each consumer subscribes to exactly what it cares about@EventType(id = "modeling-events-customer-renamed")data class ModelingEventsCustomerRenamed(val name: ModelingEventsCustomerName)
@EventType(id = "modeling-events-customer-address-changed")data class ModelingEventsCustomerAddressChanged(val address: ModelingEventsCustomerAddress)
@EventType(id = "modeling-events-customer-deactivated")data class ModelingEventsCustomerDeactivated(val reason: ModelingEventsDeactivationReason)import io.cratis.chronicle.events.EventType;
record ModelingEventsCustomerName(String value) {}record ModelingEventsEmail(String value) {}record ModelingEventsDeactivationReason(String value) {}record ModelingEventsCustomerAddress(String street, String city) {}
// One event trying to be everything — consumers must guess what changed@EventType(id = "modeling-events-customer-updated")record ModelingEventsCustomerUpdated( ModelingEventsCustomerName name, ModelingEventsCustomerAddress address, ModelingEventsEmail email, Boolean deactivated) {}
// Distinct facts — each consumer subscribes to exactly what it cares about@EventType(id = "modeling-events-customer-renamed")record ModelingEventsCustomerRenamed(ModelingEventsCustomerName name) {}
@EventType(id = "modeling-events-customer-address-changed")record ModelingEventsCustomerAddressChanged(ModelingEventsCustomerAddress address) {}
@EventType(id = "modeling-events-customer-deactivated")record ModelingEventsCustomerDeactivated(ModelingEventsDeactivationReason reason) {}defmodule MyApp.ModelingEventsCustomerName do defstruct [:value]end
defmodule MyApp.ModelingEventsEmail do defstruct [:value]end
defmodule MyApp.ModelingEventsDeactivationReason do defstruct [:value]end
defmodule MyApp.ModelingEventsCustomerAddress do defstruct [:street, :city]end
# One event trying to be everything — consumers must guess what changeddefmodule MyApp.Events.ModelingEventsCustomerUpdated do use Chronicle.Events.EventType, id: "modeling-events-customer-updated"
defstruct [:name, :address, :email, :deactivated]end
# Distinct facts — each consumer subscribes to exactly what it cares aboutdefmodule MyApp.Events.ModelingEventsCustomerRenamed do use Chronicle.Events.EventType, id: "modeling-events-customer-renamed"
defstruct [:name]end
defmodule MyApp.Events.ModelingEventsCustomerAddressChanged do use Chronicle.Events.EventType, id: "modeling-events-customer-address-changed"
defstruct [:address]end
defmodule MyApp.Events.ModelingEventsCustomerDeactivated do use Chronicle.Events.EventType, id: "modeling-events-customer-deactivated"
defstruct [:reason]endimport { eventType } from '@cratis/chronicle';
class ModelingEventsCustomerName { constructor(readonly value: string) {}}
class ModelingEventsEmail { constructor(readonly value: string) {}}
class ModelingEventsDeactivationReason { constructor(readonly value: string) {}}
class ModelingEventsCustomerAddress { constructor( readonly street: string, readonly city: string ) {}}
// One event trying to be everything — consumers must guess what changed@eventType()class ModelingEventsCustomerUpdated { constructor( readonly name?: ModelingEventsCustomerName, readonly address?: ModelingEventsCustomerAddress, readonly email?: ModelingEventsEmail, readonly deactivated?: boolean ) {}}
// Distinct facts — each consumer subscribes to exactly what it cares about@eventType()class ModelingEventsCustomerRenamed { constructor(readonly name: ModelingEventsCustomerName) {}}
@eventType()class ModelingEventsCustomerAddressChanged { constructor(readonly address: ModelingEventsCustomerAddress) {}}
@eventType()class ModelingEventsCustomerDeactivated { constructor(readonly reason: ModelingEventsDeactivationReason) {}}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.
using Cratis.Chronicle.Events;
public record ModelingEventsOrderId(Guid Value);public record ModelingEventsMoney(decimal Amount, string Currency);
// Nullable smell — "sometimes there's a discount, sometimes not"[EventType]public record ModelingEventsOrderPlacedWithNullableDiscount( ModelingEventsOrderId Id, ModelingEventsMoney Total, ModelingEventsMoney? Discount);
// Two facts[EventType]public record ModelingEventsOrderPlaced(ModelingEventsOrderId Id, ModelingEventsMoney Total);
[EventType]public record ModelingEventsDiscountApplied(ModelingEventsOrderId Id, ModelingEventsMoney Amount);import io.cratis.chronicle.events.EventType
data class ModelingEventsOrderId(val value: String)data class ModelingEventsMoney(val amount: Double, val currency: String)
// Nullable smell — "sometimes there's a discount, sometimes not"@EventType(id = "modeling-events-order-placed-with-nullable-discount")data class ModelingEventsOrderPlacedWithNullableDiscount( val id: ModelingEventsOrderId, val total: ModelingEventsMoney, val discount: ModelingEventsMoney?)
// Two facts@EventType(id = "modeling-events-order-placed")data class ModelingEventsOrderPlaced(val id: ModelingEventsOrderId, val total: ModelingEventsMoney)
@EventType(id = "modeling-events-discount-applied")data class ModelingEventsDiscountApplied(val id: ModelingEventsOrderId, val amount: ModelingEventsMoney)import io.cratis.chronicle.events.EventType;
record ModelingEventsOrderId(String value) {}record ModelingEventsMoney(double amount, String currency) {}
// Nullable smell — "sometimes there's a discount, sometimes not"@EventType(id = "modeling-events-order-placed-with-nullable-discount")record ModelingEventsOrderPlacedWithNullableDiscount( ModelingEventsOrderId id, ModelingEventsMoney total, ModelingEventsMoney discount) {}
// Two facts@EventType(id = "modeling-events-order-placed")record ModelingEventsOrderPlaced(ModelingEventsOrderId id, ModelingEventsMoney total) {}
@EventType(id = "modeling-events-discount-applied")record ModelingEventsDiscountApplied(ModelingEventsOrderId id, ModelingEventsMoney amount) {}defmodule MyApp.ModelingEventsOrderId do defstruct [:value]end
defmodule MyApp.ModelingEventsMoney do defstruct [:amount, :currency]end
# Nullable smell — "sometimes there's a discount, sometimes not"defmodule MyApp.Events.ModelingEventsOrderPlacedWithNullableDiscount do use Chronicle.Events.EventType, id: "modeling-events-order-placed-with-nullable-discount"
defstruct [:id, :total, :discount]end
# Two factsdefmodule MyApp.Events.ModelingEventsOrderPlaced do use Chronicle.Events.EventType, id: "modeling-events-order-placed"
defstruct [:id, :total]end
defmodule MyApp.Events.ModelingEventsDiscountApplied do use Chronicle.Events.EventType, id: "modeling-events-discount-applied"
defstruct [:id, :amount]endimport { eventType } from '@cratis/chronicle';
class ModelingEventsOrderId { constructor(readonly value: string) {}}
class ModelingEventsMoney { constructor( readonly amount: number, readonly currency: string ) {}}
// Nullable smell — "sometimes there's a discount, sometimes not"@eventType()class ModelingEventsOrderPlacedWithNullableDiscount { constructor( readonly id: ModelingEventsOrderId, readonly total: ModelingEventsMoney, readonly discount?: ModelingEventsMoney ) {}}
// Two facts@eventType()class ModelingEventsOrderPlaced { constructor( readonly id: ModelingEventsOrderId, readonly total: ModelingEventsMoney ) {}}
@eventType()class ModelingEventsDiscountApplied { constructor( readonly id: ModelingEventsOrderId, readonly amount: ModelingEventsMoney ) {}}Capture the decision, not the field write
Section titled “Capture the decision, not the field write”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.
The five-year test
Section titled “The five-year test”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.
Where this leads
Section titled “Where this leads”- Event and Event Type — the mechanics.
- Projections, reducers, and reactors — what consumes your well-modeled events.
- When to use event sourcing — and when modeling events isn’t worth it.