Functional Event Sourcing with the Decider Pattern
Pure functions for bulletproof business logic
Functional Event Sourcing with the Decider Pattern
Section titled “Functional Event Sourcing with the Decider Pattern”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.
Modeling Data Flow Through Time
Section titled “Modeling Data Flow Through Time”Every business system handles three types of information:
Commands (Future/Intent)
Section titled “Commands (Future/Intent)”What someone wants to happen. The intent to change the system.
PlaceOrder
- Customer wants to buy somethingCancelSubscription
- User wants to stop payingProcessPayment
- System needs to charge a card
Commands are always in the imperative: “Do this thing.”
Events (Past/What Happened)
Section titled “Events (Past/What Happened)”What actually happened. Immutable facts about the business.
OrderPlaced
- The order was successfully createdSubscriptionCancelled
- The subscription is now inactivePaymentProcessed
- Money changed hands
Events are always past tense: “This thing happened.”
State (Present/Current Reality)
Section titled “State (Present/Current Reality)”What the system looks like right now. Built from all the events.
- Current order status and items
- Active subscription details
- Account balance and transaction history
[Diagram Placeholder: Command → Decision → Event → State Flow] Commands flow into decision logic, which produces Events, which update State
The Decider Pattern Components
Section titled “The Decider Pattern Components”The Decider Pattern breaks business logic into three pure functions:
Initial State: Starting Point for Entities
Section titled “Initial State: Starting Point for Entities”Every business entity starts somewhere:
// Order starts as emptyconst initialOrderState = { id: null, items: [], status: 'draft', total: 0};
// User account starts with zero balanceconst initialAccountState = { balance: 0, transactions: [], status: 'active'};
Decide Function: Pure Business Logic Validation
Section titled “Decide Function: Pure Business Logic Validation”The heart of your business logic. Takes current state and a command, returns events (or validation errors).
type DecideFunction<Command, State, Event> = (command: Command, state: State) => Event[]
// Example: Order placement logicfunction decideOrderCommand(command: OrderCommand, state: OrderState): OrderEvent[] { switch (command.type) { case 'PlaceOrder': // Business rule validation if (state.status !== 'draft') { return [{ type: 'OrderPlacementRejected', reason: 'Order already placed' }]; }
if (command.items.length === 0) { return [{ type: 'OrderPlacementRejected', reason: 'No items in order' }]; }
// Decision: place the order return [{ type: 'OrderPlaced', orderId: generateId(), items: command.items, total: calculateTotal(command.items), timestamp: new Date() }];
case 'CancelOrder': if (state.status === 'shipped') { return [{ type: 'OrderCancellationRejected', reason: 'Order already shipped' }]; }
return [{ type: 'OrderCancelled', orderId: state.id }]; }}
Key properties:
- Pure function - Same inputs always produce same outputs
- No side effects - Doesn’t call APIs, databases, or external services
- Business rule enforcement - All validation logic lives here
- Returns events or errors - Never throws exceptions
Evolve Function: State Transition Logic
Section titled “Evolve Function: State Transition Logic”Takes current state and an event, returns new state. Used for both command processing and building projections.
type EvolveFunction<State, Event> = (state: State, event: Event) => State
// Example: How events change order statefunction evolveOrderState(state: OrderState, event: OrderEvent): OrderState { switch (event.type) { case 'OrderPlaced': return { ...state, id: event.orderId, items: event.items, total: event.total, status: 'placed' };
case 'OrderShipped': return { ...state, status: 'shipped', shippedAt: event.timestamp };
case 'OrderCancelled': return { ...state, status: 'cancelled', cancelledAt: event.timestamp };
default: return state; // Unknown events don't change state }}
Key properties:
- Pure function - Deterministic state transitions
- Simple logic - Just data transformation, no business rules
- Immutable - Returns new state, doesn’t mutate existing
- Used everywhere - Command processing, projections, rebuilding state
Pure Functions and Side Effects
Section titled “Pure Functions and Side Effects”Benefits of Functional Programming in Event Sourcing
Section titled “Benefits of Functional Programming in Event Sourcing”Predictability
// This will ALWAYS produce the same resultconst events = decide(placeOrderCommand, currentState);// No surprises, no hidden dependencies
Testability
// Test business logic without databases, APIs, or timetest('cannot place order twice', () => { const state = { status: 'placed', items: [...] }; const command = { type: 'PlaceOrder', items: [...] };
const events = decide(command, state);
expect(events[0].type).toBe('OrderPlacementRejected');});
Debugging
// Reproduce any bug with just the command and stateconst buggyState = loadStateFromProduction();const buggyCommand = loadCommandFromLogs();const result = decide(buggyCommand, buggyState);// Exact same result every time
Dependency Injection Patterns
Section titled “Dependency Injection Patterns”External dependencies (time, random IDs, API calls) are injected as command data:
// ❌ Don't do this - impure functionfunction decide(command: PlaceOrder, state: OrderState): OrderEvent[] { return [{ type: 'OrderPlaced', orderId: crypto.randomUUID(), // ← Impure! Different every time timestamp: new Date(), // ← Impure! Changes over time items: command.items }];}
// ✅ Do this - pure functionfunction decide(command: PlaceOrder, state: OrderState): OrderEvent[] { return [{ type: 'OrderPlaced', orderId: command.orderId, // ← Injected by application layer timestamp: command.timestamp, // ← Injected by application layer items: command.items }];}
The application layer handles impure concerns:
// Application layer - handles side effectsasync function handlePlaceOrderCommand(rawCommand: RawPlaceOrder) { // Enrich command with external data const enrichedCommand: PlaceOrder = { ...rawCommand, orderId: crypto.randomUUID(), timestamp: new Date(), userId: getCurrentUser().id };
// Load current state const currentState = await loadOrderState(enrichedCommand.orderId);
// Pure business logic const events = decide(enrichedCommand, currentState);
// Persist events await eventStore.append(`order-${enrichedCommand.orderId}`, events);
return events;}
Putting It All Together
Section titled “Putting It All Together”Here’s a complete Decider for a simple order system:
// Typestype OrderCommand = | { type: 'PlaceOrder'; orderId: string; items: Item[]; timestamp: Date } | { type: 'CancelOrder'; orderId: string; timestamp: Date }
type OrderEvent = | { type: 'OrderPlaced'; orderId: string; items: Item[]; total: number; timestamp: Date } | { type: 'OrderCancelled'; orderId: string; timestamp: Date } | { type: 'OrderPlacementRejected'; reason: string }
type OrderState = { id: string | null; items: Item[]; status: 'draft' | 'placed' | 'cancelled'; total: number;}
// Decider implementationconst orderDecider = { // Starting point initialState: { id: null, items: [], status: 'draft' as const, total: 0 },
// Business logic decide(command: OrderCommand, state: OrderState): OrderEvent[] { switch (command.type) { case 'PlaceOrder': if (state.status !== 'draft') { return [{ type: 'OrderPlacementRejected', reason: 'Order already placed' }]; }
return [{ type: 'OrderPlaced', orderId: command.orderId, items: command.items, total: calculateTotal(command.items), timestamp: command.timestamp }];
case 'CancelOrder': if (state.status !== 'placed') { return [{ type: 'OrderPlacementRejected', reason: 'Cannot cancel order' }]; }
return [{ type: 'OrderCancelled', orderId: command.orderId, timestamp: command.timestamp }]; } },
// State transitions evolve(state: OrderState, event: OrderEvent): OrderState { switch (event.type) { case 'OrderPlaced': return { ...state, id: event.orderId, items: event.items, total: event.total, status: 'placed' };
case 'OrderCancelled': return { ...state, status: 'cancelled' };
default: return state; } }};
Why This Pattern Works
Section titled “Why This Pattern Works”Separation of concerns - Business logic is isolated from infrastructure
Testability - Pure functions are easy to test
Reusability - Same logic works for commands, projections, and rebuilding state
Debugging - Deterministic functions make bugs reproducible
Performance - No I/O in business logic means it’s fast
The Decider Pattern isn’t magic. It’s just good functional programming applied to event sourcing. But that combination gives you business logic that actually works.
Learn more about the Decider Pattern at thinkbeforecoding.com