Skip to content

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.

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.

First, let’s run DeltaBase with the Studio for visualization:

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 - we’ll use the Studio to visualize our bank transactions!

In a new terminal, create your project:

Terminal window
mkdir deltabase-bank
cd deltabase-bank
npm init -y

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:

Terminal window
npm install @delta-base/server @delta-base/toolkit hono
npm install -D @types/node tsx nodemon

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

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:

Terminal window
npx tsx 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 events
export 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 events
export 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.

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 do
export 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 happened
export 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 events
export 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.

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

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 account
app.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 info
app.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 money
app.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 money
app.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 deployment
if (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
});
}

Start your server:

Terminal window
npm run dev

Test the Command -> Event flow with curl:

Terminal window
# Open an account
curl -X POST http://localhost:3000/accounts \
-H "Content-Type: application/json" \
-d '{"customerId": "customer-123", "initialDeposit": 100}'
# Check balance
curl http://localhost:3000/accounts/account-1640995200000
# Deposit money
curl -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 amount
curl -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:

  1. Select your ‘bank’ event store
  2. Click on “Events” to see all your banking events
  3. Click on “Streams” to see individual accounts
  4. Watch events appear in real-time as you make API calls!

This is production-ready banking software:

  1. Atomic command processing - No race conditions, commands are validated against current state
  2. Perfect audit trail - Every transaction is permanently recorded (see it in Studio!)
  3. Business rule enforcement - Can’t overdraft, can’t deposit negative amounts
  4. Immutable transaction history - Events never change, perfect for compliance
  5. Time travel capability - Calculate balance at any point in history
  6. Beautiful visualization - Studio shows you exactly what’s happening
  7. Universal deployment - Hono runs on any platform

Compare this to our original simple approach:

❌ Original approach:

// Race condition! Balance could change between read and write
const balance = await getAccountBalance(accountId);
if (balance >= amount) {
await withdrawMoney(accountId, amount); // Might fail!
}

✅ Command approach:

// Atomic! Read current state, validate, then write events
await handleAccountCommand(accountId, {
type: 'WithdrawMoney',
data: { accountId, amount }
});

Commands solve concurrency problems and make business rules explicit.

Terminal window
# Try to withdraw from non-existent account
curl -X POST http://localhost:3000/accounts/fake-account/withdraw \
-H "Content-Type: application/json" \
-d '{"amount": 100}'
# Try to deposit negative amount
curl -X POST http://localhost:3000/accounts/account-1640995200000/deposit \
-H "Content-Type: application/json" \
-d '{"amount": -50}'
# Try to open account with negative initial deposit
curl -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.

Since you used Hono, this exact code can deploy to:

  • Cloudflare Workers - Add wrangler.toml, deploy with wrangler 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.

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.