Build an Event-Sourced Order Service
Instructions
Objective
Build a complete event-sourced order service that demonstrates the core data architecture patterns covered in the lesson: event sourcing, CQRS, the Saga pattern, and snapshot optimization. This lab mirrors the architecture you would propose in a system design interview for an e-commerce order service.
Architecture Overview
Commands Event Store (append-only)
| |
v v
Command Handler -----> Events -> [E1, E2, E3, E4, ...]
| |
v v
Order Projection Analytics Projection
(current state) (revenue, counts)
|
v
Snapshot Manager
(periodic snapshots)
Saga Orchestrator
|
+---> Payment Step ----> (success) ---> Inventory Step
| |
| (success) ---> Shipping Step
| |
+--- Compensation <--- (failure at any step) <-----------+
One File, Eight Sections
The editor below is a single text area, not a project tree. The paths above are the layout you would use in a real repository; inside the editor the eight sections are concatenated into one TypeScript module, top to bottom.
Two consequences worth knowing before you start:
- Cross-file imports are commented out. A section can already see everything the
sections above it declared, so a live
import { DomainEvent } from "./event-types"would fail to resolve and collide with the declaration further down the same file. The commented lines are there to show the real repository layout. - Order matters. Interfaces and types must be declared before the section that annotates with them.
If you would rather work locally, split the sections into the files named in their banners and uncomment the imports.
Requirements
You will implement 8 TypeScript files that work together:
FILE 1: src/events/event-store.ts — Append-Only Event Store
- Implement an in-memory append-only event store
- Each event has an
aggregateId(e.g., orderId) and a sequentialversionnumber - Support optimistic concurrency control: when appending events, pass the expected version; reject if there is a version conflict (another write happened concurrently)
- Implement
append(aggregateId, events, expectedVersion): throws on version mismatch - Implement
getEvents(aggregateId): returns all events for an aggregate in order - Implement
getEventsAfterVersion(aggregateId, afterVersion): returns events after a given version (used with snapshots) - Implement
getAllEvents(): returns all events across all aggregates (for analytics projection)
FILE 2: src/events/event-types.ts — Domain Events
- Define a base
DomainEventinterface with:type,aggregateId,version,timestamp,metadata(correlationId, causationId) - Define order domain events:
OrderCreated,ItemAdded,PaymentProcessed,OrderShipped,OrderCancelled - Each event carries the data specific to what happened (e.g.,
OrderCreatedincludes customerId and items) - Use TypeScript discriminated unions for type safety
FILE 3: src/commands/command-handler.ts — Command Handler
- Handle commands:
PlaceOrder,AddItem,ProcessPayment,ShipOrder,CancelOrder - Each command handler: loads current state from the event store, validates business rules, produces new events
- Business rules to enforce:
- Cannot add items to a cancelled or shipped order
- Cannot process payment on a cancelled order
- Cannot ship an order that has not been paid
- Cannot cancel an already shipped order
- Use the event store's optimistic concurrency for safe writes
FILE 4: src/projections/order-projection.ts — Order Read Model
- Materialize the current order state by replaying events
- The read model should include:
orderId,customerId,items,status,totalAmount,paymentId,shippedAt,cancelledAt,version - Statuses:
pending,paid,shipped,cancelled - Implement
getOrder(orderId): returns current order state - Implement
rebuildFromEvents(events): rebuilds state from a list of events
FILE 5: src/projections/analytics-projection.ts — Analytics Projection
- Maintain aggregate metrics across all orders:
totalRevenue: sum of all paid order amountstotalOrders: count of all orders createdaverageOrderValue: totalRevenue / number of paid ordersordersByStatus: count of orders in each statuscancelledRevenue: total amount of cancelled orders (revenue lost)
- Process events incrementally (do not replay all events each time)
- Implement
processEvent(event)andgetAnalytics()
FILE 6: src/saga/order-saga.ts — Saga Orchestrator
- Implement an orchestration-based Saga for the order workflow with 3 steps:
- Payment: Charge the customer (simulate with async function)
- Inventory: Reserve items (simulate with async function)
- Shipping: Schedule shipment (simulate with async function)
- Each step can succeed or fail (use configurable failure simulation)
- On failure at any step, execute compensation for all previously completed steps in reverse order:
- Shipping failure: release inventory, refund payment
- Inventory failure: refund payment
- Track saga state:
pending,payment_completed,inventory_reserved,completed,compensating,compensated,failed - Log each step for visibility
FILE 7: src/snapshots/snapshot-manager.ts — Snapshot Manager
- Store periodic snapshots of aggregate state to avoid replaying all events
- Implement
saveSnapshot(aggregateId, state, version): save a snapshot at a given version - Implement
getLatestSnapshot(aggregateId): return the most recent snapshot - Implement
shouldSnapshot(eventCount): return true if a snapshot should be taken (e.g., every 5 events for this lab; in production, every 100) - Implement
loadAggregateState(aggregateId, eventStore, projector):- Load the latest snapshot (if any)
- Get events after the snapshot version
- Apply those events to the snapshot state
- Return the current state
FILE 8: src/index.ts — Main Entry
- Wire everything together
- Run a test scenario that demonstrates:
- Create an order with 2 items
- Add another item to the order
- Process payment
- Ship the order
- Query the order projection for current state
- Query analytics for aggregate metrics
- Demonstrate snapshot save and load
- Run the Saga orchestrator (once with success, once with a simulated failure showing compensation)
- Print results at each step to demonstrate the full workflow
Hints
- Use
Map<string, DomainEvent[]>for the in-memory event store, keyed by aggregateId - For optimistic concurrency, compare the expected version against the current number of events for that aggregate
- The saga orchestrator can use simple async/await with try/catch for step execution and compensation
- For the analytics projection, track a
processedEventIdsset to avoid processing the same event twice - The snapshot manager is essentially a
Map<string, { state: OrderState; version: number }> - Use
console.logwith clear labels (e.g.,[EventStore],[Saga],[Projection]) to show what is happening
What to Submit
Your submission should contain 8 file sections in the editor below, each implementing one of the files described above.