Yogesht.info
โšก
ShopFlow
Event-Driven Order Processing System
Node.js AWS SNS AWS SQS DynamoDB Express Microservices EDA LocalStack
ShopFlow is a fully working backend system that processes customer orders through an event-driven microservices architecture. A single order placement triggers an autonomous chain of events across five independent services โ€” inventory reservation, payment processing, customer notification, and audit logging โ€” without any service talking directly to another. Built to demonstrate the same architectural patterns used at Amazon, Uber, and DoorDash at scale, deployable on AWS using SNS, SQS, and DynamoDB.
The Business Problem

When a customer places an order on an e-commerce platform, several things must happen: stock must be reserved, payment must be charged, the customer must be notified, and every action must be logged for compliance. In a traditional monolithic or direct-HTTP system, these steps happen sequentially in one service.

The problems with the traditional approach:

  • If the payment provider is slow, the entire order request hangs and the customer waits
  • If the notification service crashes, the order may fail even though payment succeeded
  • Adding a new service (e.g. fraud detection) means modifying existing services
  • Scaling inventory checks independently is not possible when everything is coupled
  • A single failure anywhere in the chain can cause data inconsistency

ShopFlow solves all of this by making each responsibility an independent service that reacts to events asynchronously. The Order Service returns in milliseconds โ€” everything else happens in the background.

System Architecture (Interactive Simulation)
Event-Driven Flow: Happy Path
๐Ÿ‘ค
Customer
POST /orders
โ–ถ
๐Ÿ›’
Order Service
Express :3000
โ–ถ
๐Ÿ“ก
SNS Topic
[shopflow-orders]
โ†™โ†“โ†“โ†˜
๐Ÿ“ฌ
SQS Queue
[inventory]
๐Ÿ“ฌ
SQS Queue
[payment]
๐Ÿ“ฌ
SQS Queue
[notification]
๐Ÿ“ฌ
SQS Queue
[audit]
โ–ผ
โ–ผ
โ–ผ
โ–ผ
๐Ÿ“ฆ
Inventory Svc
Reserves stock
๐Ÿ’ณ
Payment Svc
Charges card
๐Ÿ””
Notification Svc
Sends email
๐Ÿ“‹
Audit Svc
Logs events
โ–ผ
โ–ผ
๐Ÿ“ก
SNS Topic
[inv.reserved]
๐Ÿ“ก
SNS Topic
[pay.processed]
SystemReady. Click "Simulate Order Placed" to visualize the event flow.
Order Status Lifecycle
PENDING โ†’ INVENTORY_RESERVED โ†’ PAYMENT_PROCESSED โ†’ COMPLETED
PENDING โ†’ INVENTORY_FAILED (no payment attempted)
PENDING โ†’ INVENTORY_RESERVED โ†’ PAYMENT_FAILED (stock released)
Services โ€” Deep Dive
๐Ÿ›’
Order Service
Express HTTP Server ยท Port 3000
Express REST API SNS Producer DynamoDB
The entry point of the system. This is the only service that exposes an HTTP API to the outside world. It accepts order requests, validates them, calculates totals by looking up product prices from DynamoDB, saves the order record, and publishes a single event to SNS. It is the only event producer that starts the chain โ€” every other service reacts to events.
MethodPathDescription
POST/ordersPlace a new order. Validates input, looks up prices, saves to DynamoDB, publishes event.
GET/orders/:orderIdFetch current order status and full order record.
GET/healthHealth check endpoint for load balancer probes.
  • 1Validate request โ€” customerId and items[] must be present and valid
  • 2Look up each productId in DynamoDB inventory table โ€” return 404 if not found
  • 3Calculate totalAmount = sum of (price ร— quantity) per item
  • 4Save order to DynamoDB with status: PENDING
  • 5Publish order.created event to SNS with full order payload
  • 6Return 201 Created with orderId and status immediately โ€” no waiting for downstream
๐Ÿ“ค order.created
Why it returns immediately: The Order Service does not wait for inventory or payment. It publishes one event and responds to the customer in milliseconds. All downstream processing happens asynchronously. This is the core value of EDA โ€” the API is always fast regardless of how slow downstream services are.
{
  "customerId": "CUST-001",
  "items": [
    { "productId": "PROD-001", "quantity": 2 }
  ]
}
{
  "message": "Order placed successfully",
  "orderId": "ORD-A1B2C3D4",
  "status": "PENDING",
  "totalAmount": 99.98,
  "items": [
    {
      "productId": "PROD-001",
      "productName": "Wireless Headphones",
      "quantity": 2,
      "unitPrice": 49.99,
      "lineTotal": 99.98
    }
  ]
}
๐Ÿ“ฆ
Inventory Service
SQS Consumer ยท Long-polling
SQS Consumer SNS Producer DynamoDB Atomic Writes
Listens to the inventory SQS queue for order.created events. Its responsibility is to check if every item in the order is in stock and if so, reserve the stock atomically. It has no HTTP server โ€” it is a pure background worker that runs in an infinite polling loop.
๐Ÿ“ฅ order.created ๐Ÿ“ค inventory.reserved ๐Ÿ“ค inventory.failed
  • 1Check phase: Query DynamoDB for every item. If any product is not found or has insufficient stock โ†’ immediately publish inventory.failed and stop. No partial reservations.
  • 2Reserve phase: Atomically decrement stock for each item using DynamoDB ConditionExpression: stock >= :qty. This prevents two simultaneous orders from buying the last unit.
  • 3If a race condition is detected (ConditionalCheckFailedException) โ†’ publish inventory.failed with an appropriate reason.
  • 4If all items reserved โ†’ update order status to INVENTORY_RESERVED โ†’ publish inventory.reserved.
  • 5Delete message from SQS (success). On any unhandled error, do NOT delete โ†’ SQS retries 3 times โ†’ DLQ.
