QuickCart Stage 3: Choosing the Right Data Store for Each Job
Choose a data store for each QuickCart job: relational orders, a document catalogue, search, time-series courier tracking, object storage and a warehouse.
What you'll learn: By the end of this stage you will be able to model QuickCart's core data in normalized tables, explain what each NoSQL family is good for and when none is needed, pick indexes with the right column order, choose a transaction isolation level and show that it stops an overselling bug, change a schema without downtime, and decide which data belongs in object storage, a search engine, a time-series store or a warehouse.
A kitchen has a knife, an oven and a fridge, and nobody wants one of them to do every job. Each tool is good because it gives something up: the fridge cannot cook, and the oven cannot keep milk cold.
Databases work the same way. Stage 2 gave QuickCart a cache, a CDN and read replicas, but every kind of data still lives in one PostgreSQL primary. This stage gives each kind a home that suits how it is written, read and kept.
Where QuickCart is now
QuickCart has reached one million monthly active users (an illustrative stage figure). The end-of-project targets still apply: about 2,300 browsing requests per second and about 58 checkouts per second at peak, 100,000 products per region and 5,000 stores (all illustrative assumptions, derived in Stage 1). The read path from Stage 2 holds, but the single database now does jobs it was never meant to do.
Figure 1. Stage 2 architecture: one PostgreSQL primary holds every kind of data
How to read this: everything funnels into the one highlighted box. Browsing, orders, courier pings and reports all compete for the same primary.
Five symptoms, each pointing at a different kind of data:
The catalogue has no stable shape. Milk needs a volume and allergens, rice a weight, and each new category other attributes. The product table grows dozens of mostly empty columns.
Search is a LIKE '%milk%' query. A leading wildcard cannot use a B-tree index (a trigram index could, at its own cost), so without one every search reads the whole table, ranks nothing and forgives no typos.
Courier pings flood the transactional database. They are endless small appends that nothing updates, yet they share pages and backups with payments.
Reports compete with shoppers. A query that scans a year of orders slows checkout, even on a replica once replication lag grows.
Product images live inside the database and are copied into every backup and replica.
The core idea
Choose a store by the questions each kind of data must answer, then accept the cost of running more than one. Before naming any product, write down five properties for each kind of data: shape, access pattern, consistency need (does a stale answer cost money or only annoy?), volume and rate, and lifetime.
QuickCart data
Shape
Access
Consistency need
Lifetime
Orders, payments, stock
Fixed, related
Key, small ranges, joins
Strict: money and promises
Years
Product catalogue
Varied, nested
Whole document by id
Seconds of staleness is fine
Years
Search index
Words pointing to products
Words, filters, ranking
Seconds to minutes stale is fine
Rebuildable
Courier location
Timestamped points
Latest value, a time range
Last few seconds matter
Days, then rolled up
Product images
Bytes
Fetch by key
Never changes once written
Years
Analytics history
Wide, append-only
Scans and aggregates
Daily freshness
Years
Six profiles, six different answers.
The relational core
Relational databases and normalization
A relational database stores data in tables of typed columns and rows, links tables through keys, and answers new questions with SQL joins. A primary key identifies a row; a foreign key is a column that must match a primary key elsewhere, so the database refuses an order for a shopper who does not exist. Databases basics introduces the model; here it is applied to QuickCart.
Normalization is the discipline of storing each fact exactly once. When a fact is stored in many rows, an edit must reach every copy, and a missed copy is a bug: an update anomaly (a shopper changes their email and old orders still show the old one), an insert anomaly (a store cannot be recorded until it has an order) or a delete anomaly (deleting the last order deletes the only address).
The normal forms are checkpoints. A worked example, starting from what a hurried developer might build: one row per order with the items squeezed into one cell, such as 1001 | asha@example.com | Green Basket | oat milk x2; eggs x12.
First normal form (1NF) requires atomic values, one per cell, so each order line becomes its own row. The key is now (order_id, product_id):
(Prices are illustrative teaching values.) The shopper's email now appears twice for one order.
Second normal form (2NF) removes partial dependencies, columns that depend on only part of a composite key. Shopper and store columns depend on order_id alone; product_name on product_id alone. Split into orders(order_id, shopper_id, shopper_email, store_id, store_name), order_items(order_id, product_id, quantity, unit_price_cents) and products(product_id, product_name).
Third normal form (3NF) removes transitive dependencies: a non-key column that depends on another non-key column. In orders, shopper_email depends on shopper_id and store_name on store_id, not on the order. Move them to users(shopper_id, email) and stores(store_id, name), leaving orders(order_id, shopper_id, store_id). The mnemonic for the whole ladder: every non-key column depends on the key, the whole key, and nothing but the key. An email change now touches one row.
In this course an order belongs to one store; a cart with items from two stores becomes two orders (a simplifying assumption). Figure 2 shows the core tables.
Figure 2. QuickCart's relational core
erDiagram
accTitle: QuickCart relational core
accDescr: Users place orders with stores. Orders contain order items and receive payments. Stores keep per-store inventory rows with a price and a stock count.
USERS ||--o{ ORDERS : places
STORES ||--o{ ORDERS : fulfils
STORES ||--o{ INVENTORY : stocks
ORDERS ||--|{ ORDER_ITEMS : contains
ORDERS ||--o{ PAYMENTS : receives
USERS {
bigint id PK
text email
}
STORES {
bigint id PK
text name
}
INVENTORY {
bigint store_id PK
bigint product_id PK
int price_cents
int stock
}
ORDERS {
bigint id PK
bigint shopper_id FK
bigint store_id FK
text status
timestamptz created_at
}
ORDER_ITEMS {
bigint order_id PK
bigint product_id PK
int quantity
int unit_price_cents
}
PAYMENTS {
bigint id PK
bigint order_id FK
int amount_cents
text state
}
How to read this: each line is a sentence, such as "one store stocks many inventory rows". INVENTORY holds per-store price and stock. Descriptive product data (name, attributes, images) is not here because it moves to the catalogue store later, so product_id is a plain number, not a foreign key.
The inventory table, with the rule that stock cannot go negative written into the schema:
That missing foreign key is a real price of splitting stores: the database can no longer check that product_id exists, so referential integrity across stores becomes the application's job.
When to denormalize.Denormalization keeps a copy of a fact to make reads cheaper or truer. Normalize first; copy only with a reason and a plan to keep the copy right.
Copy
Why it is justified
How it stays correct
order_items.unit_price_cents
A historical fact: the price paid must not change when the shelf price does
Written once at checkout, never updated
orders.total_cents
Avoids summing lines on every order-history page
Computed in the same transaction as the lines
Store name inside a search document
Read speed on a hot path; no join across stores
Rebuilt from the source whenever the store changes
The first row is not redundancy: the price paid is a different fact from the current price. For the trade-off at scale, see storage engines.
Beyond tables
NoSQL types
NoSQL means "not only relational tables": stores that relax joins, fixed schema or multi-row transactions to gain flexibility or scale for one access pattern. The labels blur (PostgreSQL stores JSON, Redis has streams), but four families are worth knowing. Each gets a QuickCart verdict.
Key-value. A key-value store maps a key to a value and answers one question: "what is the value for this key?" It is fast because the store understands nothing about the value. Redis, the Stage 2 cache, is the example; its data types go beyond strings to hashes, sets, sorted sets and streams. Verdict: already in use. The cart is a candidate, but it must agree with checkout, so it stays relational for now.
Document. A document store keeps self-contained records, usually JSON, and can query inside them. MongoDB's data modeling guide states the rule: data that is accessed together should be stored together. Verdict: the product catalogue, because a product page needs name, description, attributes and images together, and attributes differ by category.
A rice document has weightGrams instead of volumeMl, and nothing breaks. The rule for embedding versus referencing: embed what is read together and bounded in size (reviews grow without bound, so they stay out); reference what grows or is read separately. An honest alternative: 100,000 products at an illustrative 5 KB each is about 500 MB, which a PostgreSQL jsonb column could hold. The reasons for a separate store are operational (independent scaling and ownership, catalogue reads off the primary), not capacity, and the cost is a second system.
Wide-column. A wide-column store such as Cassandra spreads rows across machines by a partition key and orders rows within a partition by clustering columns. Its documentation describes modelling as query-driven, with data duplicated so each query reads one table, and there are no joins. Verdict: not needed. It would suit courier pings at very large scale, but a time-series store does that job with less modelling effort at QuickCart's size.
Graph. A graph store keeps nodes and relationships and is quick at traversals such as "friends of friends". Verdict: not needed. "Shoppers who bought this also bought" is a co-purchase count computed in analytics, not a deep traversal. It would earn a place if QuickCart began hunting for connected clusters of accounts, such as fraud rings.
Choosing SQL versus NoSQL
"NoSQL scales, SQL does not" is a myth: a well-indexed relational database with replicas and a cache carries a great deal of load. Choose by access pattern. Ask, in order: Does a wrong answer cost money (then start relational)? Do you know every query in advance (then a query-shaped store is possible)? Is the shape stable? Is the database really the bottleneck (measure first)? Can the team operate it at 2 a.m.?
Figure 3. Choosing a store for a QuickCart data type
flowchart TD
accTitle: Choosing a store for a QuickCart data type
accDescr: A decision flow. If wrong answers cost money, use relational. Otherwise, bytes go to object storage, time-stamped points to a time-series store, search by words to a search engine, varied records read whole to a document store, and heavy history scans to a warehouse.
A["New kind of data"] --> B{"Wrong or stale answer costs money?"}
B -->|yes| R["Relational: PostgreSQL"]
B -->|no| C{"Large opaque files?"}
C -->|yes| O["Object storage"]
C -->|no| D{"Timestamped points, mostly appended?"}
D -->|yes| T["Time-series store"]
D -->|no| E{"Searched by words and ranked?"}
E -->|yes| S["Search engine, a derived copy"]
E -->|no| F{"Varied records read whole?"}
F -->|yes| DOC["Document store"]
F -->|no| W["Scans over history? Warehouse"]
How to read this: take the first question that fits. Money comes first because it rules out the stores that give up transactions.
Making reads fast
Indexing: B-tree, LSM tree and inverted index
An index is an extra data structure that lets the database find rows without reading the whole table. Three structures cover most of QuickCart; storage engines goes deeper.
B-tree. A B-tree keeps keys sorted in a shallow, wide tree of pages. A lookup follows one branch per level to a leaf holding the key and a pointer to the row. Teaching example: if each page holds 100 keys, three levels reach 100 x 100 x 100 = 1,000,000 entries. Sorted leaves also make ranges and ORDER BY cheap. In PostgreSQL, btree is the default index type.
Composite index and column order. An index on several columns is sorted by the first, then the second within it, like a phone book sorted by surname then first name. PostgreSQL's multicolumn index documentation gives the rule: equality constraints on leading columns, plus an inequality on the first column without an equality, limit the portion of the index that is scanned. A store dashboard asks for its paid orders awaiting acceptance, oldest first:
CREATE INDEX orders_store_status_created
ON orders (store_id, status, created_at);
SELECT id FROM orders
WHERE store_id =17AND status ='paid'ORDERBY created_at
LIMIT 50;
The two equalities narrow the scan to one corner of the index, where entries are already in created_at order, so no sort is needed. A query filtering only on status cannot use that corner, because the index is ordered by store_id first. (At the time of writing, PostgreSQL 18 can sometimes skip leading values, but the documentation says only when the leading column has few distinct values.) Put equality columns first, then the range or sort column.
The write cost. Every insert updates every index, and updates to indexed columns do too. Four indexes on orders means one table write plus four index writes per order, and each index uses memory and disk. On a busy table, use CREATE INDEX CONCURRENTLY: per the documentation it does not lock out writes, does two table scans, cannot run inside a transaction block, and can leave an invalid index if it fails.
LSM tree. A log-structured merge tree turns random writes into sequential ones. Writes go to a log for durability and to a sorted in-memory table (the memtable); when it fills, it is written out as an immutable sorted file (an SSTable). Reads check the memtable, then files from newest to oldest. Compaction later merges files and drops old versions. Worked example with courier pings, memtable flushing after three entries (illustrative):
Step
Action
Result
1
Write courier 7 at 10:00, courier 7 at 10:01, courier 3 at 10:00
Memtable full: flushed as file A, sorted by courier then time
2
Rewrite courier 7 at 10:01 with a corrected position
Goes to the new memtable
3
Later, file B is flushed. A read for courier 7 at 10:01 checks newest first
Both files hold it; B's value wins
4
Compaction merges A and B
One file, newest value kept
Writes are cheap; the price is read and compaction work. Cassandra and RocksDB use this design.
Inverted index. A product row lists its words; an inverted index flips that and maps each word to the list of rows containing it, a posting list. Take three product names: 1 "oat milk 1l", 2 "whole milk 1l", 3 "oat biscuits".
Figure 4. An inverted index over three product names
flowchart LR
accTitle: An inverted index over three product names
accDescr: Each word points to the list of product ids that contain it. The query oat milk intersects the lists for oat and milk and returns product 1.
t1["oat"] --> p1["1, 3"]
t2["milk"] --> p2["1, 2"]
t3["whole"] --> p3["2"]
t4["biscuits"] --> p4["3"]
p1 --> q["Query: oat AND milk"]
p2 --> q
q --> r["Result: product 1"]
How to read this: answering "oat milk" means intersecting two lists, which leaves product 1, without reading any product text.
PostgreSQL's GIN index is this structure: it stores (key, posting list) pairs and is commonly used for full-text search. Search engines add analysis and ranking on top.
Transactions and isolation
ACID and transaction isolation levels
A transaction groups statements into one all-or-nothing unit. ACID names its guarantees: atomicity (all or none), consistency (your declared rules hold), isolation (concurrent transactions do not interfere beyond a chosen level) and durability (committed data survives crashes). ACID consistency is about constraints, a different idea from the replica agreement in CAP and consistency. Isolation is the subtle one, and it is a dial.
Anomaly
What happens
QuickCart example
Dirty read
You read another transaction's uncommitted change
See a stock count that is later rolled back
Non-repeatable read
Reading the same row twice gives different values
A price changes between two reads on the checkout page
Phantom read
Re-running a query returns a different set of rows
A courier's count of active deliveries changes mid-transaction
Write skew
Two transactions read overlapping data and write different rows, breaking a rule neither broke alone
Two assignments each see 2 active deliveries for a courier and each add a third
The SQL standard defines four levels. PostgreSQL's documentation (version 18 at the time of writing) says it implements three distinct ones:
Level
PostgreSQL behaviour
Read uncommitted
Accepted, but behaves as read committed
Read committed (the default)
Each statement sees data committed before that statement began. Non-repeatable reads and phantoms are possible
Repeatable read
The whole transaction sees one snapshot, taken at its first statement. No dirty reads or non-repeatable reads, and PostgreSQL also prevents phantoms. This is snapshot isolation; write skew is still possible
Serializable
Repeatable read plus monitoring, so the outcome matches some one-at-a-time order. One of a conflicting set is aborted
Other databases differ; check the one you run.
The stock decrement. One unit of oat milk remains at store 17 and two shoppers check out together. A tempting design reads stock (it is 1), checks it is at least 1, and writes stock = 0. Under read committed both read 1 and both write 0, so QuickCart sells a unit it does not have: a lost update. The fix is one atomic statement:
PostgreSQL reports UPDATE 1 if a row changed and UPDATE 0 if none matched. Per the documentation, under read committed a second updater waits for the first, and if the first commits, re-evaluates the WHERE clause against the new version of the row. The second statement sees stock = 0, matches nothing and returns UPDATE 0, so the app says the item is gone. Under repeatable read the second transaction fails with could not serialize access due to concurrent update and must be retried. The schema's CHECK (stock >= 0) is a last line of defence.
Figure 5. Two checkouts race for the last unit
sequenceDiagram
accTitle: Two checkouts race for the last unit
accDescr: Shopper A and shopper B both send an atomic decrement. A's transaction takes the row and commits first. B waits, re-checks the condition on the new row version, matches nothing and gets zero rows updated.
participant A as Checkout A
participant D as PostgreSQL
participant B as Checkout B
A->>D: UPDATE stock - 1 where stock >= 1
B->>D: UPDATE stock - 1 where stock >= 1
Note over D: B waits for A's row lock
D-->>A: UPDATE 1 and COMMIT
Note over D: B re-checks WHERE on new row: stock is 0
D-->>B: UPDATE 0
B-->>B: tell shopper: sold out
How to read this: the row lock makes B wait, and the re-check after A commits is what stops the oversell. No application locking is needed.
Write skew needs more. Courier 7 may carry at most 3 active deliveries. Each assignment counts the active ones and inserts a row if the count is below 3:
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECTcount(*) FROM deliveries WHERE courier_id =7AND status ='active';
-- application checks: count < 3INSERT INTO deliveries (id, courier_id, order_id, status)
VALUES (5001, 7, 88001, 'active');
COMMIT;
Two such transactions insert different rows, so no row conflicts and, under repeatable read, both commit and the courier carries 4. Under serializable, PostgreSQL detects the dependency cycle and fails one with SQLSTATE 40001; the documentation requires code to rerun the whole transaction. An atomic update covers most rules; serializable is for rules that span rows. Work across databases is in distributed transactions.
Changing the data model safely
Data modeling and schema evolution
Data modeling means deciding which entities exist, how they relate and which rules hold. Work from queries and rules, not screens.
Schema evolution is the harder half, because the schema outlives any release. During a rolling deploy old and new application versions run together, so every change must be backward compatible.
Usually safe
Dangerous
Add a nullable column or a new table
Rename or drop a column or table
Add an index with CONCURRENTLY
Change a column's type
Add a column with a constant default
SET NOT NULL on a big table (checked immediately)
Add an optional field to a document
Change what an existing field means
The PostgreSQL ALTER TABLE guide says a constant default does not require updating every row, a volatile default does, and SET NOT NULL is checked immediately against existing data.
Expand and contract handles the dangerous changes in small, reversible steps. Example: rename orders.created_at to placed_at. A plain RENAME COLUMN is fast but breaks every old server still reading created_at.
Figure 6. Expand and contract migration
flowchart LR
accTitle: Expand and contract migration
accDescr: Five steps from adding a new column through dual writes, backfill and switching reads to dropping the old column.
S1["1 Expand: add placed_at"] --> S2["2 Deploy: write both columns"]
S2 --> S3["3 Backfill old rows in batches"]
S3 --> S4["4 Deploy: read placed_at"]
S4 --> S5["5 Contract: drop created_at"]
How to read this: the first steps only add and the last only removes, and the app works at every step. Any step can be paused or reversed until step 5.
-- 1 expandALTER TABLE orders ADDCOLUMN placed_at timestamptz;
-- 3 backfill, one range at a time, until no rows remainUPDATE orders
SET placed_at = created_at
WHERE placed_at ISNULLAND id BETWEEN1AND10000;
-- 5 contract, after a full release with no code reading created_atALTER TABLE orders DROPCOLUMN created_at;
Documents need the same care: store a schemaVersion, make readers tolerate unknown and missing fields, and upgrade old documents when next written.
Specialised stores
Object and blob storage
Object storage keeps immutable blobs under keys in a flat namespace behind an HTTP API; it is cheap, durable and built for large files. It is not a file system (no in-place edits) and not a database (no queries by content). Product images are the clear case. QuickCart stores bytes in a bucket under keys such as products/9001/a1/original.jpg and keeps only the key in the catalogue document. A new photo gets a new key, so CDN copies never need invalidating. Stores upload with a time-limited signed URL straight to the bucket (the photo sharing case study walks through it; CDN and object storage covers delivery); processing photos is Stage 7 work.
On consistency, Amazon S3 states that it provides strong read-after-write consistency for writes, overwrites, deletes and listings; other providers differ, so read yours. Lifecycle rules archive or delete old objects.
Search engines
A search engine answers "which documents match these words, best first?" It uses the inverted index above plus analysis on both sides: break text into tokens, lowercase them, optionally reduce words to stems, so "Milks" matches "milk". The Elasticsearch text analysis overview describes tokenization and normalization at index time and query time. Matches are scored, with schemes such as BM25 that favour rare terms, and combined with filters. QuickCart's rules:
The index is a derived copy built from the catalogue and inventory, never the source of truth, and can be rebuilt.
It is eventually consistent: changes arrive seconds later. Checkout reads price and stock from PostgreSQL.
Honest alternative: PostgreSQL full-text search with a GIN index would serve 100,000 products. A separate engine pays off when ranking, typo tolerance and typeahead matter.
Relevance, typeahead and keeping the index in sync are Stage 7 topics; see search and indexing.
Time-series databases
A time-series database stores timestamped measurements that are mostly appended, rarely updated and queried by time range. QuickCart has two sources: courier location pings and metrics.
Teaching arithmetic (illustrative): 10,000 couriers online, one ping every 5 seconds, is 10,000 / 5 = 2,000 writes per second. At 50 bytes a ping that is 100 KB per second, about 8.6 GB per day. A transactional database can store that, but its pages and backups would fill with a stream no one updates. What these stores do (see storage engines):
Partition by time, so old data goes as whole partitions. PostgreSQL's partitioning documentation notes that dropping a partition is far faster than a bulk delete and avoids its vacuum cost.
Compress regular timestamps and similar neighbouring values.
Downsample: keep raw pings for 7 days (about 60 GB at the rate above) and one point per minute for longer.
Limit cardinality. Each distinct label combination is its own series. Labelling metrics by courier_id is bounded by the courier count (10,000 here); labelling by order_id grows without limit.
The live map needs the latest position per courier, a different question from history: serve it from one overwritten key per courier in the Stage 2 cache.
Data warehouses and data lakes
QuickCart's questions about history ("revenue by store by week", "late deliveries by district") scan millions of rows and read few columns. This is OLAP, the opposite of the transactional OLTP work in the primary. A data warehouse is an analytical database, usually column-oriented, loaded with cleaned, modelled data and queried in SQL. A data lake is cheap object storage holding raw files in any format, with structure applied when read (schema-on-read) rather than when loaded (schema-on-write). Warehouses fit dashboards and repeatable reports; lakes fit exploration and messy data, but mistakes surface late, at read time.
A warehouse is usually a star schema: a fact table of events (fact_order_items) surrounded by dimension tables (dim_product, dim_store, dim_date).
ETL versus ELT.ETL (extract, transform, load) cleans data in a separate step before loading. ELT loads raw data first and transforms it inside the warehouse. ELT keeps the raw copy, so a corrected transformation can be re-run; ETL still fits when data must be filtered or masked before it lands.
Figure 7. QuickCart's analytics path (ELT)
QuickCart analytics path: operational stores to the warehouse (ELT)PostgreSQL to Extract and load (copy); Time-series store to Extract and load (copy); Extract and load to Raw landing area; Raw landing area to Warehouse (load); Warehouse to ReportsPostgreSQL: orders, paymentsPostgreSQLorders, paymentsTime-series store: pingsTime-seriesstorepingsExtract and load: nightly batchExtract andloadnightly batchRaw landing area: object storageRaw landingareaobject storageWarehouse: transform in SQLWarehousetransform in SQLReports: dashboardsReportsdashboardscopycopyload
How to read this: data is copied out of the operational stores on a schedule, kept raw in object storage, then transformed inside the warehouse. Analysts never touch the primary.
When does QuickCart need one? When heavy reports slow checkout even on replicas, when questions span several systems, or when years of history should not sit in operational stores. Before that, a replica is enough. Near-real-time pipelines are Stage 7.
Decisions and trade-offs
Option
Gain
Cost
When it is the wrong choice
Everything in PostgreSQL
One system, real transactions
Mixed workloads contend; weak search and analytics
When load or query shape already hurts the primary
Separate document store
Flexible shape, independent scaling
Second system, no cross-store foreign keys
When data is small and rarely changes shape
Dedicated search engine
Ranking, filters, typo tolerance
Derived copy to sync
When search is a few exact lookups
Warehouse
Fast scans, isolated from checkout
Load pipeline and delay
When a replica answers every report quickly
In practice: QuickCart at this stage
NOTE
This is a realistic scenario, not a case history. It is a composite educational reference design, and every figure is an illustrative assumption.
The situation
At one million monthly active users, product search takes over a second at peak (illustrative), store managers add free-text attributes every week, and a Friday sales report slows checkout. Courier pings are a large share of the primary's writes.
Starting architecture
Figure 1: one PostgreSQL primary with replicas, a cache and a CDN, a products table with dozens of mostly empty columns, and image bytes in a table.
Options considered
Option
What it fixes
What it costs
Risk
A. Stay on PostgreSQL: jsonb, GIN search, partitioned pings, replica reports
Most problems, cheaply
The primary still carries every workload
Medium: single point of contention
B. Polyglot: five specialised stores
Each workload gets a fitting store
Five systems to run and sync
Medium: more failure modes
C. One NoSQL store for everything
Simple operations
Loses transactions on orders and stock
High: money correctness moves into app code
The decision
Field
Record
Context
Search, pings, reports and images compete with checkout on one primary
Decision
Option B, in order of pain: images, catalogue, search, pings, warehouse. Orders, payments and stock stay in PostgreSQL
Good consequences
Checkout latency protected; each store fits its job; catalogue shape is free to change
Bad consequences
No foreign key from inventory to catalogue; search lags by seconds; five systems to operate
Revisit when
The team cannot operate the stores, or measurements show option A would have sufficed
Rollout and verification
Images. Copy bytes to object storage, store keys, point the CDN origin at the bucket, then delete the database copies after a backup check.
Catalogue in shadow mode. Write to both stores, compare reads from the new one with the old path and log differences, then switch reads at 5, 25, 50 and 100 percent.
Search. Build the index from the catalogue, compare results for the top queries, then switch.
Pings. Dual-write for a week, check counts match, then stop writing to PostgreSQL.
Warehouse. Load nightly and reconcile one day of order totals.
Watch checkout p99 latency, primary CPU, index lag and a daily order-count reconciliation.
The 2 a.m. failure
A catalogue deploy turns the nutrition attribute from a string into an object. At 02:00 the search indexer, which expects a string, throws on every such document and stops. Index lag climbs, new products and edits stop appearing, and a withdrawn product stays searchable.
An alert on index lag above a few minutes pages the on-call. The first three checks: the indexer error log (one parse error repeating on one field), the lag graph (flat, then rising from the deploy time) and the schemaVersion distribution in the catalogue (a new value appearing). Mitigation: roll back the catalogue change if the old shape can be restored; otherwise deploy an indexer that accepts both shapes, set failing documents aside and let it catch up. Correctness held, because checkout reads price and stock from PostgreSQL and rejects a withdrawn item. Follow-ups: tolerant readers, a schema check in CI before catalogue deploys, and dead-letter queues, introduced in Stage 5.
Architecture after this stage
Figure 8. QuickCart architecture after Stage 3 (new stores highlighted)
QuickCart architecture after Stage 3: each data type in its own storeClients to App servers; App servers to PostgreSQL (orders); App servers to Document store (catalogue); App servers to Search engine (search); App servers to Time-series store (pings); Document store to Search engine (index); Object storage to CDN (origin); Time-series store to WarehouseClients: shop, store, courierClientsshop, store,courierCDN: imagesCDNimagesApp servers: statelessApp serversstatelessPostgreSQL: orders, paymentsPostgreSQLorders, paymentsDocument store: catalogueDocument storecatalogueSearch engine: derived copySearch enginederived copyTime-series store: pings, metricsTime-seriesstorepings, metricsObject storage: imagesObject storageimagesWarehouse: analyticsWarehouseanalyticsorderscataloguesearchpingsindexorigin
How to read this: orange borders mark what this stage added. The CDN, app servers and primary database are from earlier stages; the load balancer, cache and replicas are unchanged and left out for clarity.
QuickCart's polyglot data map
Data
Lives in
Why there
Source of truth?
Users, carts, orders, order items, payments, stock, prices
PostgreSQL
Transactions, constraints, joins; a wrong answer costs money
Yes
Product descriptions, attributes, image keys
Document store
Varied shape, read whole by id
Yes, for descriptive data
Search index
Search engine
Word queries, ranking, filters
No: rebuilt from catalogue and inventory
Courier location history, metrics
Time-series store
Append-heavy, time-range queries, retention
Yes, for history
Latest courier position
Cache (key-value)
Overwritten constantly, read by the live map
No
Product images
Object storage, served by the CDN
Large immutable blobs, cheap and durable
Yes
Analytics history
Warehouse
Column scans over years of data
No: loaded from the sources
What QuickCart looks like now
Component
Why it exists
What it costs
PostgreSQL primary
Money-critical data needs transactions
Single-writer limit, addressed in Stage 4
Document store
Varied catalogue shape, separate scaling
No cross-store foreign keys; another system
Time-series store
Cheap pings and metrics with retention
New skill; cardinality discipline
Warehouse and landing area
History without hurting checkout
A pipeline and a daily delay
The next stage, scaling writes, shards the one component that must stay relational: orders.
Architect's notes
Start from the question, not the product. Fill in the five properties before anyone names a technology.
Keep money in one transactional store. Everything else may be stale; payments and stock may not.
Mark which copy is the truth. Test that every derived copy can be rebuilt.
Cross-store consistency is yours now. Plan reconciliation jobs that check ids match across stores.
Common mistakes
Read-then-write stock checks. They oversell under concurrency; use one atomic conditional update.
Serializable without retries. Serialization failures are normal; rerun the transaction.
Wrong column order in a composite index. Equality columns first, then range or sort.
Renaming a column in one step. Old servers still running break; expand and contract.
Choosing NoSQL because "it scales". Without a measured need you give up transactions.
Key takeaways
Choose a store by shape, access pattern, consistency need, volume and lifetime.
Normalize first. Copy only a historical fact or a measured hot path.
Four NoSQL families, four jobs. QuickCart uses key-value (already) and document stores, and does not need wide-column or graph yet.
B-trees serve lookups and ranges, LSM trees serve heavy appends, inverted indexes serve word search. Every index costs writes.
Composite index order matters. Equality columns first, then the range or sort column.
Use an atomic conditional update for stock. Read committed re-checks the condition after waiting.
Write skew needs serializable or explicit locking, and code that retries.
Practice exercises
Exercise 1 (30 minutes). Add courier ratings (a shopper rates a delivery 1 to 5 with a comment). Write the normalized tables with keys and constraints, and say where a denormalized average would help and how you would keep it right.
Exercise 2 (45 minutes). For "all orders for shopper 77 in the last 30 days, newest first", write the composite index. Name one query from this lesson it helps and one it does not, and say why.
Exercise 3 (1 hour). In PostgreSQL, create inventory with one row at stock 1. In two sessions run the read-then-write version, then the atomic update, then the atomic update under REPEATABLE READ. Write down what each session sees.
Exercise 4 (1 hour). Plan the expand and contract migration that moves users.email into a user_emails table. List each step and its rollback.
Lesson 3 of 11 · Backend System Design Project: Build QuickCart
Choose a data store for each QuickCart job: relational orders, a document catalogue, search, time-series courier tracking, object storage and a warehouse.
In the rest of this lesson
Making reads fast
Transactions and isolation
Changing the data model safely
Specialised stores
Decisions and trade-offs
In practice: QuickCart at this stage
Enrolling is free. It unlocks every lesson in Backend System Design Project: Build QuickCart, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.