Event-driven CQRS is not “many databases arguing.” It is one authoritative write model projecting into many purpose-built read models. NATS is the plumbing — subjects, KV buckets, and fan-in index signals — not the system of record.
This article walks through a generic order-processing example in the Event-Driven Architecture with NATS series: a relational database as truth, a NATS KV mirror, an indexer fan-in to OpenSearch, and an analytics warehouse for reporting. The patterns apply to catalogs, users, inventory, or any entity where writes and reads diverge.
Researched against Martin Fowler’s CQRS writing, PostgreSQL logical replication, NATS documentation, and OpenSearch index APIs on July 6, 2026.
TL;DR
- CQRS separates write models from read models when query shape, scale, or access patterns diverge from writes.
- The primary database (here, PostgreSQL) is truth. NATS KV, OpenSearch, and the analytics warehouse are projections — optimized copies for different questions.
- CDC (change data capture from the write database → KV mirror) feeds a low-latency keyed read path without making KV authoritative.
- Indexer fan-in (
index.<type>→ one indexer → OpenSearch) centralizes search projection logic. - Eventual consistency is an explicit architecture choice — search may lag seconds; detail views should read from truth or the KV mirror by policy.
- This is CDC-driven CQRS, not full event sourcing. The change log comes from the database replication stream plus optional index subjects, not an append-only domain event store.
What You Will Learn Here
- Mental model: truth, plumbing, projections
- Order write path (API → PostgreSQL)
- CDC mirror: database → NATS KV (
app-objects) - Indexer fan-in:
index.<type>→ OpenSearch - Analytics warehouse as a reporting projection
- Consistency and lag expectations per read path
- Idempotent indexer implementation
- How this ties to Parts 1–3
For the architectural ladder that motivates CQRS and outbox patterns, see The Pieces of Modern, Effective Software Design. That article uses a transactional outbox; this article uses CDC from an existing write database — both are valid triggers for projections.
Mental Model: Truth, Plumbing, Projections
Lock in two rules:
- A KV bucket is a stream + latest-value view. Entity types are key prefixes, not buckets. (See Part 3.)
- NATS is plumbing. The write database decides what happened. OpenSearch and the warehouse answer different questions.
TRUTH PLUMBING PROJECTIONS
───── ──────── ───────────
PostgreSQL ──► NATS subjects ──► OpenSearch (search)
(orders table) NATS KV mirror ──► In-memory caches
│ index.* ──► Warehouse (analytics)
│
└── source of record — everything else is a copy
Writes go through the API to PostgreSQL. Every other store is a projection optimized for a specific query shape.
Glossary
| Name | Role |
|---|---|
| Order API | Write service — validates commands, owns business rules |
| PostgreSQL | Authoritative storage for order records |
| app-objects | NATS KV bucket — mirrored entity blobs keyed by entity prefix |
| orders-v1 | Key prefix for order entities inside app-objects |
| index.<type> | Index signal subjects published when an entity should be re-indexed |
| Indexer | Single consumer that fans in all index.> signals → OpenSearch |
| OpenSearch | Search read model — customer name, status, date-range queries |
| Warehouse | Analytics read model — revenue aggregates, reporting (minutes-hours lag OK) |
Order Write Path
[Client / API]
|
| POST /orders, PATCH /orders/{id}
v
+------------------+
| Order API | <<<< WRITE MODEL (system of record)
| (write service) |
+--------+---------+
|
| INSERT / UPDATE / DELETE
v
+------------------+
| PostgreSQL | authoritative order state
| orders table |
+------------------+
The write path owns validation, authorization, and invariants. Post-mutation confirmation reads (create, update, cancel) should hit the Order API or PostgreSQL — not OpenSearch.
CDC Mirror: Database → NATS KV
Change data capture (CDC) reads the database’s change log and projects it elsewhere. With PostgreSQL, logical replication or tools like Debezium consume row-level inserts, updates, and deletes from the write-ahead log.
A CDC mirror worker consumes those changes and upserts into NATS KV:
PostgreSQL orders table
|
| CDC stream (INSERT, UPDATE, DELETE)
v
+------------------+
| CDC Mirror |
| Worker |
+--------+---------+
|
| kv.Put / kv.Delete
v
+-------------------+
| NATS JetStream KV |
| bucket: app-objects|
| |
| key: |
| orders-v1.{orderId}|
+-------------------+
import { connect, StringCodec } from "nats";
const nc = await connect({ servers: process.env.NATS_URL });
const js = nc.jetstream();
const kv = await js.views.kv("app-objects");
const sc = StringCodec();
type ChangeEvent = {
op: "create" | "update" | "delete";
table: string;
key: { id: string };
after?: Record<string, unknown>;
};
async function handleChange(event: ChangeEvent) {
if (event.table !== "orders") return;
const orderId = event.key.id;
const key = `orders-v1.${orderId}`;
if (event.op === "delete") {
await kv.delete(key);
return;
}
await kv.put(key, sc.encode(JSON.stringify(event.after)));
}
Why mirror to KV?
- Editorial inference: Services that need order blobs by ID can read from KV without querying PostgreSQL on every request — reducing hot-path read load.
- KV is not truth. If KV and PostgreSQL disagree, the database wins. Build drift detection and rebuild paths.
Tie-back to Part 3: The key orders-v1.{id} lives in bucket app-objects (stream KV_app-objects). Downstream services can watch("orders-v1.>") for reactive updates.
CDC vs outbox
| Trigger | When it fits |
|---|---|
| Transactional outbox (Pieces article) | You control the write database and can add an outbox table in the same transaction |
| CDC from the write database (this article) | Truth already lives in a relational store; you project from the existing change log |
Both end at the same place: a reliable change signal that projectors consume.
CDC caveat: Replication slots and consumer lag need monitoring. If the CDC worker falls behind retention limits, you need a rebuild path from a database snapshot or export — the exact retention depends on your CDC tooling and database configuration.
Indexer Fan-In: index.<type> → OpenSearch
Search needs denormalized documents, analyzers, and aggregations — a different shape than normalized table rows. Rather than N separate projectors, many services publish index signals:
Order API / CDC ──► index.order
Customer service ──► index.customer
Catalog service ──► index.product
│
│ all subjects match index.>
v
+------------------+
| Indexer |
| (single worker |
| or queue grp) |
+--------+---------+
|
| idempotent upsert
v
+------------------+
| OpenSearch |
| orders index |
+------------------+
Why fan-in?
- One place to own OpenSearch mapping, bulk settings, and error handling
- Each domain service publishes a small signal — not full search documents
index.>wildcard subscription (Part 1 subjects) catches all entity types
Tie-back to Part 2: The indexer should use a durable JetStream consumer with explicit ack, finite MaxDeliver, and idempotent handlers — not Core NATS pub/sub.
Analytics Warehouse Projection
OpenSearch answers “find orders for customer X placed this week.” A warehouse answers “what was total revenue by region last quarter?”
The warehouse is an analytics read model — fed by the same change lineage (CDC export, stream load, or periodic ETL). It is not on the online read path. Minutes-to-hours lag is usually acceptable.
Snowflake, BigQuery, or any columnar warehouse fits this role. The ingestion tooling is stack-specific; the pattern is not.
Consistency and Lag Expectations
| Operation | Authoritative store | Projected stores | Expected lag | Read path |
|---|---|---|---|---|
| Place order | PostgreSQL via Order API | KV, OpenSearch, warehouse | KV: sub-second; Search: 2–10s; Analytics: minutes-hours | Detail views hit API or PostgreSQL; search may lag |
| Update order status | PostgreSQL | KV, OpenSearch | Search: seconds | Search results may show old status briefly |
| Cancel order | PostgreSQL | KV, OpenSearch, warehouse | Similar | Search may still list the order until the indexer processes the delete |
| Search orders | — | OpenSearch | — | Reads projection only; not truth |
| Dashboard / report | — | Warehouse | Hours | Stale aggregates are expected |
Treat these lag budgets as SLO inputs: wire detail and mutation confirmations to the write path; treat search and analytics as eventually consistent projections.
Concrete Implementation: Idempotent Indexer
import { connect, StringCodec } from "nats";
const nc = await connect({ servers: process.env.NATS_URL });
const js = nc.jetstream();
const sc = StringCodec();
type IndexSignal = {
id: string; // dedupe key
entityType: string; // "order"
entityId: string;
op: "upsert" | "delete";
version: number;
};
const sub = await js.pullSubscribe("index.>", {
config: { durable_name: "search-indexer", ack_policy: "explicit" },
stream: "INDEX_SIGNALS",
});
for (;;) {
const msgs = await sub.fetch(20, { expires: 5000 });
for (const msg of msgs) {
const signal: IndexSignal = JSON.parse(sc.decode(msg.data));
if (await alreadyProcessed(signal.id)) {
msg.ack();
continue;
}
try {
if (signal.op === "delete") {
await openSearch.delete({
index: "orders",
id: signal.entityId,
});
} else {
const doc = await buildSearchDocument(signal.entityType, signal.entityId);
await openSearch.index({
index: "orders",
id: signal.entityId,
body: doc,
});
}
await markProcessed(signal.id);
msg.ack();
} catch (err) {
msg.nak(); // JetStream redelivers per AckWait / MaxDeliver
}
}
}
buildSearchDocument fetches the current state from KV or the Order API — the indexer assembles the search document from truth or mirror, not from the signal payload alone. That keeps signals small and documents fresh.
Failure Modes and Replay
| Failure | Mitigation |
|---|---|
| Indexer bug wrote bad documents | Fix bug, replay from JetStream consumer or rebuild from database export |
| CDC consumer lag | Monitor replication slot lag; scale workers; plan rebuild from snapshot if retention exceeded |
| KV drift from database | Periodic reconciliation job; KV is a cache, not truth |
| Poison message | MaxDeliver stops redelivery; alert and manual intervention (Part 2) |
| OpenSearch mapping change | New index + reindex playbook |
Operational metrics
projection_lag_seconds— time from database write to OpenSearch visibilitykv_mirror_age_seconds— CDC worker lagcdc_slot_lag_bytes— replication consumer health (PostgreSQL)indexer_dlq_depth— messages that hitMaxDeliveropensearch_bulk_failures— indexer health
How This Ties to Layers 1–3
| Layer | What it taught | What Part 4 adds |
|---|---|---|
| Part 1 — Core NATS | Subjects, wildcards, pub/sub | index.> fan-in subscription; $KV.app-objects.orders-v1.> watch pattern |
| Part 2 — JetStream | Streams, durable consumers, ack | Indexer durability, MaxDeliver, replay after bugs |
| Part 3 — JetStream KV | Bucket = stream, key prefixes, watch | app-objects mirror, entity type as prefix, KV as projection not truth |
| Part 4 — Projections | — | Why each store exists; lag budgets per read path |
ONE TRUTH → MANY READ MODELS
=================================
Order API ──► PostgreSQL (truth)
│
├── CDC ──► KV app-objects / orders-v1.*
│ │
│ └── watch ──► cache warmers
│
├── index.order ──┐
│ index.* ├──► Indexer ──► OpenSearch
│ │
└── ETL ─────────────────────────────► Warehouse
When NOT to Add Another Projection
Fowler’s CQRS guidance applies: use read models when the read side has genuinely outgrown the write side. Do not add KV + OpenSearch + a warehouse on day one because the diagram looks sophisticated.
| Symptom | Reasonable response |
|---|---|
| ”Search is slow” | OpenSearch projection |
| ”Other services need order blobs by ID” | KV mirror (this article) |
| “Finance needs quarterly aggregates” | Warehouse projection |
| ”We have 500 orders” | PostgreSQL queries are probably fine |
Next in series: Observability and SLOs (Part 5) · NATS vs Kafka vs SQS (Part 6)
Sources
- CQRS — Martin Fowler — when to separate read and write models
- What do you mean by “Event-Driven”? — Martin Fowler — event notification vs state transfer vs event sourcing
- PostgreSQL Logical Replication — CDC foundation for relational write models
- NATS Subjects —
index.>wildcard fan-in - NATS Key/Value Store — KV mirror consistency model
- NATS JetStream — durable indexer consumers
- OpenSearch Index Document API — idempotent upsert by document ID
- The Pieces of Modern, Effective Software Design — CQRS ladder and outbox pattern
- Core NATS (Part 1)
- JetStream (Part 2)
- JetStream KV (Part 3)