Why two phases? We check ALL items before reserving ANY. If we reserved item A and then found item B was out of stock, item A would be locked with no order attached. The two-phase approach ensures it's all-or-nothing โ€” consistent with database transaction semantics, but implemented at the application layer.
Why ConditionExpression? Without it, two simultaneous orders for the last unit could both read stock = 1, both pass the check, and both decrement โ€” leaving stock at -1. DynamoDB's conditional write rejects one of them at the database level, making the operation atomic.
๐Ÿ’ณ
Payment Service
SQS Consumer ยท Long-polling
SQS Consumer SNS Producer Simulated Gateway
Listens to the payment SQS queue for inventory.reserved events. Only begins payment processing after inventory is confirmed โ€” never charges a card for an out-of-stock item. Payment is simulated (80% success / 20% decline) with realistic decline reasons and network latency (300โ€“800ms).
๐Ÿ“ฅ inventory.reserved ๐Ÿ“ค payment.processed ๐Ÿ“ค payment.failed
  • 1Extract orderId, customerId, items, totalAmount from the event payload.
  • 2Simulate payment gateway delay (300โ€“800ms) โ€” mimics real API call latency.
  • 380% success: Generate a transaction ID โ†’ update order with transactionId and chargedAmount โ†’ set status PAYMENT_PROCESSED โ†’ publish payment.processed.
  • 420% decline: Pick a realistic decline reason (insufficient funds, card expired, fraud block, etc.) โ†’ set status PAYMENT_FAILED โ†’ publish payment.failed.
  • Insufficient funds
  • Card expired
  • Transaction declined by issuing bank
  • Suspected fraud โ€” transaction blocked
