One Product, Four Databases: Assigning Stores to Bounded Contexts
A marketplace has orders in Postgres, sessions in Redis, product search in Elasticsearch, analytics in ClickHouse, and a document store holding seller profiles. Five teams chose five engines over five years, and on paper this looks like drift. It is actually polyglot persistence working correctly: each bounded context gets a store matched to its own access pattern, and no store is asked to serve a workload it was not built for. The alternative — one database for everything — is polyglot persistence's opposite number, and its cost shows up as query contortions and scalability ceilings rather than as operational variety.
The assignment exercise, done properly for a marketplace:
| Bounded context | Dominant access pattern | Store | Why not the obvious alternative |
|---|---|---|---|
| Orders / payments ledger | multi-row ACID, constraints, reconciliation | relational (Postgres) | documents cannot enforce the transfer invariant |
| Product catalog page | one aggregate: product + variants + media | document or relational + jsonb | avoid per-field joins for a single-page read |
| Search | inverted index, full-text, faceting | Elasticsearch/OpenSearch | SQL ILIKE over 20M rows is not a search engine |
| Session / cart cache | key→value, TTL, extreme read rate | Redis | paying Postgres for reads that need no durability |
| Analytics | columnar scans, aggregations over months | ClickHouse/warehouse | OLTP row-store scans wreck serving latency |
Two boundary rules keep this from becoming chaos. First, one writer per fact: only the orders service writes orders, and every other store holds a copy derived from it — Elasticsearch, Redis, and the analytics tables are projections of the system of record, never peer authorities. When two systems can both write the same fact, you no longer have polyglot persistence; you have two sources of truth and an eventual argument. Second, the boundaries follow the domain model (orders, catalog, search, sessions), not the technology — if two contexts would share one engine and one schema, that is one context split by accident.
A trap to avoid: adding a store per query instead of per context. Redis for a cache tier, Elasticsearch for search, ClickHouse for analytics are all defensible — but a 'graph microservice' whose only query is a single friendship lookup is a sixth technology to operate for one feature. The test: does this context have a workload family the current engine serves poorly (not one slow query), and will it still exist in two years? If the answer to the second question is no, use a materialized view, a precomputed table, or a cached adjacency list instead.
Interview framing that reads as experience: polyglot persistence is a distributed-systems decision wearing a database hat. Splitting storage per context is what lets each team scale, deploy, and evolve independently — and it is exactly what makes cross-context queries, transactions, and consistency your problem to engineer. Those glue questions — how the search index, the cache, and the ledger stay honest with each other — are the next chapter, and the cost of each additional engine is the one after.
Keeping Copies Honest: The Three Ways a Search Index, a Cache, and a Ledger Stay in Sync
The seller renames a product; search still shows the old name for ninety seconds; a cached page serves stale data for an hour; a report counts the order twice because the analytics pipeline delivered it twice. These are not three bugs — they are one design question with three answers: when the system of record changes, how does each derived store find out, and what does a reader see in the gap?
The three mechanisms, with their trade-offs:
1. SYNCHRONOUS dual write (write DB, then write the copy, in the request path)
+ copy always fresh after ack − both must succeed or they diverge
− latency = both services − no atomicity across two systems
(the classic dual-write problem: commit succeeded, index write failed
⇒ permanent divergence with no signal)
2. ASYNC outbox + relay (Debezium/CDC or poller on an outbox table)
+ one atomic commit makes the event durable with the row
+ decoupled latency, replayable, multiple copies fed from one stream
− copies lag by the relay's lag (measure it) − needs offset/DLQ handling
3. RECONCILIATION / rebuild (nightly compare, or full reindex from the log)
+ heals whatever the first two missed − bounded staleness window = your SLA
− full reindex of 20M docs is an operational event, not a click
Notice the shape of a correct system: (2) for delivery, (3) as the safety net, and (1) only where the copy must be atomically fresh — which usually means the copy is not a copy but the same transaction (a materialized view refreshed in-process). This is CQRS's logic appearing naturally: the write model updates the system of record; read models (search documents, cached aggregates, analytics rows) are rebuilt from its changes.
Per-store failure modes worth naming:
- Search index: near-real-time by nature (refresh interval ≈ 1 s), so brief staleness is normal — but a failed indexing consumer is not; monitor the consumer's lag against the DB's change stream, not just the index's document count.
- Cache: choose explicit semantics — cache-aside with TTL plus invalidation on write (stale window = TTL if invalidation is lost), or write-through (fresh but couples write latency to Redis). A cache without a version/invalidation story is a correctness bug with a hit-rate dashboard.
- Analytics store: exactly-once is unattainable in general, so make loads idempotent (upsert keyed on event id, or full-partition replacement — the lambda/batch-rebuild pattern) and dedupe downstream in queries where needed.
- Cross-store reads: if one page must join orders (Postgres) with profiles (document) and search results (Elasticsearch), accept the fan-out read or materialize a serving copy — do not attempt distributed transactions across the three.
The debugging story matters as much as the design: give every write a correlationId that rides through the outbox into every projection, so 'why is search stale?' is one query against the relay's offsets rather than three hours of log archaeology. In interviews, state the rule of thumb that signals seniority: derived stores may lag; they may not silently diverge — lag is measured and alerted, divergence is prevented by one atomic origin (outbox) and healed by reconciliation.
The Ops Tax: Pricing Each Additional Database Technology
Adding Elasticsearch to the stack takes one afternoon: helm install, index a sample, ship the feature. Removing it two years later takes a quarter. Polyglot persistence is cheap to enter and expensive to leave, so the decision must be priced the way the exit price, not the entry price. Here is the honest ledger for each new engine:
| Cost category | What you actually take on |
|---|---|
| Upgrades | version matrix per engine, driver compatibility, planned downtime windows for each |
| Backups & recovery | separate backup tooling, separate restore testing — and RTO/RPO per store |
| On-call | distinct failure signatures (heap pressure in JVMs vs connection storms vs WAL floods) |
| Security | one more CVE surface, one more secrets/ACL boundary, one more audit item |
| Observability | one more metrics/dashboards/alert set, one more capacity model |
| Skills | hiring, bus factor, runbooks that only two people have ever executed |
| Data movement | every new sync edge between stores is another lag/failure path to monitor |
The multiplier is not one cost — it is the number of pairwise integrations: stores synchronizing with each other grow roughly as n(n-1)/2, which is why a five-store landscape with mutual sync edges is a support burden that dwarfs any single engine's. Cap the count deliberately: most teams should be able to justify every store on one page — engine, context, access pattern, owner, integration edges, exit plan — and if two stores serve the same context, one of them is wrong.
Concrete decision heuristics:
- Exhaust the features of the current store first. Postgres alone covers a staggering range:
jsonbfor semi-structured data,pg_trgm/full-text for search-lite,LISTEN/NOTIFYplus materialized views for read models, table partitioning for time-series-ish loads,pg_cronfor jobs. Reach the boundary of those before adding a technology. - Buy the managed version or do not buy it. Self-hosted Kafka + Elasticsearch + Cassandra is a second team's worth of work; managed equivalents (MSK, Elastic Cloud, DynamoDB, Memorystore) convert ops tax into a bill — usually the right trade for a product team.
- Require an owner and an exit plan at adoption: who is on-call for it, and how do we get the data out if we drop it? 'We'll figure it out later' is how abandoned clusters persist for years, still holding production data nobody trusts.
- Re-review annually. Stores outlive their reasons; a cache added for a launch in 2023 may be a Redis Cluster you now operate at six figures for 40% hit rate.
Interview framing: the question 'would you add Redis here?' deserves a ledger answer — what it buys (latency, decoupling), what it costs (a technology, an invalidation contract, an edge in the sync graph), and what you tried first (a covering index, a materialized view, PgBouncer). Showing that you price exits and integration edges, not just features, is what separates an architect from an assembler of tools. The next topic splits an even bigger boundary: serving queries versus running analytics — Transactional vs Analytical Workloads.
Quiz
1. Which rule most directly prevents polyglot persistence from becoming 'two sources of truth'?
2. Writing the database row and the Elasticsearch document in the request handler succeeds for the row and fails for the document. What happened, and what is the standard fix?
3. A team adds a graph database because one query needs a two-hop friend lookup. What is the strongest counterargument?
4. Roughly how does the integration burden grow as stores begin synchronizing with each other, and what does that imply?
Flashcards
Question
What is polyglot persistence, and what makes it different from accidental database sprawl?
Click to reveal answer
Answer
Each bounded context uses a store matched to its access pattern (relational ledger, document catalog, search index, KV cache, columnar analytics), with one writer per fact and other stores as derived projections. Sprawl is adding engines per query without owners, edges, or exit plans — the difference is deliberate assignment plus a single authority per datum.
Question
The three sync mechanisms between a system of record and its derived stores, and when each is used
Click to reveal answer
Answer
Synchronous dual write: fresh but non-atomic across systems — the divergence hazard, so use it only where the copy shares the transaction. Outbox + CDC relay: one atomic origin, replayable, multi-copy — the default. Reconciliation/full rebuild: heals everything missed — the safety net; staleness bound equals your SLA.
Question
State the rule for derived stores in one sentence, plus the alarm that enforces it.
Click to reveal answer
Answer
Derived stores may lag, but must never silently diverge: lag is bounded, measured, and alerted (relay/consumer offset vs source change position), while divergence is prevented by a single atomic origin (outbox written with the row) and repaired by scheduled reconciliation or reindex.
Question
Name four items on the operational ledger for each new database technology.
Click to reveal answer
Answer
Upgrades and driver compatibility; separate backup tooling with restore testing and its own RTO/RPO; on-call with distinct failure signatures; security/CVE and secrets surface; plus observability, capacity modeling, team skills/bus factor, and the new sync edges to monitor — priced at the exit cost, not the entry cost.
Question
What should you try before adding a second store?
Click to reveal answer
Answer
Exhaust the current engine's features: Postgres alone offers jsonb for semi-structured data, trigram/full-text search, LISTEN/NOTIFY with materialized views for read models, partitioning for time-series shapes, and pg_cron for jobs. Then require a named owner, an integration-edge inventory, an exit plan, and prefer managed offerings to convert ops work into a bill.
Revision Notes
Key Takeaways
- 1. Polyglot persistence is correct when stores follow bounded contexts and access patterns, and chaos when stores follow individual queries
- 2. One writer per fact is the line between derived projections and competing sources of truth
- 3. Derived copies need an atomic origin (outbox plus CDC) and a reconciliation net; dual writes in the request path fail silently
- 4. Integration edges grow quadratically with the number of stores, so the store count itself is a design parameter with an owner and an exit plan
- 5. Managed offerings convert the ops tax into a bill — usually the right trade for product teams
Interview Tips
- • "Which databases would you use for this product?" — one row per bounded context: pattern, engine, and what the alternative fails at; end with one-writer-per-fact
- • "How do you keep search in sync?" — outbox written in the DB transaction, CDC relay, lag alarm keyed on consumer offsets, and periodic full rebuild as the net; name the dual-write failure explicitly
- • "Would you add Redis here?" — ledger answer: what it buys, what it costs (tech + invalidation contract + sync edge), and what you tried first (covering index, materialized view, pooling)
- • "How do you decide when to split stores?" — workload family, not one slow query; owner, integration edges, exit plan, and annual review of engines that outlived their reason
Cheat Sheet
Polyglot Persistence
Assignment rule: one store per bounded context, chosen by access pattern — and one writer per fact; every other copy is a projection of the system of record.
| Context | Pattern | Store |
|---|---|---|
| ledger/orders | multi-row ACID, constraints | relational |
| catalog page | one aggregate | document / jsonb |
| search | inverted index + facets | Elasticsearch |
| cache/session | key→value + TTL | Redis |
| analytics | columnar scans | ClickHouse/warehouse |
Sync mechanisms:
dual write in-request fresh, non-atomic ⇒ DIVERGENCE (commit ok, index fail)
outbox + CDC relay atomic origin, replayable, feeds all copies → DEFAULT
reconciliation/rebuild heals what delivery missed → safety net; staleness = SLA
Rule: derived stores may lag (measured + alerted); they may never silently diverge.
Track relay lag by consumer offsets, correlate with correlationId, key analytics loads
idempotently (upsert by event id / partition replace).
Cost ledger per new engine: upgrades & driver matrix · backups + restore testing (RTO/RPO) ·
on-call signatures · CVE/secrets surface · dashboards · skills/bus factor · sync edges.
Pairwise edges ≈ n(n-1)/2 ⇒ cap engines, keep edges few and monitored.
Adoption gates: exhaust current engine first (jsonb, trigram/FTS, matviews, LISTEN/NOTIFY,
partitioning) · managed or not at all · named owner + exit plan at adoption · annual re-review.
Interview signal: price the exit and the edges, not the features.