Event
An event records something that happened: AccountOpened, MoneyDeposited, MoneyWithdrawn. It’s named in the past tense because, by the time anyone sees it, it’s a fact.
Events are the only thing an aggregate stores as its history. Its state, every view, and every policy’s reaction are all derived from them.
Events are the business’s memory
Section titled “Events are the business’s memory”Businesses ran on events long before software. Take a bank’s ledger. It’s a list of things that happened, written in order and never erased: opened, deposited 100, withdrew 30. The balance at the bottom of the page is a sum of the lines above it. When the bank finds a mistake, it doesn’t rub out a line; it writes a new one that corrects it.
An event-sourced model keeps its history the same way. Each event is one line in the ledger of one aggregate instance. Everything else, the account’s state, its statement, the customer’s dashboard, is worked out from those lines. Because nothing is overwritten, you can always answer “how did it get like this?”, and a question nobody thought to ask last year can be answered from history you already have.
Find the events
Section titled “Find the events”To find a business’s events, ask its people to tell the story of their work as a series of things that happened, in order. At a bank, it might go: “The customer registers. Their main account is opened. They deposit their pay. They withdraw cash. Late at night they withdraw again, and the account is frozen for review.” Each sentence in the past tense is an event: CustomerRegistered, AccountOpened, MoneyDeposited, MoneyWithdrawn, AccountFrozen.
Telling it as a story surfaces the things people care about, in their own words, and in the order they happen. The commands fall out next: for each event, ask what someone asked for to make it happen. The aggregates come after that: for each command, ask what has to be checked before it’s allowed, and what state that check needs.
Name what the business saw
Section titled “Name what the business saw”Name an event after what happened in the business, not after what changed in a database. AccountFrozen tells you something. AccountUpdated or StatusChanged tells you a row was written, and leaves you to dig through its fields to find out why.
A specific name is what lets the rest of the model react. A fraud policy listens for MoneyWithdrawn; a statement prints a line for each MoneyDeposited. Neither could subscribe to “the account changed” without re-deciding what kind of change it was.
One command can record more than one event when more than one thing happened. And a rejection is not an event: when a command is refused, nothing happened, so nothing is recorded.
Declare an event
Section titled “Declare an event”An event has a name and, usually, some data.
export class MoneyWithdrawn extends Domain.Event("MoneyWithdrawn", { amount: Schema.Int, balanceAfter: Schema.Int,}) {}Each field is a schema, so events are encoded the same way wherever they’re stored or sent. An event that needs no data has no fields: Domain.Event("AccountClosed").
Describe it
Section titled “Describe it”An event’s last argument is its annotations. Give it a description that says what happened, in the business’s words. It is the event Schema’s description too.
export class MoneyWithdrawn extends Domain.Event( "MoneyWithdrawn", { amount: Schema.Int, balanceAfter: Schema.Int }, { description: "Money left an account. Amounts are in cents." },) {}Only decide creates events
Section titled “Only decide creates events”Events are created in exactly one place: an aggregate’s decide handler, as its answer to a command.
: [new MoneyWithdrawn({ amount: cmd.amount, balanceAfter: s.balance - cmd.amount })],The command declares which events it may record, so decide can’t return anything else. Once they’re committed, they’re never changed or removed. A mistake is corrected the way an accountant corrects one, with a new event that reverses it, so the history still shows both.
Record what readers will need
Section titled “Record what readers will need”Put in the event what someone reading it later needs to understand it on its own, without asking the aggregate what its state was at the time.
MoneyWithdrawn carries balanceAfter for that reason. The aggregate knows the balance when it decides, so it’s free to record. A view that shows the balance, a statement that prints it on each line, and a policy that flags low balances can all read it straight from the event. Without it, each would have to keep its own running total and hope it never drifts.
The same goes for who did something and why. When a support agent freezes an account, the event records the agent and the reason, so the history explains itself.
export class AccountFrozen extends Domain.Event("AccountFrozen", { reason: Schema.String, by: Actor,}) {}Keep old events readable
Section titled “Keep old events readable”Events are kept for good, so every event you’ve ever recorded will be read again: each time an aggregate rebuilds its state, and each time a view is built from history. Treat an event’s shape as a promise to your future self.
Add a new field as optional, so events recorded before it existed still decode. When the meaning of something changes, add a new event rather than changing what an old one means. A history in which MoneyWithdrawn meant one thing in March and another in May is a history nobody can read.
export class MoneyWithdrawn extends Domain.Event("MoneyWithdrawn", { amount: Schema.Int, balanceAfter: Schema.Int, channel: Schema.optional(Schema.Literals(["atm", "app"])), // added later}) {}Fold events into state
Section titled “Fold events into state”The aggregate turns each event into its next state with evolve. This is where an event’s data takes effect.
evolve: { AccountOpened: (_, e) => ({ _tag: "Open" as const, owner: e.owner, balance: 0 }), MoneyDeposited: (s, e) => (s._tag === "Open" ? { ...s, balance: e.balanceAfter } : s), MoneyWithdrawn: (s, e) => (s._tag === "Open" ? { ...s, balance: e.balanceAfter } : s),},evolve can’t refuse an event. It already happened, and only decide gets to say no. decide returns a typed rejection such as NotOpen before it records a deposit to an account that isn’t open, so evolve only has to cover every state. See Aggregate.
The envelope
Section titled “The envelope”When an event commits, it’s stored inside an envelope that says where and when it happened. id is the aggregate instance and stream names its history, like Account/a-1. seq is the event’s position in that stream, starting at 1. at is the commit time, and commandId is the id of the command that caused it.
evolve receives the envelope as its third argument, for state that depends on when something happened.
MoneyWithdrawn: (_, e, { at }) => ({ balance: e.balanceAfter, lastWithdrawal: DateTime.formatIsoDateUtc(at),}),The commandId lets a client recognize the events its own commands caused, which is how an app confirms an optimistic update.
Delivered to views and policies
Section titled “Delivered to views and policies”In the same commit that stores an event, the instance records a delivery for every view and policy that consumes it. After the commit, each delivery is sent. Every consumer receives every event it consumes at least once, and in the order the instance recorded them.
A view can record events of its own as it folds, such as one StatementLine per deposit or withdrawal. Those are delivered the same way, so another view or policy can build on them.
Follow an instance’s events
Section titled “Follow an instance’s events”A client reads an account’s history through a view that follows it, then keeps receiving new events as they’re recorded. AccountSummary declares no events of its own, so it emits every event it applies.
export const followAccount = Effect.fn(function* (bank: CustomerClient, accountId: AccountId) { yield* bank.AccountSummary.events(accountId).pipe( Stream.filter(Domain.Read.isEvent), Stream.runForEach(({ event, envelope }) => Effect.log(`#${envelope.seq} ${event._tag} (command ${envelope.commandId})`), ), );});events replays from the beginning, sends a CaughtUp frame, then continues live. Domain.Read.isEvent keeps only the events. Pass after with a seq to resume from a position you’ve already seen.
Next, Aggregate: where commands are decided and events are recorded.