Why payment happens AFTER inventory? We never charge a customer for something we cannot fulfil. The event sequence enforces this business rule architecturally โ€” Payment Service only ever sees inventory.reserved events, so an out-of-stock order never reaches it.
๐Ÿ””
Notification Service
SQS Consumer ยท Long-polling
SQS Consumer 3 Event Types Terminal Sink
The customer-facing boundary of the system. Listens for all possible terminal events and sends the appropriate message to the customer. It handles three event types and has a dedicated handler for each โ€” the message content and tone changes based on what happened. On success, it also marks the order as fully COMPLETED.
๐Ÿ“ฅ payment.processed ๐Ÿ“ฅ payment.failed ๐Ÿ“ฅ inventory.failed
EventMessage SentStatus Update
payment.processedOrder confirmation with transaction ID, items, delivery estimateโ†’ COMPLETED
payment.failedPayment declined notice with reason, prompt to retryNone (already PAYMENT_FAILED)
inventory.failedOut-of-stock notice, no payment taken messageNone (already INVENTORY_FAILED)
Why Notification handles 3 events but Payment only handles 1? Notification is the customer-facing boundary โ€” it must respond to every possible terminal state regardless of which service caused the failure. Payment only cares about one upstream trigger (inventory.reserved) so it has one handler. Services only handle what they own.
๐Ÿ“‹
Audit Service
SQS Consumer ยท Long-polling
SQS Consumer Append-only Log Event Sourcing
A silent observer that records every single event that flows through the system. Zero business logic โ€” it simply writes an immutable entry to DynamoDB for every message it receives. No other service knows it exists, which is the correct pattern: producers publish events without knowing who is listening. Serves compliance, debugging, and replay requirements.
๐Ÿ“ฅ ALL events (order.created, inventory.*, payment.*)
FieldValue
orderIdHash key โ€” groups all events for one order
eventIdTimestamp-prefixed unique ID โ€” sort key for chronological ordering
eventTypee.g. order.created, payment.processed
payloadComplete snapshot of the event data at that point in time
recordedAtWhen the event was published (from SNS)
storedAtWhen the audit service wrote the record
orderId: ORD-A1B2C3D4
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[2026-05-07T10:00:00Z]  order.created
[2026-05-07T10:00:01Z]  inventory.reserved
[2026-05-07T10:00:02Z]  payment.processed
Why sequential processing here? Unlike other services that use Promise.all() for speed, the Audit Service processes messages one by one to preserve write order in DynamoDB. For a compliance log, chronological accuracy matters more than throughput.
EDA Patterns Demonstrated
๐Ÿ“ก Pub / Sub Fan-out
Order Service publishes one event to SNS. SNS delivers a copy to all 4 subscriber queues simultaneously. Adding a 5th service (e.g. fraud detection) requires zero changes to any existing service โ€” just subscribe the new queue to SNS.
๐Ÿ“ฌ Async Decoupling via SQS
Producers and consumers never communicate directly. If Inventory Service goes down, messages wait in the queue and are processed when it recovers. The Order API returns instantly regardless of downstream health.
๐Ÿ” Dead Letter Queue (DLQ)
Every SQS queue has a paired DLQ. If a message fails processing 3 times (e.g. a corrupt payload or a service bug), it moves to the DLQ automatically. No message is silently lost. Operations can inspect and replay DLQ messages.
๐Ÿ“– Event Sourcing
The Audit Service builds an immutable, append-only log of every event. You can reconstruct the complete state of any order by replaying its event history. This is the foundation of CQRS and event-sourced systems.
โš›๏ธ Atomic Conditional Writes
Stock reservation uses DynamoDB's ConditionExpression to prevent overselling under concurrent load. Two simultaneous orders for the last unit cannot both succeed โ€” one gets rejected at the database level.
๐Ÿ”— Correlation ID Threading
The orderId flows through every event in the chain. Any log line from any service can be correlated back to the originating order. This is the foundation of distributed tracing โ€” same principle as X-Ray, Jaeger, and Datadog APM.
๐Ÿ“Š At-Least-Once Delivery
SQS guarantees every message will be delivered at least once. Services must be designed to handle duplicate messages gracefully (idempotency). The visibility timeout hides a message while it's being processed.
๐Ÿงฑ Single Responsibility
Each service owns exactly one concern. Order Service knows about orders. Payment Service knows about payments. No service knows how other services work internally โ€” they only communicate through events.
Data Model
๐Ÿ—„๏ธ DynamoDB Tables
TablePartition KeySort KeyUsed By
shopflow-ordersorderIdโ€”Order, Inventory, Payment, Notification Services
shopflow-inventoryproductIdโ€”Order Service (price lookup), Inventory Service (stock)
shopflow-eventsorderIdeventIdAudit Service

Sample product data seeded on startup:

productIdNameStockPrice
PROD-001Wireless Headphones15$49.99
PROD-002Mechanical Keyboard0$89.99 (intentionally out of stock)
PROD-003USB-C Hub30$29.99
Technology Stack
๐ŸŸฉ
Node.js + Express
Runtime and HTTP framework for the Order Service REST API
๐Ÿ“ก
AWS SNS
Event bus โ€” fan-out pub/sub to all subscriber queues simultaneously
๐Ÿ“ฌ
AWS SQS
Per-service message queues with DLQs and 3-retry redrive policy
โšก
AWS DynamoDB
NoSQL store for orders, inventory, and append-only event log
๐Ÿณ
Docker + LocalStack
Full local AWS simulation โ€” develop and test without cloud costs
โ˜๏ธ
AWS CDK (planned)
Infrastructure as code โ€” deploy entire stack to AWS with one command
How to Run Locally

Prerequisites: Node.js 18+, Docker Desktop running

1
Clone and install
git clone https://github.com/phpfriend/event-driven-architecture
cd event-driven-architecture
npm install
2
Start local AWS infrastructure (Terminal 1)
Starts LocalStack, creates SNS topic, 4 SQS queues + DLQs, 3 DynamoDB tables, seeds inventory data.
npm run infra:up
3
Start all 5 services (Terminal 2)
Launches all services with color-coded output per service โ€” cyan (Order), green (Inventory), yellow (Payment), magenta (Notification), blue (Audit).
npm run dev:all
4
Run end-to-end tests (Terminal 3)
Places 3 orders (happy path, out-of-stock, multi-item) and polls status until each reaches a terminal state.
npm run test:order
5
Inspect audit trail for any order
node scripts/check-order.js ORD-XXXXXXXX
Why Event-Driven Architecture?
ProblemEDA Solution
Payment provider is slowPayment Service runs independently โ€” Order API returns in milliseconds regardless
Notification service crashesMessage stays in SQS queue, delivered when service recovers โ€” no data loss
Need to add Fraud Detection serviceSubscribe it to SNS โ€” zero changes to any existing service
Two customers race for last itemDynamoDB conditional write rejects one atomically โ€” no overselling possible
Compliance audit requirementAudit Service passively records everything โ€” producers don't know it exists
Scale inventory checks independentlyDeploy more consumers for the inventory queue โ€” other services unaffected
Debug a failed order in productionQuery the event log for the orderId โ€” full history, timestamped, immutable