Skip to content

    System Design

    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.

    By Published 24 min read

    • system-design
    • system-design-project
    • databases
    • data-modeling
    • indexing
    • nosql
    Share

    This lesson is part of Backend System Design Project: Build QuickCart. Enroll for free, then sign in, to read it in full.

    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

    Stage 2 architecture: one PostgreSQL primary holds every kind of dataShoppers and stores to App servers (browse, search); Courier apps to App servers (pings); App servers to Cache; App servers to PostgreSQL primary (all writes); PostgreSQL primary to Read replicas; Analysts to Read replicas (reports)Shoppers and storesShoppers andstoresCourier apps: location pingsCourier appslocation pingsAnalysts: heavy reportsAnalystsheavy reportsApp servers: statelessApp serversstatelessCacheCachePostgreSQL primary: every data typePostgreSQLprimaryevery data typeRead replicasRead replicasbrowse, searchpingsall writesreports

    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:

    1. 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.
    2. 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.
    3. Courier pings flood the transactional database. They are endless small appends that nothing updates, yet they share pages and backups with payments.
    4. Reports compete with shoppers. A query that scans a year of orders slows checkout, even on a replica once replication lag grows.
    5. 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):

    order_id product_id shopper_id shopper_email store_id store_name product_name quantity unit_price_cents
    1001 9001 77 asha@example.com 17 Green Basket Oat milk, 1 L 2 190
    1001 9002 77 asha@example.com 17 Green Basket Eggs, 12 1 320

    (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:

    CREATE TABLE inventory (
      store_id    bigint  NOT NULL REFERENCES stores (id),
      product_id  bigint  NOT NULL,
      price_cents integer NOT NULL CHECK (price_cents >= 0),
      stock       integer NOT NULL CHECK (stock >= 0),
      PRIMARY KEY (store_id, product_id)
    );

    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.

    {
      "productId": 9001,
      "name": "Whole Milk, 1 L",
      "categories": ["dairy", "milk"],
      "attributes": { "volumeMl": 1000, "allergens": ["milk"], "storage": "keep chilled" },
      "images": ["products/9001/a1/original.jpg"],
      "schemaVersion": 2
    }

    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 = 17 AND status = 'paid'
    ORDER BY 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:

    UPDATE inventory
    SET stock = stock - 1
    WHERE store_id = 17 AND product_id = 9001 AND stock >= 1;

    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;
    SELECT count(*) FROM deliveries WHERE courier_id = 7 AND status = 'active';
    -- application checks: count < 3
    INSERT 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 expand
    ALTER TABLE orders ADD COLUMN placed_at timestamptz;
    
    -- 3 backfill, one range at a time, until no rows remain
    UPDATE orders
    SET placed_at = created_at
    WHERE placed_at IS NULL AND id BETWEEN 1 AND 10000;
    
    -- 5 contract, after a full release with no code reading created_at
    ALTER TABLE orders DROP COLUMN 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

    1. Images. Copy bytes to object storage, store keys, point the CDN origin at the bucket, then delete the database copies after a backup check.
    2. 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.
    3. Search. Build the index from the catalogue, compare results for the top queries, then switch.
    4. Pings. Dual-write for a week, check counts match, then stop writing to PostgreSQL.
    5. 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.

    Official documentation

    Enroll to keep reading

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments