โก
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.
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.
โถ
๐
Order Service
Express :3000
โถ
๐ก
SNS Topic
[shopflow-orders]
๐ฌ
SQS Queue
[notification]
โผ
โผ
โผ
โผ
๐ฆ
Inventory Svc
Reserves stock
๐ณ
Payment Svc
Charges card
๐
Notification Svc
Sends email
โผ
โผ
๐ก
SNS Topic
[inv.reserved]
๐ก
SNS Topic
[pay.processed]
SystemReady. Click "Simulate Order Placed" to visualize the event flow.
PENDING
โ
INVENTORY_RESERVED
โ
PAYMENT_PROCESSED
โ
COMPLETED
PENDING
โ
INVENTORY_FAILED
(no payment attempted)
PENDING
โ
INVENTORY_RESERVED
โ
PAYMENT_FAILED
(stock released)
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.
API Endpoints
| Method | Path | Description |
POST | /orders | Place a new order. Validates input, looks up prices, saves to DynamoDB, publishes event. |
GET | /orders/:orderId | Fetch current order status and full order record. |
GET | /health | Health check endpoint for load balancer probes. |
Request โ Response Flow
- 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
Events Published
๐ค 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.
Sample Request
{
"customerId": "CUST-001",
"items": [
{ "productId": "PROD-001", "quantity": 2 }
]
}
Sample Response
{
"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
}
]
}
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.
Events Consumed / Published
๐ฅ order.created
๐ค inventory.reserved
๐ค inventory.failed
Processing Logic (Two-Phase Check โ Reserve)
- 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.
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).
Events Consumed / Published
๐ฅ inventory.reserved
๐ค payment.processed
๐ค payment.failed
Processing Logic
- 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.
Simulated Decline Reasons
- 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.
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.
Events Consumed
๐ฅ payment.processed
๐ฅ payment.failed
๐ฅ inventory.failed
Handler per Event Type
| Event | Message Sent | Status Update |
payment.processed | Order confirmation with transaction ID, items, delivery estimate | โ COMPLETED |
payment.failed | Payment declined notice with reason, prompt to retry | None (already PAYMENT_FAILED) |
inventory.failed | Out-of-stock notice, no payment taken message | None (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.
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.
Events Consumed
๐ฅ ALL events (order.created, inventory.*, payment.*)
What Gets Stored (per event)
| Field | Value |
| orderId | Hash key โ groups all events for one order |
| eventId | Timestamp-prefixed unique ID โ sort key for chronological ordering |
| eventType | e.g. order.created, payment.processed |
| payload | Complete snapshot of the event data at that point in time |
| recordedAt | When the event was published (from SNS) |
| storedAt | When the audit service wrote the record |
Sample Audit Trail for One Order
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.
๐ก 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.
๐๏ธ DynamoDB Tables
| Table | Partition Key | Sort Key | Used By |
| shopflow-orders | orderId | โ | Order, Inventory, Payment, Notification Services |
| shopflow-inventory | productId | โ | Order Service (price lookup), Inventory Service (stock) |
| shopflow-events | orderId | eventId | Audit Service |
Sample product data seeded on startup:
| productId | Name | Stock | Price |
| PROD-001 | Wireless Headphones | 15 | $49.99 |
| PROD-002 | Mechanical Keyboard | 0 | $89.99 (intentionally out of stock) |
| PROD-003 | USB-C Hub | 30 | $29.99 |
๐ฉ
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
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
| Problem | EDA Solution |
| Payment provider is slow | Payment Service runs independently โ Order API returns in milliseconds regardless |
| Notification service crashes | Message stays in SQS queue, delivered when service recovers โ no data loss |
| Need to add Fraud Detection service | Subscribe it to SNS โ zero changes to any existing service |
| Two customers race for last item | DynamoDB conditional write rejects one atomically โ no overselling possible |
| Compliance audit requirement | Audit Service passively records everything โ producers don't know it exists |
| Scale inventory checks independently | Deploy more consumers for the inventory queue โ other services unaffected |
| Debug a failed order in production | Query the event log for the orderId โ full history, timestamped, immutable |