Skip to content

Build a Banking System

Complete event-sourced banking with commands, projections, and real-time updates

Build a production-ready banking system with DeltaBase. You’ll learn the patterns used in real applications:

  • Commands & Events with the Decider pattern
  • Projections for fast account balance lookups
  • Webhook subscriptions for event delivery
  • Real-time updates via WebSocket
┌─────────────────────────────────────────────────────────────────────────┐
│ Banking System Architecture │
│ │
│ HTTP API Event Store Read Models │
│ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
│ │ POST │──Command──▶ │ account- │──Webhook─▶│ balances │ │
│ │ /deposit │ │ {id} │ │ transactions │ │
│ └──────────┘ └───────────┘ └──────────────┘ │
│ │ ▲ │
│ │ WebSocket │ │
│ ▼ │ │
│ ┌──────────┐ │ │
│ │ Browser │◀────Query────────┘ │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────────────────┘

Time: ~45 minutes


By the end of this tutorial, you’ll have:

  1. Bank accounts with deposits, withdrawals, and transfers
  2. Business rules that prevent overdrafts and invalid operations
  3. Fast balance lookups via projected read models
  4. Transaction history as a queryable projection
  5. Real-time balance updates via WebSocket (optional)

  • Node.js 18+
  • DeltaBase CLI installed
  • Basic TypeScript knowledge

Terminal window
npx @delta-base/cli dev

You should see:

Deltabase development environment is ready!
API Server: http://localhost:8787
Studio UI: http://localhost:3000

Keep this running throughout the tutorial.

In a new terminal:

Terminal window
mkdir banking-system
cd banking-system
npm init -y
npm install @delta-base/server @delta-base/toolkit hono
npm install -D typescript @types/node tsx

Create tsconfig.json:

{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"]
}
banking-system/
├── src/
│ ├── core/
│ │ └── accounts.ts # Domain logic (Decider)
│ ├── functions/
│ │ ├── deposit.ts # Command handlers
│ │ ├── withdraw.ts
│ │ └── open-account.ts
│ ├── projections/
│ │ ├── balances.projection.ts
│ │ ├── balances.handler.ts
│ │ └── balances.route.ts
│ ├── shared/
│ │ └── event-store.ts # DeltaBase client
│ └── index.ts # HTTP server
├── deltabase.config.ts # Infrastructure config
├── package.json
└── tsconfig.json

Events are facts - things that happened. They’re immutable.

Create src/core/accounts.ts:

