Functional Event Sourcing with the Decider Pattern
Pure functions for bulletproof business logic
The Decider Pattern is how you write business logic that doesn’t break. It’s functional programming applied to event sourcing, and it makes your code predictable, testable, and bulletproof.
Starting from a System
Section titled “Starting from a System”Let’s look at any system. Without interactions with the outside, it would be useless. We can represent these interactions as inputs and outputs.
Most systems need to remember what happened before. A light switch remembers if it’s on or off. A bank account remembers its balance. This memory is called state.
┌─────────┐ Input ─────────▶│ System │─────────▶ Output │ ┌───┐ │ │ │ S │ │ S = State │ └───┘ │ └─────────┘The typical approach: check current state, apply some logic, modify state, maybe produce output. But this conflates two very different things:
- Deciding what should happen
- Applying the effects of that decision
Mixing them makes code hard to test, hard to debug, and hard to trust.
Separating Decision from Effect
Section titled “Separating Decision from Effect”The Decider Pattern untangles this. Instead of modifying state directly, we first express what happened as data, then apply those changes separately.
┌─────────────────────────┐ │ │ Command ───────▶│ decide(command, state) │───────▶ Event(s) │ │ └─────────────────────────┘ │ ▼ ┌─────────────────────────┐ │ │ Event ─────────▶│ evolve(state, event) │───────▶ New State │ │ └─────────────────────────┘Commands are inputs - what someone wants to happen. They’re named as intentions:
order.place- place an orderorder.cancel- cancel an orderlight.switch-on- turn the light on
Events are outputs - what actually happened. They’re named in past tense:
order.placed- the order was placedorder.cancelled- the order was cancelledlight.switched-on- the light was turned on
State is the current situation, rebuilt from all events that happened.
The Three Functions
Section titled “The Three Functions”A Decider is just three pure functions:
initialState
Section titled “initialState”Where does your system start? Before anything happens, what’s the state?
const initialState = () => ({ exists: false, items: [], total: 0, status: 'pending',});decide
Section titled “decide”Given a command and current state, what events should happen?
This is your business logic. It checks rules, validates constraints, and returns events describing what happened. It doesn’t change anything - it just decides.
const decide = (command, state) => { switch (command.type) { case 'order.place': if (state.exists) { throw new Error('Order already exists'); } if (command.data.items.length === 0) { throw new Error('Order must have items'); } return [{ type: 'order.placed', data: { items: command.data.items, total: calculateTotal(command.data.items), }, }];
case 'order.cancel': if (!state.exists) { throw new Error('Order does not exist'); } if (state.status === 'shipped') { throw new Error('Cannot cancel shipped order'); } return [{ type: 'order.cancelled', data: { reason: command.data.reason }, }]; }};evolve
Section titled “evolve”Given current state and an event, what’s the new state?
This is pure data transformation. The decision has already been made - we’re just updating state to reflect what happened.
const evolve = (state, event) => { switch (event.type) { case 'order.placed': return { exists: true, items: event.data.items, total: event.data.total, status: 'placed', };
case 'order.cancelled': return { ...state, status: 'cancelled', };
default: return state; }};Putting It Together
Section titled “Putting It Together”Bundle the three functions into one object:
const orderDecider = { initialState, decide, evolve,};That’s a Decider. Three functions, no magic.
Running a Decider
Section titled “Running a Decider”Here’s how you use it with any event store:
async function handleCommand(eventStore, streamId, command, decider) { // 1. Load past events const { events: pastEvents, version } = await eventStore.readStream(streamId);
// 2. Rebuild current state by folding events let state = decider.initialState(); for (const event of pastEvents) { state = decider.evolve(state, event); }
// 3. Decide what happens const newEvents = decider.decide(command, state);
// 4. Save new events await eventStore.appendToStream(streamId, newEvents, version);
// 5. Compute final state for the caller for (const event of newEvents) { state = decider.evolve(state, event); }
return { state, events: newEvents };}Usage:
const result = await handleCommand( eventStore, 'order-123', { type: 'order.place', data: { items: [{ id: 'pizza', qty: 2, price: 1299 }] } }, orderDecider);
console.log(result.state);// { exists: true, items: [...], total: 2598, status: 'placed' }Why This Matters
Section titled “Why This Matters”Pure Functions = Predictable Code
Section titled “Pure Functions = Predictable Code”The decide function has no side effects. Same inputs always produce same outputs.
const events = decide(command, state);// No network calls. No database reads. No randomness.// Just logic.Testing Without Pain
Section titled “Testing Without Pain”No mocks. No test databases. Just call functions with data.
import { describe, it, expect } from 'vitest';
describe('order decider', () => { it('places an order', () => { const command = { type: 'order.place', data: { items: [{ id: 'laptop', qty: 1, price: 99900 }] }, };
const events = decide(command, initialState());
expect(events[0].type).toBe('order.placed'); expect(events[0].data.total).toBe(99900); });
it('rejects duplicate order', () => { const existingState = { exists: true, items: [], total: 0, status: 'placed' }; const command = { type: 'order.place', data: { items: [] } };
expect(() => decide(command, existingState)).toThrow('Order already exists'); });});Debugging Becomes Trivial
Section titled “Debugging Becomes Trivial”To reproduce any bug, you just need the command and state that caused it.
// Grab these from logsconst bugState = JSON.parse(productionStateSnapshot);const bugCommand = JSON.parse(productionCommand);
// Reproduce exactlyconst result = decide(bugCommand, bugState);// Same result every timeTypes (Optional but Helpful)
Section titled “Types (Optional but Helpful)”TypeScript makes deciders safer:
// Commandstype PlaceOrder = { type: 'order.place'; data: { items: Array<{ id: string; qty: number; price: number }> };};
type CancelOrder = { type: 'order.cancel'; data: { reason: string };};
type OrderCommand = PlaceOrder | CancelOrder;
// Eventstype OrderPlaced = { type: 'order.placed'; data: { items: Array<{ id: string; qty: number; price: number }>; total: number };};
type OrderCancelled = { type: 'order.cancelled'; data: { reason: string };};
type OrderEvent = OrderPlaced | OrderCancelled;
// Statetype OrderState = { exists: boolean; items: Array<{ id: string; qty: number; price: number }>; total: number; status: 'pending' | 'placed' | 'shipped' | 'cancelled';};
// Decider typetype Decider<State, Command, Event> = { initialState: () => State; decide: (command: Command, state: State) => Event[]; evolve: (state: State, event: Event) => State;};Handling Time and IDs
Section titled “Handling Time and IDs”The decide function should be pure. But what about timestamps and random IDs?
Option 1: Pass them in the command
// Application layer adds the impure bitsconst enrichedCommand = { type: 'order.place', data: { orderId: crypto.randomUUID(), timestamp: new Date().toISOString(), items: rawCommand.items, },};
const events = decide(enrichedCommand, state);Option 2: Accept minor impurity
For pragmatic teams, generating timestamps in decide is often acceptable:
const decide = (command, state) => { // Technically impure, but practically fine const timestamp = new Date().toISOString();
return [{ type: 'order.placed', data: { ...eventData, placedAt: timestamp }, }];};The tradeoff: slightly harder to test deterministically, but simpler code.
Checking If Something Exists
Section titled “Checking If Something Exists”A common pattern: use exists or an optional id to track whether something has been created:
type OrderState = { exists: boolean; // or: id?: string // ...};
// For creation - must NOT exist yetif (state.exists) { throw new Error('Order already exists');}
// For modification - MUST existif (!state.exists) { throw new Error('Order does not exist');}Using Deciders with DeltaBase
Section titled “Using Deciders with DeltaBase”Everything above works with plain TypeScript. DeltaBase provides helpers that eliminate boilerplate.
Type Helpers
Section titled “Type Helpers”DeltaBase’s @delta-base/toolkit provides generic types:
import type { Command, Event, Decider } from '@delta-base/toolkit';
type PlaceOrder = Command< 'order.place', { items: Array<{ id: string; qty: number; price: number }> }>;
type OrderPlaced = Event< 'order.placed', { items: Array<{ id: string; qty: number; price: number }>; total: number }>;handleCommandWithDecider
Section titled “handleCommandWithDecider”Instead of writing the command handling boilerplate yourself:
import { DeltaBase } from '@delta-base/server';import { handleCommandWithDecider } from '@delta-base/toolkit';
const client = new DeltaBase({ apiKey: process.env.DELTABASE_API_KEY, baseUrl: 'https://api.delta-base.com',});
const eventStore = client.getEventStore('orders');
const result = await handleCommandWithDecider( eventStore, 'order-123', command, orderDecider);
console.log(result.newState);console.log(result.newEvents);This handles reading events, rebuilding state, running decide, and appending new events.
Automatic Retry
Section titled “Automatic Retry”For concurrent writes, DeltaBase provides automatic retry:
import { handleCommandWithDeciderAndRetry } from '@delta-base/toolkit';
const result = await handleCommandWithDeciderAndRetry( eventStore, 'order-123', command, orderDecider);On version conflict, it re-reads events, rebuilds state, and retries.
Summary
Section titled “Summary”The Decider Pattern is:
- Three pure functions:
initialState,decide,evolve - No framework required: Works with plain TypeScript
- Highly testable: No mocks, no databases
- Easy to debug: Same inputs = same outputs
The pattern works with any event store. DeltaBase provides helpers to reduce boilerplate, but the pattern stands on its own.
Next Steps
Section titled “Next Steps”- Send your first event - Get started in 2 minutes
- Banking System tutorial - Complete example with deposits, withdrawals, and business rules
- CQRS Implementation - Command/query separation guide
The Decider Pattern was originally described by Jérémie Chassaing at thinkbeforecoding.com