Event-Driven Architecture with NATS · Part 4

EDA with NATS: One Truth, Many Read Models — CQRS Projections

How a generic order domain flows from a relational write database through NATS KV and index subjects into OpenSearch and an analytics warehouse — without mistaking plumbing for source of record.

11 min read Updated Jul 6, 2026

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:

  1. A KV bucket is a stream + latest-value view. Entity types are key prefixes, not buckets. (See Part 3.)
  2. 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

NameRole
Order APIWrite service — validates commands, owns business rules
PostgreSQLAuthoritative storage for order records
app-objectsNATS KV bucket — mirrored entity blobs keyed by entity prefix
orders-v1Key prefix for order entities inside app-objects
index.<type>Index signal subjects published when an entity should be re-indexed
IndexerSingle consumer that fans in all index.> signals → OpenSearch
OpenSearchSearch read model — customer name, status, date-range queries
WarehouseAnalytics 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

TriggerWhen 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

OperationAuthoritative storeProjected storesExpected lagRead path
Place orderPostgreSQL via Order APIKV, OpenSearch, warehouseKV: sub-second; Search: 2–10s; Analytics: minutes-hoursDetail views hit API or PostgreSQL; search may lag
Update order statusPostgreSQLKV, OpenSearchSearch: secondsSearch results may show old status briefly
Cancel orderPostgreSQLKV, OpenSearch, warehouseSimilarSearch may still list the order until the indexer processes the delete
Search ordersOpenSearchReads projection only; not truth
Dashboard / reportWarehouseHoursStale 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

FailureMitigation
Indexer bug wrote bad documentsFix bug, replay from JetStream consumer or rebuild from database export
CDC consumer lagMonitor replication slot lag; scale workers; plan rebuild from snapshot if retention exceeded
KV drift from databasePeriodic reconciliation job; KV is a cache, not truth
Poison messageMaxDeliver stops redelivery; alert and manual intervention (Part 2)
OpenSearch mapping changeNew index + reindex playbook

Operational metrics

  • projection_lag_seconds — time from database write to OpenSearch visibility
  • kv_mirror_age_seconds — CDC worker lag
  • cdc_slot_lag_bytes — replication consumer health (PostgreSQL)
  • indexer_dlq_depth — messages that hit MaxDeliver
  • opensearch_bulk_failures — indexer health

How This Ties to Layers 1–3

LayerWhat it taughtWhat Part 4 adds
Part 1 — Core NATSSubjects, wildcards, pub/subindex.> fan-in subscription; $KV.app-objects.orders-v1.> watch pattern
Part 2 — JetStreamStreams, durable consumers, ackIndexer durability, MaxDeliver, replay after bugs
Part 3 — JetStream KVBucket = stream, key prefixes, watchapp-objects mirror, entity type as prefix, KV as projection not truth
Part 4 — ProjectionsWhy 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.

SymptomReasonable 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