import type { Command, Decider, Event, ReadEvent } from '@delta-base/toolkit';
import { IllegalStateError, ValidationError } from '@delta-base/toolkit';
export namespace Accounts {
// ═══════════════════════════════════════════════════════════════════
// EVENTS - Facts that happened (immutable, past tense)
// ═══════════════════════════════════════════════════════════════════
export type AccountOpened = Event<
'account.opened',
{
accountId: string;
customerId: string;
initialBalance: number;
openedAt: string;
}
>;
export type MoneyDeposited = Event<
'money.deposited',
{
accountId: string;
amount: number;
description?: string;
depositedAt: string;
}
>;
export type MoneyWithdrawn = Event<
'money.withdrawn',
{
accountId: string;
amount: number;
description?: string;
withdrawnAt: string;
}
>;
export type AccountClosed = Event<
'account.closed',
{
accountId: string;
reason?: string;
closedAt: string;
}
>;
export type AccountEvents =
| AccountOpened
| MoneyDeposited
| MoneyWithdrawn
| AccountClosed;
// ═══════════════════════════════════════════════════════════════════
// COMMANDS - Intent (can be rejected)
// ═══════════════════════════════════════════════════════════════════
export type OpenAccount = Command<
'account.open',
{
accountId: string;
customerId: string;
initialBalance: number;
}
>;
export type DepositMoney = Command<
'money.deposit',
{
accountId: string;
amount: number;
description?: string;
}
>;
export type WithdrawMoney = Command<
'money.withdraw',
{
accountId: string;
amount: number;
description?: string;
}
>;
export type CloseAccount = Command<
'account.close',
{
accountId: string;
reason?: string;
}
>;
export type AccountCommands =
| OpenAccount
| DepositMoney
| WithdrawMoney
| CloseAccount;
// ═══════════════════════════════════════════════════════════════════
// STATE - Current state of an account (derived from events)
// ═══════════════════════════════════════════════════════════════════
export interface AccountState {
accountId?: string;
customerId?: string;
balance: number;
isOpen: boolean;
openedAt?: string;
closedAt?: string;
}
// ═══════════════════════════════════════════════════════════════════
// DECIDER - Business logic as pure functions
// ═══════════════════════════════════════════════════════════════════
/**
* decide: Given current state and a command, return events (or throw)
* This is where business rules live.
*/
export const decide = (
command: AccountCommands,
state: AccountState
): AccountEvents | AccountEvents[] => {
switch (command.type) {
case 'account.open':
return handleOpenAccount(command, state);
case 'money.deposit':
return handleDeposit(command, state);
case 'money.withdraw':
return handleWithdraw(command, state);
case 'account.close':
return handleCloseAccount(command, state);
}
};
const handleOpenAccount = (
command: OpenAccount,
state: AccountState
): AccountOpened => {
// Business rule: Can't open an account that already exists
if (state.isOpen) {
throw new IllegalStateError(
`Account ${command.data.accountId} already exists`,
{ accountId: command.data.accountId },
'open',
'open account'
);
}
// Business rule: Initial balance must be non-negative
if (command.data.initialBalance < 0) {
throw new ValidationError('Initial balance must be non-negative', {
initialBalance: command.data.initialBalance,
});
}
return {
type: 'account.opened',
data: {
accountId: command.data.accountId,
customerId: command.data.customerId,
initialBalance: command.data.initialBalance,
openedAt: new Date().toISOString(),
},
};
};
const handleDeposit = (
command: DepositMoney,
state: AccountState
): MoneyDeposited => {
// Business rule: Account must exist
if (!state.isOpen) {
throw new IllegalStateError(
`Account ${command.data.accountId} does not exist or is closed`,
{ accountId: command.data.accountId },
state.closedAt ? 'closed' : 'not_found',
'deposit money'
);
}
// Business rule: Deposit amount must be positive
if (command.data.amount <= 0) {
throw new ValidationError('Deposit amount must be positive', {
amount: command.data.amount,
});
}
return {
type: 'money.deposited',
data: {
accountId: command.data.accountId,
amount: command.data.amount,
description: command.data.description,
depositedAt: new Date().toISOString(),
},
};
};
const handleWithdraw = (
command: WithdrawMoney,
state: AccountState
): MoneyWithdrawn => {
// Business rule: Account must exist
if (!state.isOpen) {
throw new IllegalStateError(
`Account ${command.data.accountId} does not exist or is closed`,
{ accountId: command.data.accountId },
state.closedAt ? 'closed' : 'not_found',
'withdraw money'
);
}
// Business rule: Withdrawal amount must be positive
if (command.data.amount <= 0) {
throw new ValidationError('Withdrawal amount must be positive', {
amount: command.data.amount,
});
}
// Business rule: No overdrafts allowed
if (state.balance < command.data.amount) {
throw new ValidationError(
`Insufficient funds. Balance: $${state.balance}, Requested: $${command.data.amount}`,
{
balance: state.balance,
requested: command.data.amount,
}
);
}
return {
type: 'money.withdrawn',
data: {
accountId: command.data.accountId,
amount: command.data.amount,
description: command.data.description,
withdrawnAt: new Date().toISOString(),
},
};
};
const handleCloseAccount = (
command: CloseAccount,
state: AccountState
): AccountClosed => {
if (!state.isOpen) {
throw new IllegalStateError(
`Account ${command.data.accountId} does not exist or is already closed`,
{ accountId: command.data.accountId },
state.closedAt ? 'closed' : 'not_found',
'close account'
);
}
// Business rule: Must have zero balance to close
if (state.balance !== 0) {
throw new ValidationError(
`Cannot close account with non-zero balance: $${state.balance}`,
{ balance: state.balance }
);
}
return {
type: 'account.closed',
data: {
accountId: command.data.accountId,
reason: command.data.reason,
closedAt: new Date().toISOString(),
},
};
};
/**
* evolve: Given current state and an event, return new state
* This is how we rebuild state from events.
*/
export const evolve = (
state: AccountState,
event: ReadEvent<AccountEvents>
): AccountState => {
switch (event.type) {
case 'account.opened':
return {
accountId: event.data.accountId,
customerId: event.data.customerId,
balance: event.data.initialBalance,
isOpen: true,
openedAt: event.data.openedAt,
};
case 'money.deposited':
return {
...state,
balance: state.balance + event.data.amount,
};
case 'money.withdrawn':
return {
...state,
balance: state.balance - event.data.amount,
};
case 'account.closed':
return {
...state,
isOpen: false,
closedAt: event.data.closedAt,
};
}
};
/**
* initialState: Starting state before any events
*/
export const initialState = (): AccountState => ({
balance: 0,
isOpen: false,
});
/**
* The complete Decider - used by handleCommandWithDecider
*/
export const decider: Decider<AccountState, AccountCommands, AccountEvents> =
{
decide,
evolve,
initialState,
};
}

