Skip to content

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.

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.

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.

Let’s start with a simple inventory system that tracks products and stock levels.

Terminal window
npx @delta-base/cli dev

Create our inventory domain:

src/inventory.ts
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
};
}

Now let’s create a shopping cart that reserves inventory temporarily:

src/cart.ts
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)
};
}

Here’s where it gets interesting. What happens when two customers try to buy the last item?

Let’s test this scenario:

src/test-race-condition.ts
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);
Terminal window
npx tsx 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 sneakers
  • StockReserved for the winning customer
  • The losing customer gets an error (no events created)

Now let’s create orders that convert cart reservations to actual sales:

src/orders.ts
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
};
}

Let’s create a complete API that ties everything together:

src/api.ts
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 endpoints
app.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 endpoints
app.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 endpoints
app.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;

Let’s test the complete e-commerce flow:

src/test-full-workflow.ts
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);
Terminal window
npx tsx src/test-full-workflow.ts

This tutorial introduced several advanced event sourcing concepts:

  • Cart talks to Inventory via reservations
  • Orders coordinate between Cart and Inventory
  • Each domain maintains its own consistency
  • Race conditions handled gracefully
  • First-come-first-served for limited inventory
  • Clear error messages for customers
  • Inventory reserved temporarily for carts
  • Released when items removed or carts abandoned
  • Converted to permanent allocation on order
  • Systems coordinate through events, not API calls
  • Each domain maintains its own state
  • Workflow emerges from event interactions

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.