Banking system
Build accounts with deposits, withdrawals, and business rules
Time to build something real. We’re going to create a bank account system that properly handles commands, enforces business rules, and provides a real API. No fake demos.
What we’re building
Section titled “What we’re building”By the end of this tutorial, you’ll have:
- A working bank account system with proper event sourcing
- Command validation that prevents overdrafts
- Complete audit trail of every transaction (visible in Studio!)
- REST API built with Hono (deployable anywhere)
- Understanding of why commands matter
Should take about 20 minutes. Let’s go.
Step 1: Start DeltaBase locally
Section titled “Step 1: Start DeltaBase locally”First, let’s run DeltaBase with the Studio for visualization:
npx @delta-base/cli dev
yarn dlx @delta-base/cli dev
pnpx @delta-base/cli dev
bunx @delta-base/cli dev
deno run -A npm:@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 - we’ll use the Studio to visualize our bank transactions!
Step 2: Set up your project
Section titled “Step 2: Set up your project”In a new terminal, create your project:
mkdir deltabase-bankcd deltabase-banknpm init -y
mkdir deltabase-bankcd deltabase-bankyarn init -y
mkdir deltabase-bankcd deltabase-bankpnpm init
mkdir deltabase-bankcd deltabase-bankbun init -y
mkdir deltabase-bankcd deltabase-bankdeno init
Install DeltaBase and Hono. We love Hono because the same code runs on Cloudflare Workers, Lambda, Azure Functions, or containers. It’s fast and sticks to web standards:
npm install @delta-base/server @delta-base/toolkit hononpm install -D @types/node tsx nodemon
yarn add @delta-base/server @delta-base/toolkit honoyarn add -D @types/node tsx nodemon
pnpm add @delta-base/server @delta-base/toolkit honopnpm add -D @types/node tsx nodemon
bun add @delta-base/server @delta-base/toolkit honobun add -D @types/node tsx nodemon
deno add @delta-base/server @delta-base/toolkit hono
You could use Express, Fastify, or any other framework. But Hono’s versatility is hard to beat.
Create your package.json
scripts:
{ "scripts": { "dev": "nodemon --exec tsx src/server.ts", "build": "tsc", "start": "node dist/server.js" }}
Step 3: Create an event store
Section titled “Step 3: Create an event store”Before we can store events, we need to create an event store. Create src/setup.ts
:
import { DeltaBase } from '@delta-base/server';
const deltabase = new DeltaBase({ baseUrl: 'http://localhost:3000' // Your local DeltaBase instance});
const managementClient = deltabase.getManagementClient();
async function setupEventStore() { try { // Create an event store called 'bank' const eventStore = await managementClient.createEventStore({ name: 'bank', description: 'Banking system with commands and events' });
console.log('✅ Event store created:', eventStore.name); console.log('📊 Check it out in Studio: http://localhost:3001'); } catch (error) { if (error.message?.includes('already exists')) { console.log('✅ Event store already exists'); } else { console.error('❌ Failed to create event store:', error.message); } }}
setupEventStore();
Run the setup:
npx tsx src/setup.ts
yarn dlx tsx src/setup.ts
pnpm dlx tsx src/setup.ts
bun run src/setup.ts
deno run --allow-net src/setup.ts
Step 4: Start with events (like getting started)
Section titled “Step 4: Start with events (like getting started)”Create src/account.ts
- let’s start simple, like the getting started tutorial:
import { DeltaBase } from '@delta-base/server';
const deltabase = new DeltaBase({ baseUrl: 'http://localhost:3000' // Your local DeltaBase instance});
export const eventStore = deltabase.getEventStore('bank');
// Simple function to calculate balance from eventsexport async function getAccountBalance(accountId: string): Promise<number> { const result = await eventStore.readStream(accountId);
let balance = 0; for (const event of result.events) { if (event.type === 'money.deposited') { balance += event.data.amount; } else if (event.type === 'money.withdrawn') { balance -= event.data.amount; } }
return balance;}
// Simple functions to store eventsexport async function depositMoney(accountId: string, amount: number) { await eventStore.appendToStream(accountId, [{ type: 'money.deposited', data: { amount } }]);}
export async function withdrawMoney(accountId: string, amount: number) { // Add validation - this is where commands become useful! const currentBalance = await getAccountBalance(accountId);
if (currentBalance < amount) { throw new Error(`Insufficient funds. Balance: $${currentBalance}, Attempted: $${amount}`); }
await eventStore.appendToStream(accountId, [{ type: 'money.withdrawn', data: { amount } }]);}
This is exactly like the getting started tutorial, but with validation added. You can see the problem - we’re reading the balance twice during a withdrawal (once to check, once when withdrawing). What if someone else deposits money in between? That’s where commands help.
Step 5: Add command types
Section titled “Step 5: Add command types”Commands represent intent - what someone wants to do. Create src/types.ts
:
import type { Command, Event } from '@delta-base/toolkit';
// Commands are intent - what someone wants to doexport type OpenAccount = Command<'OpenAccount', { accountId: string; customerId: string; initialDeposit: number;}>;
export type DepositMoney = Command<'DepositMoney', { accountId: string; amount: number;}>;
export type WithdrawMoney = Command<'WithdrawMoney', { accountId: string; amount: number;}>;
export type AccountCommand = OpenAccount | DepositMoney | WithdrawMoney;
// Events are facts - what actually happenedexport type AccountOpened = Event<'account.opened', { accountId: string; customerId: string; initialDeposit: number;}>;
export type MoneyDeposited = Event<'money.deposited', { accountId: string; amount: number;}>;
export type MoneyWithdrawn = Event<'money.withdrawn', { accountId: string; amount: number;}>;
export type AccountEvent = AccountOpened | MoneyDeposited | MoneyWithdrawn;
// State that gets built from eventsexport type AccountState = { accountId?: string; customerId?: string; balance: number; isOpen: boolean;};
Notice how commands are present tense (“DepositMoney”) and events are past tense (“MoneyDeposited”). Commands can be rejected, events are facts.
Step 6: Build a command handler
Section titled “Step 6: Build a command handler”Now we solve the race condition problem with proper command handling. Create src/account-handler.ts
:
import { handleCommandWithDecider } from '@delta-base/toolkit';import { eventStore } from './account';import type { AccountCommand, AccountEvent, AccountState } from './types';
// This is the "decider pattern" - it's how we handle commands properlyconst accountDecider = { // Business logic: given current state and command, what events should happen? decide: (command: AccountCommand, state: AccountState): AccountEvent[] => { switch (command.type) { case 'OpenAccount': if (state.isOpen) { throw new Error('Account already exists'); } if (command.data.initialDeposit < 0) { throw new Error('Initial deposit must be positive'); } return [{ type: 'account.opened', data: { accountId: command.data.accountId, customerId: command.data.customerId, initialDeposit: command.data.initialDeposit } }];
case 'DepositMoney': if (!state.isOpen) { throw new Error('Account does not exist'); } if (command.data.amount <= 0) { throw new Error('Deposit amount must be positive'); } return [{ type: 'money.deposited', data: { accountId: command.data.accountId, amount: command.data.amount } }];
case 'WithdrawMoney': if (!state.isOpen) { throw new Error('Account does not exist'); } if (command.data.amount <= 0) { throw new Error('Withdrawal amount must be positive'); } // Here's the key - we check balance against CURRENT state // No race conditions because this happens atomically if (state.balance < command.data.amount) { throw new Error(`Insufficient funds. Balance: $${state.balance}, Attempted: $${command.data.amount}`); } return [{ type: 'money.withdrawn', data: { accountId: command.data.accountId, amount: command.data.amount } }];
default: return []; } },
// State evolution: how events change state evolve: (state: AccountState, event: AccountEvent): AccountState => { switch (event.type) { case 'account.opened': return { ...state, accountId: event.data.accountId, customerId: event.data.customerId, balance: event.data.initialDeposit, isOpen: true };
case 'money.deposited': return { ...state, balance: state.balance + event.data.amount };
case 'money.withdrawn': return { ...state, balance: state.balance - event.data.amount };
default: return state; } },
// Initial state initialState: (): AccountState => ({ balance: 0, isOpen: false })};
export async function handleAccountCommand(accountId: string, command: AccountCommand) { return handleCommandWithDecider( eventStore, accountId, command, accountDecider );}
This solves the race condition! The command handler reads current state, validates the command, and only then writes events. DeltaBase ensures this happens atomically.
Step 7: Build your API with Hono
Section titled “Step 7: Build your API with Hono”Create src/server.ts
with command-based endpoints:
import { Hono } from 'hono';import { cors } from 'hono/cors';import { handleAccountCommand } from './account-handler';import { eventStore } from './account';import type { AccountEvent, AccountState } from './types';
const app = new Hono();
app.use('*', cors());
// Open a new accountapp.post('/accounts', async (c) => { const { customerId, initialDeposit } = await c.req.json(); const accountId = `account-${Date.now()}`;
try { const result = await handleAccountCommand(accountId, { type: 'OpenAccount', data: { accountId, customerId, initialDeposit } });
return c.json({ accountId, message: 'Account opened', balance: result.newState.balance }); } catch (error) { return c.json({ error: error.message }, 400); }});
// Get account balance and infoapp.get('/accounts/:accountId', async (c) => { const accountId = c.req.param('accountId');
try { // Aggregate events to rebuild current state const aggregation = await eventStore.aggregateStream<AccountState, AccountEvent>(accountId, { evolve: (state, event) => { // Use the same evolve function from our decider const { accountDecider } = await import('./account-handler'); return accountDecider.evolve(state, event); }, initialState: () => ({ balance: 0, isOpen: false }) });
if (!aggregation.state.isOpen) { return c.json({ error: 'Account not found' }, 404); }
return c.json({ accountId: aggregation.state.accountId, customerId: aggregation.state.customerId, balance: aggregation.state.balance, isOpen: aggregation.state.isOpen }); } catch (error) { return c.json({ error: 'Failed to get account' }, 500); }});
// Deposit moneyapp.post('/accounts/:accountId/deposit', async (c) => { const accountId = c.req.param('accountId'); const { amount } = await c.req.json();
try { const result = await handleAccountCommand(accountId, { type: 'DepositMoney', data: { accountId, amount } });
return c.json({ message: 'Money deposited', newBalance: result.newState.balance }); } catch (error) { return c.json({ error: error.message }, 400); }});
// Withdraw moneyapp.post('/accounts/:accountId/withdraw', async (c) => { const accountId = c.req.param('accountId'); const { amount } = await c.req.json();
try { const result = await handleAccountCommand(accountId, { type: 'WithdrawMoney', data: { accountId, amount } });
return c.json({ message: 'Money withdrawn', newBalance: result.newState.balance }); } catch (error) { return c.json({ error: error.message }, 400); }});
// Get transaction history (all events for an account)app.get('/accounts/:accountId/transactions', async (c) => { const accountId = c.req.param('accountId');
try { const result = await eventStore.readStream<AccountEvent>(accountId); return c.json({ transactions: result.events.map(event => ({ type: event.type, data: event.data, timestamp: event.createdAt })), totalEvents: result.events.length }); } catch (error) { return c.json({ error: 'Failed to get transactions' }, 500); }});
export default app;
// For Node.js deploymentif (import.meta.env?.PROD) { const { serve } = await import('@hono/node-server'); const port = Number(process.env.PORT) || 3000;
console.log(`Banking API running on port ${port}`); serve({ fetch: app.fetch, port });}
Step 8: Test your banking system
Section titled “Step 8: Test your banking system”Start your server:
npm run dev
yarn dev
pnpm dev
bun run dev
deno task dev
Test the Command -> Event flow with curl:
# Open an accountcurl -X POST http://localhost:3000/accounts \ -H "Content-Type: application/json" \ -d '{"customerId": "customer-123", "initialDeposit": 100}'
# Check balancecurl http://localhost:3000/accounts/account-1640995200000
# Deposit moneycurl -X POST http://localhost:3000/accounts/account-1640995200000/deposit \ -H "Content-Type: application/json" \ -d '{"amount": 50}'
# Try to withdraw more than balance (should fail)curl -X POST http://localhost:3000/accounts/account-1640995200000/withdraw \ -H "Content-Type: application/json" \ -d '{"amount": 200}'
# Withdraw valid amountcurl -X POST http://localhost:3000/accounts/account-1640995200000/withdraw \ -H "Content-Type: application/json" \ -d '{"amount": 75}'
# View transaction history (audit trail)curl http://localhost:3000/accounts/account-1640995200000/transactions
Now check the Studio! Open http://localhost:3001 and:
- Select your ‘bank’ event store
- Click on “Events” to see all your banking events
- Click on “Streams” to see individual accounts
- Watch events appear in real-time as you make API calls!
What you just built
Section titled “What you just built”This is production-ready banking software:
- Atomic command processing - No race conditions, commands are validated against current state
- Perfect audit trail - Every transaction is permanently recorded (see it in Studio!)
- Business rule enforcement - Can’t overdraft, can’t deposit negative amounts
- Immutable transaction history - Events never change, perfect for compliance
- Time travel capability - Calculate balance at any point in history
- Beautiful visualization - Studio shows you exactly what’s happening
- Universal deployment - Hono runs on any platform
Why commands matter
Section titled “Why commands matter”Compare this to our original simple approach:
❌ Original approach:
// Race condition! Balance could change between read and writeconst balance = await getAccountBalance(accountId);if (balance >= amount) { await withdrawMoney(accountId, amount); // Might fail!}
✅ Command approach:
// Atomic! Read current state, validate, then write eventsawait handleAccountCommand(accountId, { type: 'WithdrawMoney', data: { accountId, amount }});
Commands solve concurrency problems and make business rules explicit.
Try breaking the business rules
Section titled “Try breaking the business rules”# Try to withdraw from non-existent accountcurl -X POST http://localhost:3000/accounts/fake-account/withdraw \ -H "Content-Type: application/json" \ -d '{"amount": 100}'
# Try to deposit negative amountcurl -X POST http://localhost:3000/accounts/account-1640995200000/deposit \ -H "Content-Type: application/json" \ -d '{"amount": -50}'
# Try to open account with negative initial depositcurl -X POST http://localhost:3000/accounts \ -H "Content-Type: application/json" \ -d '{"customerId": "customer-456", "initialDeposit": -25}'
All commands get rejected because your business logic says so. Events only happen when commands are valid.
Deploy anywhere
Section titled “Deploy anywhere”Since you used Hono, this exact code can deploy to:
- Cloudflare Workers - Add
wrangler.toml
, deploy withwrangler deploy
- AWS Lambda - Use the Lambda adapter
- Azure Functions - Use the Azure adapter
- Docker containers - The Node.js server works out of the box
- Vercel/Netlify - Serverless functions
No code changes needed. That’s the beauty of web standards.
What’s next?
Section titled “What’s next?”Your banking system works, but you can make it better:
- Add user authentication and authorization
- Implement interest calculations over time
- Add transfer commands between accounts
- Build read models for account statements
- Add real-time notifications for large transactions
- Implement regulatory reporting
The beauty of event sourcing is that you can add these features without breaking existing functionality. Your events are your single source of truth.
Ready to ship something real? You just learned the foundations.