Key insight: The Decider pattern separates:

  • decide - Business rules (when can this happen?)
  • evolve - State transitions (how does state change?)
  • initialState - Starting point

All three are pure functions - easy to test, easy to reason about.


Command handlers connect HTTP requests to the domain logic.

Create src/shared/event-store.ts:

import { DeltaBase } from '@delta-base/server';
// For local development
const deltabase = new DeltaBase({
baseUrl: process.env.DELTABASE_BASE_URL || 'http://localhost:8787',
apiKey: process.env.DELTABASE_API_KEY,
});
export const eventStore = deltabase.getEventStore(
process.env.DELTABASE_EVENT_STORE_NAME || 'banking'
);

Create src/functions/open-account.ts:

import { handleCommandWithDecider } from '@delta-base/toolkit';
import { Accounts } from '../core/accounts';
import { eventStore } from '../shared/event-store';
export interface OpenAccountRequest {
customerId: string;
initialBalance?: number;
}
export interface OpenAccountResult {
accountId: string;
balance: number;
message: string;
}
export async function openAccountHandler(
request: OpenAccountRequest
): Promise<OpenAccountResult> {
// Generate a unique account ID
const accountId = `account-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const command: Accounts.OpenAccount = {
type: 'account.open',
data: {
accountId,
customerId: request.customerId,
initialBalance: request.initialBalance ?? 0,
},
};
const result = await handleCommandWithDecider(
eventStore,
accountId, // Stream ID = Account ID
command,
Accounts.decider
);
return {
accountId,
balance: result.newState.balance,
message: 'Account opened successfully',
};
}

Create src/functions/deposit.ts:

import { handleCommandWithDecider } from '@delta-base/toolkit';
import { Accounts } from '../core/accounts';
import { eventStore } from '../shared/event-store';
export interface DepositRequest {
amount: number;
description?: string;
}
export interface DepositResult {
newBalance: number;
message: string;
}
export async function depositHandler(
accountId: string,
request: DepositRequest
): Promise<DepositResult> {
const command: Accounts.DepositMoney = {
type: 'money.deposit',
data: {
accountId,
amount: request.amount,
description: request.description,
},
};
const result = await handleCommandWithDecider(
eventStore,
accountId,
command,
Accounts.decider
);
return {
newBalance: result.newState.balance,
message: `Deposited $${request.amount}`,
};
}

Create src/functions/withdraw.ts:

import { handleCommandWithDecider } from '@delta-base/toolkit';
import { Accounts } from '../core/accounts';
import { eventStore } from '../shared/event-store';
export interface WithdrawRequest {
amount: number;
description?: string;
}
export interface WithdrawResult {
newBalance: number;
message: string;
}
export async function withdrawHandler(
accountId: string,
request: WithdrawRequest
): Promise<WithdrawResult> {
const command: Accounts.WithdrawMoney = {
type: 'money.withdraw',
data: {
accountId,
amount: request.amount,
description: request.description,
},
};
const result = await handleCommandWithDecider(
eventStore,
accountId,
command,
Accounts.decider
);
return {
newBalance: result.newState.balance,
message: `Withdrew $${request.amount}`,
};
}

Projections build read models optimized for queries. Instead of replaying events every time, we maintain pre-computed views.

Create src/projections/balances.projection.ts:

import type {
EventTypeOf,
IReadModelStore,
Projection,
ReadEvent,
} from '@delta-base/toolkit';
import { Accounts } from '../core/accounts';
// The shape of our read model
export interface AccountBalanceReadModel {
accountId: string;
customerId: string;
balance: number;
isOpen: boolean;
openedAt: string;
closedAt?: string;
lastTransactionAt: string;
_revision: number; // For idempotency
}
export class AccountBalancesProjection
implements Projection<Accounts.AccountEvents>
{
private readonly prefix = 'account-balance';
readonly supportedEventTypes: EventTypeOf<Accounts.AccountEvents>[] = [
'account.opened',
'money.deposited',
'money.withdrawn',
'account.closed',
];
constructor(private store: IReadModelStore) {}
async processEvents(
events: ReadEvent<Accounts.AccountEvents>[]
): Promise<void> {
for (const event of events) {
if (this.supportedEventTypes.includes(event.type)) {
await this.project(event);
}
}
}
private async project(
event: ReadEvent<Accounts.AccountEvents>
): Promise<void> {
const key = `${this.prefix}:${event.streamId}`;
const existing = await this.store.get<AccountBalanceReadModel>(key);
// Idempotency check
if (existing && existing._revision >= event.streamPosition) {
return;
}
switch (event.type) {
case 'account.opened':
await this.handleAccountOpened(event, key);
break;
case 'money.deposited':
await this.handleMoneyDeposited(event, key, existing);
break;
case 'money.withdrawn':
await this.handleMoneyWithdrawn(event, key, existing);
break;
case 'account.closed':
await this.handleAccountClosed(event, key, existing);
break;
}
}
private async handleAccountOpened(
event: ReadEvent<Accounts.AccountOpened>,
key: string
): Promise<void> {
const readModel: AccountBalanceReadModel = {
accountId: event.data.accountId,
customerId: event.data.customerId,
balance: event.data.initialBalance,
isOpen: true,
openedAt: event.data.openedAt,
lastTransactionAt: event.data.openedAt,
_revision: event.streamPosition,
};
await this.store.put(key, readModel);
}
private async handleMoneyDeposited(
event: ReadEvent<Accounts.MoneyDeposited>,
key: string,
existing: AccountBalanceReadModel | null
): Promise<void> {
if (!existing) {
console.error(`Account ${event.streamId} not found for deposit`);
return;
}
const updated: AccountBalanceReadModel = {
...existing,
balance: existing.balance + event.data.amount,
lastTransactionAt: event.data.depositedAt,
_revision: event.streamPosition,
};
await this.store.put(key, updated);
}
private async handleMoneyWithdrawn(
event: ReadEvent<Accounts.MoneyWithdrawn>,
key: string,
existing: AccountBalanceReadModel | null
): Promise<void> {
if (!existing) {
console.error(`Account ${event.streamId} not found for withdrawal`);
return;
}
const updated: AccountBalanceReadModel = {
...existing,
balance: existing.balance - event.data.amount,
lastTransactionAt: event.data.withdrawnAt,
_revision: event.streamPosition,
};
await this.store.put(key, updated);
}
private async handleAccountClosed(
event: ReadEvent<Accounts.AccountClosed>,
key: string,
existing: AccountBalanceReadModel | null
): Promise<void> {
if (!existing) {
return;
}
const updated: AccountBalanceReadModel = {
...existing,
isOpen: false,
closedAt: event.data.closedAt,
_revision: event.streamPosition,
};
await this.store.put(key, updated);
}
}

Create src/projections/balances.handler.ts:

import type { IReadModelStore, ReadEvent } from '@delta-base/toolkit';
import { Accounts } from '../core/accounts';
import { AccountBalancesProjection } from './balances.projection';
export interface ProcessEventsRequest {
events: ReadEvent<Accounts.AccountEvents>[];
}
export interface ProcessEventsResult {
processedEventCount: number;
}
export async function processBalanceEventsHandler(
store: IReadModelStore,
request: ProcessEventsRequest
): Promise<ProcessEventsResult> {
const projection = new AccountBalancesProjection(store);
await projection.processEvents(request.events);
return {
processedEventCount: request.events.length,
};
}

Create src/projections/balances.route.ts:

import { Hono } from 'hono';
import { processBalanceEventsHandler } from './balances.handler';
import type { IReadModelStore } from '@delta-base/toolkit';
export function createBalancesProjectionRoute(store: IReadModelStore) {
const app = new Hono();
app.post('/events', async (c) => {
// Verify projection auth token
const authHeader = c.req.header('Authorization');
const expectedToken = process.env.PROJECTION_AUTH_TOKEN || 'dev-token';
if (authHeader !== `Bearer ${expectedToken}`) {
return c.json({ error: 'Unauthorized' }, 401);
}
const body = await c.req.json();
const { events } = body;
if (!events || !Array.isArray(events)) {
return c.json({ error: 'Invalid request body' }, 400);
}
const result = await processBalanceEventsHandler(store, { events });
return c.json({
success: true,
...result,
});
});
return app;
}

Bring everything together in src/index.ts:

import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { serve } from '@hono/node-server';
import { InMemoryReadModelStore } from '@delta-base/toolkit';
import { openAccountHandler } from './functions/open-account';
import { depositHandler } from './functions/deposit';
import { withdrawHandler } from './functions/withdraw';
import { createBalancesProjectionRoute } from './projections/balances.route';
import type { AccountBalanceReadModel } from './projections/balances.projection';
const app = new Hono();
app.use('*', cors());
// Read model store (use KV in production)
const readModelStore = new InMemoryReadModelStore();
// ═══════════════════════════════════════════════════════════════════
// COMMAND ENDPOINTS (write side)
// ═══════════════════════════════════════════════════════════════════
app.post('/accounts', async (c) => {
try {
const body = await c.req.json();
const result = await openAccountHandler({
customerId: body.customerId,
initialBalance: body.initialBalance,
});
return c.json(result, 201);
} catch (error: any) {
return c.json({ error: error.message }, 400);
}
});
app.post('/accounts/:accountId/deposit', async (c) => {
try {
const accountId = c.req.param('accountId');
const body = await c.req.json();
const result = await depositHandler(accountId, {
amount: body.amount,
description: body.description,
});
return c.json(result);
} catch (error: any) {
return c.json({ error: error.message }, 400);
}
});
app.post('/accounts/:accountId/withdraw', async (c) => {
try {
const accountId = c.req.param('accountId');
const body = await c.req.json();
const result = await withdrawHandler(accountId, {
amount: body.amount,
description: body.description,
});
return c.json(result);
} catch (error: any) {
return c.json({ error: error.message }, 400);
}
});
// ═══════════════════════════════════════════════════════════════════
// QUERY ENDPOINTS (read side - from projections)
// ═══════════════════════════════════════════════════════════════════
app.get('/accounts/:accountId', async (c) => {
const accountId = c.req.param('accountId');
const account = await readModelStore.get<AccountBalanceReadModel>(
`account-balance:${accountId}`
);
if (!account) {
return c.json({ error: 'Account not found' }, 404);
}
return c.json(account);
});
app.get('/accounts', async (c) => {
const accounts = await readModelStore.list<AccountBalanceReadModel>(
'account-balance:'
);
return c.json(accounts);
});
// ═══════════════════════════════════════════════════════════════════
// PROJECTION WEBHOOK (receives events from DeltaBase)
// ═══════════════════════════════════════════════════════════════════
app.route(
'/api/projections/balances',
createBalancesProjectionRoute(readModelStore)
);
// ═══════════════════════════════════════════════════════════════════
// START SERVER
// ═══════════════════════════════════════════════════════════════════
const port = Number(process.env.PORT) || 3001;
console.log(`Banking API running on http://localhost:${port}`);
console.log(`
Endpoints:
POST /accounts - Open account
POST /accounts/:id/deposit - Deposit money
POST /accounts/:id/withdraw - Withdraw money
GET /accounts/:id - Get account (from projection)
GET /accounts - List accounts (from projection)
POST /api/projections/balances/events - Webhook for projections
`);
serve({ fetch: app.fetch, port });
export default app;

Create deltabase.config.ts to define your event store and webhook subscription:

import type { InfrastructureConfig as DeltaBaseConfig } from '@delta-base/server';
const SERVICE_URL = process.env.SERVICE_URL || 'http://localhost:3001';
const EVENT_STORE_NAME = process.env.DELTABASE_EVENT_STORE_NAME || 'banking';
const PROJECTION_TOKEN = process.env.PROJECTION_AUTH_TOKEN || 'dev-token';
const config: DeltaBaseConfig = {
eventStores: [
{
name: EVENT_STORE_NAME,
description: 'Banking system events',
subscriptions: [
{
id: 'account-balances-projection',
eventFilter: [
'account.opened',
'money.deposited',
'money.withdrawn',
'account.closed',
],
subscriberType: 'webhook',
webhook: {
url: `${SERVICE_URL}/api/projections/balances/events`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${PROJECTION_TOKEN}`,
},
retryPolicy: {
maxAttempts: 5,
backoffMinutes: 2,
},
},
},
],
},
],
};
export default config;

First, create the event store and subscription:

Terminal window
pnpx @delta-base/cli deploy
Terminal window
npx tsx src/index.ts
Terminal window
# Open an account
curl -X POST http://localhost:3001/accounts \
-H "Content-Type: application/json" \
-d '{"customerId": "cust-123", "initialBalance": 100}'
# Note the accountId from the response, then:
# Deposit money
curl -X POST http://localhost:3001/accounts/account-xxx/deposit \
-H "Content-Type: application/json" \
-d '{"amount": 50, "description": "Paycheck"}'
# Try to withdraw more than balance (should fail)
curl -X POST http://localhost:3001/accounts/account-xxx/withdraw \
-H "Content-Type: application/json" \
-d '{"amount": 200}'
# Withdraw valid amount
curl -X POST http://localhost:3001/accounts/account-xxx/withdraw \
-H "Content-Type: application/json" \
-d '{"amount": 30, "description": "Coffee"}'
# Get account balance (from projection)
curl http://localhost:3001/accounts/account-xxx
# List all accounts
curl http://localhost:3001/accounts

Open http://localhost:3000 to see:

  • Your events in real-time
  • Stream details for each account
  • Event payloads and metadata

Part 8: Real-time Balance Updates (Optional)

Section titled “Part 8: Real-time Balance Updates (Optional)”

Add WebSocket support to receive live balance updates in the browser.

Add to src/index.ts:

// Add WebSocket endpoint for real-time updates
app.get('/ws/accounts/:accountId', async (c) => {
const accountId = c.req.param('accountId');
// Connect to DeltaBase WebSocket
const ws = new WebSocket(
`ws://localhost:8787/event-stores/banking/events/ws`
);
ws.onopen = () => {
ws.send(JSON.stringify({
action: 'subscribe',
eventFilter: ['money.deposited', 'money.withdrawn'],
streamId: accountId,
position: 'latest',
}));
};
// Forward events to client
// (Implementation depends on your WebSocket library)
return c.text('WebSocket endpoint - connect with a WebSocket client');
});

For a complete WebSocket implementation, see the Real-Time Events Guide.


┌─────────────────────────────────────────────────────────────────────────┐
│ Complete Banking Architecture │
│ │
│ Client │
│ │ │
│ ├─POST /accounts─────────┐ │
│ ├─POST /deposit──────────┤ │
│ └─POST /withdraw─────────┤ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Command Handler │ │
│ │ (Decider) │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Event Store │ │
│ │ (DeltaBase) │ │
│ └────────┬────────┘ │
│ │ │
│ Webhook │
│ │ │
│ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Projection │─────▶│ Read Model │ │
│ │ (Balances) │ │ (KV Store) │ │
│ └─────────────────┘ └────────┬────────┘ │
│ │ │
│ Client │ │
│ │ │ │
│ ├─GET /accounts/:id──────────────────────────────┘ │
│ └─GET /accounts──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘

You now have:

  1. Domain logic isolated in the Decider (pure functions, testable)
  2. Command handlers that use handleCommandWithDecider for atomic operations
  3. Projections that build read models via webhooks
  4. Fast queries from projected read models
  5. Infrastructure as Code with deltabase.config.ts