Skip to content

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.

Every business system handles three types of information:

What someone wants to happen. The intent to change the system.

  • PlaceOrder - Customer wants to buy something
  • CancelSubscription - User wants to stop paying
  • ProcessPayment - System needs to charge a card

Commands are always in the imperative: “Do this thing.”

What actually happened. Immutable facts about the business.

  • OrderPlaced - The order was successfully created
  • SubscriptionCancelled - The subscription is now inactive
  • PaymentProcessed - Money changed hands

Events are always past tense: “This thing happened.”

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 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 empty
const initialOrderState = {
id: null,
items: [],
status: 'draft',
total: 0
};
// User account starts with zero balance
const 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 logic
function 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

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 state
function 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

Benefits of Functional Programming in Event Sourcing

Section titled “Benefits of Functional Programming in Event Sourcing”

Predictability

// This will ALWAYS produce the same result
const events = decide(placeOrderCommand, currentState);
// No surprises, no hidden dependencies

Testability

// Test business logic without databases, APIs, or time
test('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 state
const buggyState = loadStateFromProduction();
const buggyCommand = loadCommandFromLogs();
const result = decide(buggyCommand, buggyState);
// Exact same result every time

External dependencies (time, random IDs, API calls) are injected as command data:

// ❌ Don't do this - impure function
function 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 function
function 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 effects
async 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;
}

Here’s a complete Decider for a simple order system:

// Types
type 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 implementation
const 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;
}
}
};

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