Event-Driven Analytics
An e-commerce analytics backend that separates writes from reads with a transactional outbox and RabbitMQ.
- Node.js
- Express
- amqplib
- RabbitMQ 3
- PostgreSQL 14 ×2
- Docker Compose
- Pattern
- CQRS · transactional outbox · materialized views
- Guarantees
- At-least-once delivery · idempotent consumers · DLQ
- Services
- command :8080 · consumer · query :8081
Overview
The system separates the write path (creating products and orders) from the read path (sales analytics) using CQRS. Each side has its own PostgreSQL database and its own service, connected by RabbitMQ.
Analytics queries hit pre-computed materialized views instead of joining across the transactional schema, keeping reads fast regardless of write volume.
Problem
Analytics workloads and transactional workloads want different shapes of data. A normalised write model is good for consistency and terrible for aggregate queries; a denormalised read model is the reverse.
Keeping the two in sync asynchronously introduces the dual-write problem: a service that writes to its database and then publishes to a broker can crash between the two and lose the event forever.
Architecture
- 01Command Service (:8080) exposes POST /api/products and POST /api/orders. Each command writes business rows and an outbox row in one transaction.
- 02An outbox publisher relays unpublished outbox rows to RabbitMQ, guaranteeing at-least-once delivery.
- 03Consumer Service subscribes to the exchange, updates the read database and records each event id in processed_events so redelivered messages are ignored. Failures route to a dead-letter queue.
- 04Query Service (:8081) serves analytics such as GET /api/analytics/products/{id}/sales from materialized views.
Implementation
Command with outbox
The business write and the outbox insert are wrapped in a single transaction; if either fails, neither is committed.
1BEGIN;2INSERT INTO orders (customer_id, total) VALUES ($1, $2) RETURNING id;3INSERT INTO order_items (order_id, product_id, quantity, price) VALUES ...;4INSERT INTO outbox (aggregate_id, event_type, payload)5 VALUES ($order_id, 'OrderCreated', $payload);6COMMIT;Idempotent consumption
Because delivery is at-least-once, the consumer must tolerate duplicates. Each processed event id is stored; a repeat is acknowledged and skipped.
Materialized read models
The read database holds pre-aggregated per-product sales figures, refreshed by the consumer as events arrive, so the query service does no heavy joins at request time.
Challenges
Losing events between DB and broker
Solved by the outbox: the event is durable the moment the transaction commits, and the relay can be retried safely.
Duplicate deliveries
At-least-once implies duplicates. The processed_events table makes the consumer idempotent.
Poison messages
Malformed or repeatedly failing messages are routed to a DLQ for inspection instead of blocking the queue.
Technical decisions
Two databases, not two schemas
Physically separating the read and write stores makes the isolation explicit and lets each be tuned independently.
Outbox over dual writes
Simpler alternatives (publish after commit) are exactly the failure mode the pattern exists to remove.
RabbitMQ
A mature broker with acknowledgements, durable queues and dead-lettering built in.
Result
A containerised CQRS system where writes stay normalised, reads stay fast, and the path between them survives crashes and redeliveries.
Lessons
- 01Consistency in event-driven systems is a design decision made at the transaction boundary.
- 02Idempotency and dead-lettering are not optional extras — they are what make at-least-once delivery usable.
