Decide, Evolve, Repeat¶
Almost every Event Sourcing implementation starts with aggregates. You define a class, give it a method for each command, mutate internal state when events are applied, and wire it all up with a framework. This works. Countless systems have been built this way. But if you step back and ask what Event Sourcing actually needs at its core, the answer is surprisingly minimal.
The Decider pattern, introduced by Jérémie Chassaing, strips Event Sourcing down to three functions. No classes. No inheritance. No framework. Just three pure functions that capture everything an event-sourced component does. It is one of the most elegant ideas in the Event Sourcing space, and once you see it, it changes how you think about the entire approach.
The Three Functions¶
The Decider pattern consists of three components. Each has a clear type signature and a single responsibility:
decide takes a command and the current state, and returns a list of events describing what happened. This is where all the business logic lives. Should this book be borrowed? Is it available? Has the borrower exceeded their limit? The function examines the command in the context of the current state and produces events that describe the outcome. It does not change anything. It only decides.
evolve takes the current state and a single event, and returns the new state. This is pure data transformation. The decision has already been made. The evolve function simply updates the state to reflect what happened. No business rules, no validation, no side effects. Just a state transition.
initialState is the state before anything has happened. The blank slate from which everything begins.
That is it. Three components. Two functions and a value. Everything an event-sourced aggregate does can be expressed through this structure.
How They Work Together¶
Let's make this concrete with our library domain. A book can be acquired, borrowed, and returned. Here is what the Decider looks like:
The evolve function handles state transitions:
evolve(state, event) =
match event with
| BookAcquired → { isRegistered: true, isAvailable: true, borrowedBy: none }
| BookBorrowed → { ...state, isAvailable: false, borrowedBy: event.borrowedBy }
| BookReturned → { ...state, isAvailable: true, borrowedBy: none }
The decide function contains the business rules:
decide(command, state) =
match command with
| AcquireBook →
if state.isRegistered then fail("Book already acquired")
else [BookAcquired { title, author, isbn }]
| BorrowBook →
if not state.isAvailable then fail("Book is not available")
else [BookBorrowed { borrowedBy, borrowedUntil }]
| ReturnBook →
if state.isAvailable then fail("Book is not borrowed")
else [BookReturned { returnedAt }]
Now watch how they compose. When a command arrives, the system reconstructs the current state by folding all past events over the initial state:
Then it calls decide with the command and the current state:
The new events are stored. If the state needs to be updated, the same evolve function is applied:
The same two functions handle everything: state reconstruction from history, command validation, and state updates after new events. There is no separate "replay" mechanism and "command handler" mechanism. It is the same loop: decide, evolve, repeat.
What Traditional Aggregates Do Differently¶
In a typical object-oriented aggregate, decisions and state transitions live inside the same class. Consider how the same library example might look as a traditional aggregate:
class BookAggregate {
isAvailable = false
borrowedBy = none
handleBorrowBook(command) {
if not this.isAvailable then
fail("Book is not available")
emit BookBorrowed { borrowedBy, borrowedUntil }
}
applyBookBorrowed(event) {
this.isAvailable = false
this.borrowedBy = event.borrowedBy
}
}
This is not wrong. The command handler validates and emits events, the apply method updates state. There is a separation of sorts. But everything lives inside one class, and the two mechanisms are implicitly coupled: the command handler must know which state the apply method will set, because it relies on that state for its validation logic. As the aggregate grows, this implicit coupling becomes harder to trace.
As we discussed in Your Aggregate Is Not a Table, aggregates already have a tendency to grow beyond their intended scope. In the Kill Aggregate interview, Bastian Waidelich described this problem vividly: aggregates attract domain logic "like a black hole" until they become massive objects that are hard to split. The class structure that initially provides encapsulation becomes a container that accumulates ever more responsibility.
The Decider pattern avoids this by making the separation explicit from the start. There is no class to grow into. There are just two functions with clear type signatures, and the contract between them is visible in those signatures rather than hidden in shared mutable state.
What You Gain by Separating Them¶
The separation of decide and evolve is not just aesthetically pleasing. It has practical consequences that compound over time.
Testing becomes trivial. Since both functions are pure, tests follow a natural Given-When-Then structure. Given this state, when this command arrives, then these events should be produced. No mocks, no database, no framework setup. Just inputs and outputs.
// Given
state = { isRegistered: true, isAvailable: true, borrowedBy: none }
// When
events = decide(BorrowBook { borrowedBy: "/readers/23" }, state)
// Then
assert events == [BookBorrowed { borrowedBy: "/readers/23", ... }]
This is what DDD: Back to Basics describes as the fundamental triangle: Commands produce Events, Events build State, State informs the next Command. The Decider pattern makes this triangle explicit in code. The type signatures are the contract. There is nothing hidden.
Composition becomes possible. Two independent Deciders can be combined into one. If your system has a Book Decider and a Reader Decider, you can compose them into a Library Decider that handles both. The decide function dispatches to the right sub-decider. The evolve function routes events to the right state. This is function composition, and it works because the interfaces are uniform.
Infrastructure disappears. The Decider knows nothing about databases, HTTP, or event stores. It is pure domain logic. You can run it in memory for tests, against EventSourcingDB in production, or against a different storage backend entirely. The same three functions work everywhere, because they have no dependencies on anything outside the domain.
What You Don't Lose¶
A common concern when people first see the Decider pattern is that it looks like giving up the structure that aggregates provide. Where is the encapsulation? Where is the boundary?
The boundary is still there. The decide function encapsulates all business rules for a given context. Nothing outside it can produce events for that context. The encapsulation is just expressed differently: through a function signature instead of a class definition.
You don't need a functional language. Jérémie Chassaing introduced the pattern in F#, but it works in any language. In TypeScript, decide and evolve are plain functions. In Go, they are functions with typed parameters. In Java, they can be static methods or lambdas. The pattern is about separation of concerns, not about a programming paradigm.
You don't lose expressiveness. A Decider can model everything an aggregate can: complex business rules, multi-step validations, conditional event production. The difference is not in what you can express, but in how clearly the structure communicates its intent. The type signature (Command, State) → Event list tells you everything about what decide does. There is no hidden state, no lifecycle methods, no initialization order to worry about.
The Pattern That Disappears¶
The Decider pattern shares a quality with the best technical ideas: it feels obvious in hindsight. Of course the decision and the state transition are separate concerns. Of course they should be separate functions. Of course the type signatures should make the contract explicit.
This is not a new architecture. It is a clarification of what Event Sourcing already does. Every event-sourced system has something that decides what events to produce and something that applies those events to state. The Decider pattern simply gives these things names, types, and a clear boundary.
As we explored in All Models Are Wrong, Some Are Useful, the best approaches are often the simplest ones. The Decider pattern embodies this: two functions and an initial value. No framework required. No patterns to memorize beyond the three components. Just decide what happened, evolve the state, and repeat.
If you want to try this approach with EventSourcingDB, the Getting Started guide will have you writing and reading events in minutes. The Decider pattern fits naturally on top: your decide function produces events, EventSourcingDB stores them, and your evolve function reconstructs state when needed. And if you want to discuss how the Decider pattern might simplify your current architecture, we're always happy to think it through together at hello@thenativeweb.io.