Shopping cart
Inventory, orders, and handling conflicts when things get scarce
You built a banking system. Now let’s build an e-commerce store. This tutorial adds complexity: inventory management, cross-domain workflows, and handling conflicts when two customers want the last item.
What we’re building
Section titled “What we’re building”By the end of this tutorial, you’ll have:
- Inventory system with stock tracking
- Shopping cart that reserves items temporarily
- Order processing that handles payment and fulfillment
- Conflict resolution when inventory runs low
- Cross-aggregate workflows (cart → inventory → order → payment)
Should take about 30 minutes. Let’s build something real.
Why this matters
Section titled “Why this matters”The banking tutorial taught you basic event sourcing. This tutorial teaches you how multiple parts of a system work together with events. You’ll hit real problems:
- Race conditions - Two people buying the last item
- Consistency boundaries - What happens atomically vs eventually
- Workflow orchestration - Multi-step processes that can fail
- Business rules - Complex logic across domains
This is where event sourcing starts to shine.
Step 1: Start with inventory
Section titled “Step 1: Start with inventory”Let’s start with a simple inventory system that tracks products and stock levels.
npx @delta-base/cli dev
pnpm exec @delta-base/cli dev
yarn @delta-base/cli dev
bunx @delta-base/cli dev
Create our inventory domain:
import { createEventStore } from '@delta-base/server';
interface InventoryEvent { type: 'ProductAdded' | 'StockReplenished' | 'StockReserved' | 'StockReleased'; productId?: string; name?: string; price?: number; quantity?: number; reservationId?: string;}
const eventStore = createEventStore<InventoryEvent>('inventory');
export async function addProduct(productId: string, name: string, price: number, initialStock: number) { await eventStore.appendToStream(productId, [ { type: 'ProductAdded', productId, name, price, quantity: initialStock } ]);}
export async function replenishStock(productId: string, quantity: number) { await eventStore.appendToStream(productId, [ { type: 'StockReplenished', productId, quantity } ]);}
export async function reserveStock(productId: string, quantity: number, reservationId: string) { // Get current stock level const events = await eventStore.readStream(productId); const currentStock = calculateStock(events);
if (currentStock < quantity) { throw new Error(`Insufficient stock. Available: ${currentStock}, Requested: ${quantity}`); }
await eventStore.appendToStream(productId, [ { type: 'StockReserved', productId, quantity, reservationId } ]);}
export async function releaseStock(productId: string, reservationId: string) { // Find the reservation and release it const events = await eventStore.readStream(productId); const reservation = events.find(e => e.type === 'StockReserved' && e.reservationId === reservationId );
if (!reservation) { throw new Error(`Reservation ${reservationId} not found`); }
await eventStore.appendToStream(productId, [ { type: 'StockReleased', productId, quantity: reservation.quantity, reservationId } ]);}
function calculateStock(events: InventoryEvent[]): number { let available = 0; let reserved = 0;
for (const event of events) { switch (event.type) { case 'ProductAdded': case 'StockReplenished': available += event.quantity!; break; case 'StockReserved': available -= event.quantity!; reserved += event.quantity!; break; case 'StockReleased': available += event.quantity!; reserved -= event.quantity!; break; } }
return available;}
export async function getProductStock(productId: string) { const events = await eventStore.readStream(productId); const available = calculateStock(events); const product = events.find(e => e.type === 'ProductAdded');
return { productId, name: product?.name || 'Unknown', price: product?.price || 0, available, events: events.length };}
Step 2: Build the shopping cart
Section titled “Step 2: Build the shopping cart”Now let’s create a shopping cart that reserves inventory temporarily:
import { createEventStore } from '@delta-base/server';import { reserveStock, releaseStock } from './inventory';
interface CartEvent { type: 'CartCreated' | 'ItemAdded' | 'ItemRemoved' | 'CartCheckedOut' | 'CartAbandoned'; customerId?: string; productId?: string; quantity?: number; reservationId?: string;}
const eventStore = createEventStore<CartEvent>('carts');
export async function createCart(cartId: string, customerId: string) { await eventStore.appendToStream(cartId, [ { type: 'CartCreated', customerId } ]);}
export async function addToCart(cartId: string, productId: string, quantity: number) { const reservationId = `cart-${cartId}-${productId}-${Date.now()}`;
try { // Reserve inventory first await reserveStock(productId, quantity, reservationId);
// Then add to cart await eventStore.appendToStream(cartId, [ { type: 'ItemAdded', productId, quantity, reservationId } ]);
return { success: true, reservationId }; } catch (error) { return { success: false, error: error.message }; }}
export async function removeFromCart(cartId: string, productId: string) { const events = await eventStore.readStream(cartId);
// Find the reservation for this product const addEvent = events .filter(e => e.type === 'ItemAdded' && e.productId === productId) .pop(); // Get the most recent add
if (!addEvent) { throw new Error(`Product ${productId} not found in cart`); }
// Release the inventory reservation await releaseStock(productId, addEvent.reservationId!);
// Remove from cart await eventStore.appendToStream(cartId, [ { type: 'ItemRemoved', productId, reservationId: addEvent.reservationId } ]);}
export async function getCart(cartId: string) { const events = await eventStore.readStream(cartId); const items = new Map();
for (const event of events) { switch (event.type) { case 'ItemAdded': items.set(event.productId, { productId: event.productId, quantity: event.quantity, reservationId: event.reservationId }); break; case 'ItemRemoved': items.delete(event.productId); break; } }
return { cartId, customerId: events.find(e => e.type === 'CartCreated')?.customerId, items: Array.from(items.values()), totalItems: Array.from(items.values()).reduce((sum, item) => sum + item.quantity, 0) };}
Step 3: Handle the race condition
Section titled “Step 3: Handle the race condition”Here’s where it gets interesting. What happens when two customers try to buy the last item?
Let’s test this scenario:
import { addProduct, getProductStock } from './inventory';import { createCart, addToCart } from './cart';
async function testRaceCondition() { // Add a product with only 1 item in stock await addProduct('limited-edition-sneakers', 'Limited Edition Sneakers', 299.99, 1);
console.log('Initial stock:', await getProductStock('limited-edition-sneakers'));
// Create two carts await createCart('cart-customer-1', 'customer-1'); await createCart('cart-customer-2', 'customer-2');
// Both customers try to add the same item simultaneously console.log('\n--- Race condition test ---');
const [result1, result2] = await Promise.all([ addToCart('cart-customer-1', 'limited-edition-sneakers', 1), addToCart('cart-customer-2', 'limited-edition-sneakers', 1) ]);
console.log('Customer 1 result:', result1); console.log('Customer 2 result:', result2);
console.log('\nFinal stock:', await getProductStock('limited-edition-sneakers'));}
testRaceCondition().catch(console.error);
npx tsx src/test-race-condition.ts
pnpm exec tsx src/test-race-condition.ts
yarn tsx src/test-race-condition.ts
bun run src/test-race-condition.ts
Result: One customer gets the item, the other gets an error. This is correct behavior! The first request to complete gets the item.
Open Studio at http://localhost:3000
to see the events:
ProductAdded
for the sneakersStockReserved
for the winning customer- The losing customer gets an error (no events created)
Step 4: Build the order system
Section titled “Step 4: Build the order system”Now let’s create orders that convert cart reservations to actual sales:
import { createEventStore } from '@delta-base/server';import { getCart } from './cart';
interface OrderEvent { type: 'OrderCreated' | 'OrderPaid' | 'OrderShipped' | 'OrderCancelled'; orderId?: string; customerId?: string; items?: { productId: string; quantity: number; price: number }[]; total?: number; paymentId?: string; trackingNumber?: string;}
const eventStore = createEventStore<OrderEvent>('orders');
export async function createOrderFromCart(orderId: string, cartId: string) { const cart = await getCart(cartId);
if (cart.items.length === 0) { throw new Error('Cannot create order from empty cart'); }
// In a real app, you'd fetch product prices here const items = cart.items.map(item => ({ productId: item.productId, quantity: item.quantity, price: 299.99 // Mock price }));
const total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
await eventStore.appendToStream(orderId, [ { type: 'OrderCreated', orderId, customerId: cart.customerId, items, total } ]);
return { orderId, total, items };}
export async function processPayment(orderId: string, paymentId: string) { // Mock payment processing const success = Math.random() > 0.1; // 90% success rate
if (success) { await eventStore.appendToStream(orderId, [ { type: 'OrderPaid', orderId, paymentId } ]); return { success: true, paymentId }; } else { await eventStore.appendToStream(orderId, [ { type: 'OrderCancelled', orderId, reason: 'Payment failed' } ]); return { success: false, error: 'Payment declined' }; }}
export async function getOrder(orderId: string) { const events = await eventStore.readStream(orderId); const createEvent = events.find(e => e.type === 'OrderCreated'); const paidEvent = events.find(e => e.type === 'OrderPaid'); const cancelledEvent = events.find(e => e.type === 'OrderCancelled');
if (!createEvent) { throw new Error('Order not found'); }
let status = 'created'; if (paidEvent) status = 'paid'; if (cancelledEvent) status = 'cancelled';
return { orderId: createEvent.orderId, customerId: createEvent.customerId, items: createEvent.items, total: createEvent.total, status, paymentId: paidEvent?.paymentId };}
Step 5: Build the API
Section titled “Step 5: Build the API”Let’s create a complete API that ties everything together:
import { Hono } from 'hono';import { addProduct, getProductStock, reserveStock } from './inventory';import { createCart, addToCart, removeFromCart, getCart } from './cart';import { createOrderFromCart, processPayment, getOrder } from './orders';
const app = new Hono();
// Inventory endpointsapp.post('/products', async (c) => { const { productId, name, price, stock } = await c.req.json(); await addProduct(productId, name, price, stock); return c.json({ success: true });});
app.get('/products/:id', async (c) => { const productId = c.req.param('id'); const stock = await getProductStock(productId); return c.json(stock);});
// Cart endpointsapp.post('/carts', async (c) => { const { cartId, customerId } = await c.req.json(); await createCart(cartId, customerId); return c.json({ success: true, cartId });});
app.post('/carts/:id/items', async (c) => { const cartId = c.req.param('id'); const { productId, quantity } = await c.req.json();
const result = await addToCart(cartId, productId, quantity);
if (result.success) { return c.json({ success: true, reservationId: result.reservationId }); } else { return c.json({ success: false, error: result.error }, 400); }});
app.delete('/carts/:cartId/items/:productId', async (c) => { const cartId = c.req.param('cartId'); const productId = c.req.param('productId');
try { await removeFromCart(cartId, productId); return c.json({ success: true }); } catch (error) { return c.json({ success: false, error: error.message }, 400); }});
app.get('/carts/:id', async (c) => { const cartId = c.req.param('id'); const cart = await getCart(cartId); return c.json(cart);});
// Order endpointsapp.post('/orders', async (c) => { const { orderId, cartId } = await c.req.json();
try { const order = await createOrderFromCart(orderId, cartId); return c.json({ success: true, order }); } catch (error) { return c.json({ success: false, error: error.message }, 400); }});
app.post('/orders/:id/payment', async (c) => { const orderId = c.req.param('id'); const { paymentId } = await c.req.json();
const result = await processPayment(orderId, paymentId);
if (result.success) { return c.json({ success: true, paymentId: result.paymentId }); } else { return c.json({ success: false, error: result.error }, 400); }});
app.get('/orders/:id', async (c) => { const orderId = c.req.param('id');
try { const order = await getOrder(orderId); return c.json(order); } catch (error) { return c.json({ error: error.message }, 404); }});
export default app;
Step 6: Test the full workflow
Section titled “Step 6: Test the full workflow”Let’s test the complete e-commerce flow:
import { addProduct } from './inventory';import { createCart, addToCart } from './cart';import { createOrderFromCart, processPayment } from './orders';
async function testFullWorkflow() { console.log('🛍️ E-commerce workflow test');
// 1. Add products await addProduct('sneakers', 'Cool Sneakers', 149.99, 5); await addProduct('t-shirt', 'Cool T-Shirt', 29.99, 10);
console.log('✅ Products added');
// 2. Create cart and add items await createCart('cart-123', 'customer-alice');
const result1 = await addToCart('cart-123', 'sneakers', 1); const result2 = await addToCart('cart-123', 't-shirt', 2);
console.log('✅ Items added to cart:', { result1, result2 });
// 3. Create order const order = await createOrderFromCart('order-456', 'cart-123'); console.log('✅ Order created:', order);
// 4. Process payment const payment = await processPayment('order-456', 'payment-789'); console.log('✅ Payment processed:', payment);
console.log('\n🎉 Full workflow completed successfully!');}
testFullWorkflow().catch(console.error);
npx tsx src/test-full-workflow.ts
pnpm exec tsx src/test-full-workflow.ts
yarn tsx src/test-full-workflow.ts
bun run src/test-full-workflow.ts
What you learned
Section titled “What you learned”This tutorial introduced several advanced event sourcing concepts:
Cross-aggregate workflows
Section titled “Cross-aggregate workflows”- Cart talks to Inventory via reservations
- Orders coordinate between Cart and Inventory
- Each domain maintains its own consistency
Conflict resolution
Section titled “Conflict resolution”- Race conditions handled gracefully
- First-come-first-served for limited inventory
- Clear error messages for customers
Temporal reservations
Section titled “Temporal reservations”- Inventory reserved temporarily for carts
- Released when items removed or carts abandoned
- Converted to permanent allocation on order
Event choreography
Section titled “Event choreography”- Systems coordinate through events, not API calls
- Each domain maintains its own state
- Workflow emerges from event interactions
What’s next?
Section titled “What’s next?”Check out Studio at http://localhost:3000
to see all the events flowing through your system. You’ll see:
- Inventory reservations and releases
- Cart state changes over time
- Order creation and payment processing
Try breaking things:
- What happens if payment fails after order creation?
- How do you handle abandoned carts?
- What if someone adds items faster than they can be reserved?
Next tutorial: Real-time chat - Add WebSockets and collaboration to event sourcing.
You’re building real systems now. Event sourcing scales with complexity.