Skip to content

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.

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:

  1. Deciding what should happen
  2. Applying the effects of that decision

Mixing them makes code hard to test, hard to debug, and hard to trust.

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 order
  • order.cancel - cancel an order
  • light.switch-on - turn the light on

Events are outputs - what actually happened. They’re named in past tense:

  • order.placed - the order was placed
  • order.cancelled - the order was cancelled
  • light.switched-on - the light was turned on

State is the current situation, rebuilt from all events that happened.

A Decider is just three pure functions:

Where does your system start? Before anything happens, what’s the state?

const initialState = () => ({
exists: false,
items: [],
total: 0,
status: 'pending',
});

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 },
}];
}
};

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;
}
};

Bundle the three functions into one object:

const orderDecider = {
initialState,
decide,
evolve,
};

That’s a Decider. Three functions, no magic.

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' }

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.

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');
});
});

To reproduce any bug, you just need the command and state that caused it.

// Grab these from logs
const bugState = JSON.parse(productionStateSnapshot);
const bugCommand = JSON.parse(productionCommand);
// Reproduce exactly
const result = decide(bugCommand, bugState);
// Same result every time

TypeScript makes deciders safer:

// Commands
type 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;
// Events
type 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;
// State
type OrderState = {
exists: boolean;
items: Array<{ id: string; qty: number; price: number }>;
total: number;
status: 'pending' | 'placed' | 'shipped' | 'cancelled';
};
// Decider type
type Decider<State, Command, Event> = {
initialState: () => State;
decide: (command: Command, state: State) => Event[];
evolve: (state: State, event: Event) => State;
};

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 bits
const 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.

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 yet
if (state.exists) {
throw new Error('Order already exists');
}
// For modification - MUST exist
if (!state.exists) {
throw new Error('Order does not exist');
}

Everything above works with plain TypeScript. DeltaBase provides helpers that eliminate boilerplate.

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 }
>;

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.

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.


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.



The Decider Pattern was originally described by Jérémie Chassaing at thinkbeforecoding.com