Skip to content

    System Design

    QuickCart Stage 7: Search, Notifications, Uploads and Analytics

    Add search and recommendations, a notification service, a safe photo upload pipeline and stream-based analytics to QuickCart, with the trade-offs of each.

    By Published 24 min read

    • system-design
    • system-design-project
    • search
    • notifications
    • stream-processing
    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 keep a search index in step with the catalogue, rank results and recommend products in two stages, design a notification service that respects preferences and survives provider failures, build an upload pipeline that keeps image bytes off your servers, and explain windows, late data and idempotent sinks well enough to design an analytics pipeline.

    A restaurant kitchen runs on order tickets. Tickets arrive on a rail and each station reads the ones it cares about. By the end of Stage 6, QuickCart has its own ticket rail: checkout produces order events on a stream, and payments land in a ledger. Stage 7 adds the stations that read those tickets and the catalogue: search, recommendations, notifications, media processing and analytics. QuickCart is an educational reference design (an online grocery marketplace in one city region), and every number below is an illustrative assumption.

    Where QuickCart is now

    Stage 3 only placed a search engine, an object store and a warehouse in the architecture. Nothing feeds them properly.

    1. Search drifts. The app server writes the catalogue and then the index in one request. When the second write fails, a product cannot be found, or a deleted one still appears, and nobody is told.
    2. Notifications are sent inline. The order worker calls the email provider directly: a slow provider slows orders, preferences are ignored, and a retry can email a shopper twice.
    3. Uploads go through the app servers and are resized inside the request.
    4. Analytics is a nightly export. Operations sees nothing during the Saturday rush.

    Browsing peaks at about 2,300 requests per second (Stage 1); if one in four is a search (illustrative), that is about 575 searches per second. At an illustrative four notifications per order, 500,000 orders a day produce 2,000,000 notifications: about 23 per second on average and 230 at the 10 times peak. If each of the 5,000 stores uploads 20 photos a day (illustrative), that is about 1.2 uploads per second, but at an illustrative 3 MB each it is 300 GB a day. Bytes and fan-out are the problem.

    Figure 1. QuickCart after Stage 6, before the pipelines.

    QuickCart before Stage 7: dual writes, inline sends, photo bytes through the app, nightly exportShoppers to App servers (search, browse); Store app to App servers (photo bytes); App servers to Catalogue store (write); App servers to Search index (dual write); App servers to Order worker (order events); Order worker to Email provider (inline send); App servers to Orders database; Orders database to Warehouse (nightly export)ShoppersShoppersStore app: stock, prices, photosStore appstock, prices,photosApp servers: resize photos inlineApp serversresize photosinlineCatalogue storeCataloguestoreSearch index: can driftSearch indexcan driftOrder worker: sends email inlineOrder workersends emailinlineEmail providerEmail providerOrders databaseOrdersdatabaseWarehouse: yesterday's dataWarehouseyesterday's datasearch, browsephoto byteswritedual writeorder eventsinline sendnightly export

    How to read this: the amber boxes are where Stage 7 applies. Each is a derived copy produced by the request that changed the source of truth, so a failure in the copy becomes a failure, or a silent mismatch, in the real work.

    The core idea

    Treat every derived view as the output of a pipeline that reads a durable fact, never as a side effect of the request that created the fact. The catalogue row, the order event, the uploaded original and the click are facts. The search document, the push notification, the thumbnail and the revenue chart are derived: they can be rebuilt, retried and discarded. Four rules follow.

    1. One writer to the source of truth, one event per change. Consumers react to events; the request path does not call them.
    2. Consumers are idempotent. Delivery is at least once, so each tolerates the same event twice.
    3. Every derived store has a rebuild path. Without one it is a second source of truth.
    4. Every pipeline exposes its lag: how old is the oldest unprocessed event.

    Search and recommendation

    Product search: the pipeline from the catalogue

    Search means a shopper types words and gets a ranked list. A relational database answers LIKE '%milk%' poorly: it cannot rank or tolerate typos, and a leading wildcard cannot use an ordinary index. A search engine (Elasticsearch, OpenSearch and other Lucene-based systems) stores an inverted index, a map from each term to the documents containing it (search and indexing covers the mechanics). QuickCart's index holds one document per product, a projection of the catalogue with only the fields search needs.

    {
      "product_id": "p_18342",
      "name": "Full-cream milk 1 L",
      "brand": "Meadowfield",
      "category": "dairy",
      "dietary": ["vegetarian"],
      "available_in": ["store_17", "store_42"],
      "price_min": 1.19,
      "popularity_7d": 0.82
    }
    • available_in lists nearby stores that have stock. It changes when stock flips between "some" and "none", not on every sale, or a busy product would cause an index write per order line. Exact stock is read from the inventory service at add-to-cart and checkout, never from the index.
    • price_min is for sorting and filtering only. The displayed price comes from the price cache (Stage 2), so a lagging index never causes a wrong charge.
    • popularity_7d comes from the analytics pipeline below (an illustrative 0 to 1 scale).

    Analyzers

    Before text enters the index, and again when a query arrives, an analyzer splits it into tokens, lowercases it and may reduce words to a root; if the two sides differ, terms fail to match. QuickCart indexes name as analysed text and brand, category and available_in as exact keyword fields (analysing "frozen foods" would split it and break exact filters). It also keeps a synonym list ("courgette", "zucchini") that a person owns.

    Typo tolerance

    A shopper types "bananna". Fuzzy matching finds terms within a small edit distance (the single-character insertions, deletions, substitutions or transpositions between two terms) of the query term; "bananna" is one deletion from "banana". In Elasticsearch the match query's fuzziness parameter sets the maximum edit distance and AUTO derives it from term length (the rules are version-dependent; see the reference at the end). Fuzzy matching costs more than exact matching and can hit the wrong word ("bread", "beard"), so let exact matches outrank fuzzy ones.

    Faceted filters

    Facets are the filter sidebar with counts ("Dairy (42), Bakery (18)"), computed by aggregations that count matching documents per field value in the same request as the hits.

    {
      "query": {
        "bool": {
          "must": [
            { "match": { "name": { "query": "bananna", "fuzziness": "AUTO" } } }
          ],
          "filter": [
            { "term": { "available_in": "store_17" } },
            { "term": { "dietary": "vegetarian" } }
          ]
        }
      },
      "aggs": {
        "by_category": { "terms": { "field": "category" } },
        "by_brand": { "terms": { "field": "brand" } }
      },
      "size": 20
    }

    filter clauses do not affect scoring and can be cached by the engine, so availability and dietary tags go there rather than in must. Counts from a terms aggregation can be approximate on a sharded index (the Elasticsearch documentation says so): fine for "42 or 41 items", not for a payment. Compute each facet's counts with its own filter removed, or ticking "Dairy" makes every other category show zero.

    Ranking signals

    The order of results is what the shopper experiences. Text relevance (BM25 is the usual default in Lucene-based engines) says how well the words match. QuickCart blends it with popularity (orders in the last 7 days), availability, personal history (reordered before) and delivery distance: text alone ties many equal matches, popularity alone buries new products, and history alone helps nobody new. A worked example with illustrative weights: the shopper searches "milk", and the score is text + 0.3 x popularity + 0.5 x reordered.

    Product Text Popularity Reordered Without personal signal With it
    Full-cream milk 1 L 0.90 0.8 yes 0.90 + 0.24 = 1.14 1.64
    Milk chocolate bar 0.95 0.9 no 0.95 + 0.27 = 1.22 1.22
    Oat milk 1 L 0.85 0.4 no 0.97 0.97

    Without the personal signal the chocolate bar wins; with a reorder history, the actual milk comes first. Production systems learn weights from click data (learning to rank), but a hand-tuned blend is an explainable start.

    Keeping the index in sync with the source of truth

    This fixes pain 1. A dual write fails because two writes are not atomic: one can succeed and the other fail, and reversing the order only swaps which store is wrong. Instead, write once to the catalogue and let an event carry the change. Change data capture (CDC) reads the database's transaction log and emits one event per committed change; the transactional outbox (Stage 5) writes the change and an "event to publish" row in one transaction. An indexer consumes the events and upserts documents by product id with a version number, so a late older event cannot overwrite a newer one.

    Figure 2. How a catalogue or stock change reaches the search index.

    Search indexing pipeline: catalogue and stock changes flow through an event stream to an idempotent indexerCatalogue store to Event stream (outbox or CDC); Inventory service to Event stream (stock flips); Event stream to Indexer; Indexer to Search index (upsert); Search index to Search service (query); Reindex job to Search index (alias switch)Catalogue store: source of truthCataloguestoresource of truthInventory service: stock flipsInventoryservicestock flipsEvent stream: product, stock eventsEvent streamproduct, stockeventsIndexer: idempotent upsertIndexeridempotent upsertSearch index: products_v1Search indexproducts_v1Search serviceSearch serviceReindex job: builds products_v2Reindex jobbuildsproducts_v2outbox or CDCstock flipsupsertqueryalias switch

    How to read this: only the solid arrows are on the query path. Events feed the index, and the reindex job can rebuild it by filling a second index and repointing an alias.

    Plan the full reindex before you need it. Analysis settings generally cannot change on existing data, so a change builds products_v2 beside products_v1 and switches an alias; Elasticsearch's aliases API applies several actions in one request. The index lags by seconds, so add-to-cart verifies stock with the inventory service.

    Recommendation: candidate generation, then ranking

    A recommendation answers "what else might this shopper want?". Scoring all 100,000 products per request is too slow, so systems work in two stages: a cheap stage picks a few hundred candidates, and a costlier stage ranks them. Approaches, simplest first:

    • Popularity. The most ordered products in the region this week. It needs no personal data and works for a brand-new shopper (the cold start problem), but recommends the same things to everyone.
    • Co-purchase. Count product pairs that share a basket. The trap: bananas are in many baskets, so raw counts say everything goes with bananas. Compare with the base rate. Suppose 1,000 of 10,000 baskets (illustrative) contain pasta, 420 of those also contain sauce, and sauce is in 15 percent of all baskets. The chance of sauce given pasta is 42 percent against a base rate of 15 percent: a lift of 42 / 15 = 2.8. A lift near 1 means no real association, however large the counts.
    • Collaborative filtering, at concept level: products bought by the same people are similar, and shoppers with similar baskets like similar things. Matrix factorisation learns an embedding (a short list of numbers) per shopper and product whose dot product predicts affinity. It needs history and is hard to explain.
    • Content-based. Use attributes (category, brand, dietary tags); it works for a new product.

    Figure 3. The two-stage recommendation pipeline.

    Recommendation pipeline: offline candidate generation, online ranking and business rulesOrder and click history to Offline jobs; Offline jobs to Candidate store (nightly); Shopper app to Recommendation API (1 request); Recommendation API to Candidate store (2 candidates); Recommendation API to Ranker (3 score); Ranker to Business rules (4 filter); Business rules to Shopper app (5 top 10)Order and click historyOrder andclick historyOffline jobs: co-purchase, similarityOffline jobsco-purchase,similarityCandidate store: precomputed listsCandidatestoreprecomputed listsShopper appShopper appRecommendation APIRecommendationAPIRanker: scores a few hundredRankerscores a fewhundredBusiness rules: stock, diet, in cartBusiness rulesstock, diet, incartnightly1 request2 candidates3 score4 filter5 top 10

    How to read this: the top row runs in the background and may be hours old; the bottom row runs per request and must answer in tens of milliseconds.

    The business rules step removes out-of-stock items, things already in the cart, anything that breaks a dietary filter and age-restricted items for an unverified account; no model overrides those. Impressions and clicks flow back into the analytics pipeline. Measure quality by comparing add-to-cart rate against a control group.

    Notification systems

    A notification service turns events ("your order is out for delivery") into messages on the right channel, at the right time, once. Its consumers are people, so duplicates and bad timing are very visible. The site's notification service blog post designs a larger one end to end.

    Fan-out, channels and templates

    Fan-out means one event produces many deliveries. "Out for delivery" produces one push; a campaign produces one message per recipient: for 2,000,000 daily users at an illustrative 2,000 sends per second, about 17 minutes. The service queues one record per recipient and workers send at a rate the provider accepts.

    Channels differ. Push is instant but needs the app and permission, and tokens go stale: use it for order progress. Email holds long content but is slow to be read and prone to bounces: use it for receipts. SMS reaches any phone but costs per message: use it as a fallback for time-critical items. A template is the message with placeholders, stored per type, channel and language ("Your order {{order_id}} is {{minutes}} minutes away"). The event carries ids and values; the notification service owns the words.

    Preferences, quiet hours and two kinds of message

    Shoppers choose channels per type, and the preference check is a lookup before any send. Transactional messages (order confirmed, payment failed) honour the channel choice, but a shopper cannot opt out of everything. Time-critical ones (courier at the door) use a fallback and ignore quiet hours. Marketing needs consent and obeys quiet hours (for example none from 21:00 to 08:00 local time, illustrative), which need the shopper's time zone; a held message is rescheduled, not dropped. Consent rules differ by region, and a legal owner decides the wording.

    De-duplication and rate limits

    Events arrive at least once, so a restart can replay one. A de-duplication key (order, event type, channel) makes the second delivery a no-op. Redis can claim a key atomically with SET and its NX (only if absent) and EX (expiry in seconds) options:

    redis-cli SET notif:ord_9917:out_for_delivery:push 1 NX EX 86400
    # OK       first claim: send the push
    redis-cli SET notif:ord_9917:out_for_delivery:push 1 NX EX 86400
    # (nil)    already claimed: skip

    One limit: if a worker claims the key and crashes before sending, the message is lost. Either record the key after a successful send and accept a rare duplicate, or store a status (claimed, sent) and let a sweeper retry stale claims. For a delivery update a rare duplicate beats a missed message.

    Per-user rate limits protect the shopper from a bug that sends forty pushes: a token bucket per user and channel (see rate limiting) caps marketing at a few messages a day (illustrative). Updates about one order are collapsed, so "10 minutes away" replaces "15 minutes away".

    Provider failure, fallbacks and delivery receipts

    Providers send the actual push, email and SMS, and they fail: timeouts, throttling, outages. The pipeline copes in layers: retry with exponential backoff and jitter, a bounded number of times; a circuit breaker per provider that stops sending for a while after repeated failures (resilience patterns); a secondary provider for email and SMS (push usually has none, since each platform has its own gateway); and channel fallback, so when push fails permanently (invalid token) the service tries SMS if the type allows. A shopper who disabled SMS is not texted because push failed, except for a short, declared list of critical types.

    A delivery receipt is the provider's later report on a message it accepted; "accepted" only means it took the request. Not every provider or carrier returns every status, so a missing receipt means "unknown", not "failed". Receipts delete invalid tokens, suppress hard bounces and fill the delivery log.

    Figure 4. One notification, from event to phone.

    sequenceDiagram accTitle: Out for delivery notification accDescr: The notification service consumes an order event, checks preferences and the de-duplication key, sends a push, handles an invalid-token receipt and falls back to SMS. participant E as Event stream participant N as Notification service participant P as Preferences participant D as Dedup store participant G as Push gateway participant S as SMS provider E->>N: OutForDelivery for order 9917 N->>P: channels and quiet hours for shopper 31 P-->>N: push on, SMS fallback allowed N->>D: claim key for order, event, channel D-->>N: first claim N->>G: send rendered push G-->>N: accepted G-->>N: receipt: token invalid N->>S: fallback SMS, same message S-->>N: accepted Note over N: log each attempt and status

    How to read this: the service decides who, which channel and whether to send before it touches a provider. The receipt arrives later and triggers the fallback.

    Every notification ends as delivered, suppressed or failed, and each outcome is recorded for the support question "why did I not get a message?". Transactional and marketing messages use separate queues, so a campaign backlog cannot delay "your courier is here".

    File upload and media processing

    Store owners photograph products. This is the one pipeline whose input is a large, untrusted file from the internet.

    Pre-signed uploads straight to object storage

    Photo bytes never pass through the app servers. The API hands the store app a pre-signed URL: a time-limited URL, signed with the server's credentials, that lets its holder perform one operation on one object key. In Amazon S3, according to its documentation, such a URL allows an upload without the uploader having credentials, is limited by the permissions of its creator, replaces any existing object with the same key, and can be valid for up to seven days when generated by the SDKs or CLI. QuickCart uses 15 minutes (illustrative).

    1. The store app asks the API to start an upload for product p_18342, with an idempotency key. The API checks the store owns the product and creates an uploads row as pending.
    2. The API returns a pre-signed PUT URL for a random key such as incoming/up_7b2e (never a name the client chose) and the content type it signed for.
    3. The app uploads straight to object storage, using multipart upload for large files so a dropped connection resumes.
    4. Storage emits an "object created" event for the incoming/ prefix to a queue; workers scan, validate, transform and publish, and the app polls the API for status.

    A pre-signed PUT URL does not enforce a size range by itself; an S3 POST policy can carry a content-length-range condition, and the scan worker rejects oversize or wrong-type files anyway. Never trust the client's claimed type or size.

    The processing pipeline: scan, validate, resize, address

    Scan. The worker runs the file through an antivirus engine first (ClamAV, an open-source toolkit with a scanning daemon, is one example). Infected or unparseable files move to a quarantine prefix and the upload becomes rejected.

    Validate by decoding. Decode with an image library and read the real format and size from the bytes, not the extension. Reject images over a pixel limit (a small file can decode to an enormous image) and strip metadata (EXIF can carry GPS coordinates).

    Resize. Produce the sizes the apps request, for example 200, 600 and 1,200 pixels wide (illustrative), in a format such as WebP where clients support it. The pool scales on queue age. Video transcoding is the same pattern with long jobs (video streaming case study); v1 handles images only.

    Content addressing. A content-addressed store names an object by a cryptographic hash of its bytes, such as SHA-256. The worker hashes the validated original (never a client-supplied hash) and writes outputs under media/<hash>/600.webp. Two stores uploading the same manufacturer photo share one object. New bytes mean a new URL, so the CDN can cache for a very long time with no invalidation (CDN and object storage). The cost is deletion: delete only when a reference count reaches zero, and purge the CDN for a legal takedown.

    Publish. The worker sets the hash and sizes on the product, which emits a catalogue event that the indexer (Figure 2) picks up. Pipelines chain through events, not calls.

    Figure 5. One store photo, from the phone to the CDN.

    sequenceDiagram accTitle: Product photo upload accDescr: The store app gets a pre-signed URL from the API, uploads directly to object storage, and workers scan, resize and publish the photo through a queue. participant A as Store app participant P as API participant O as Object storage participant Q as Queue participant W as Media workers participant C as Catalogue A->>P: start upload for product 18342, idempotency key P-->>A: pre-signed URL for incoming/up_7b2e A->>O: PUT original bytes O->>Q: object created event Q->>W: deliver event W->>O: read original Note over W: scan, decode, strip EXIF, hash W->>O: write media/hash/600.webp and other sizes W->>C: set image hash, emit catalogue event A->>P: poll status P-->>A: ready

    How to read this: the bytes never pass through QuickCart's API. The queue and workers do the slow work; the catalogue update at the end makes the photo visible.

    Failure and retry

    S3's documentation says its event notifications are designed to be delivered at least once, so a worker may see an event twice. Workers are idempotent: output keys derive from the hash, and status only moves forward. A crashed worker's message returns to the queue when the visibility timeout expires. A file that always fails goes to a dead-letter queue after a few retries and the upload is marked failed. A sweeper deletes pending rows whose URLs expired. One trap the S3 documentation warns about: writing to the bucket that triggers the notification can loop, so the trigger applies only to the incoming/ prefix.

    Stream processing and analytics pipelines

    Stream processing: windows, event time and late data

    Stream processing computes continuously over events as they arrive. The first idea is the window: you cannot sum an endless stream, so you sum a slice. As Apache Flink's documentation names them, tumbling windows are fixed and do not overlap (orders per minute), sliding windows overlap (orders in the last 10 minutes, updated each minute) and session windows close after a gap of inactivity.

    The second idea is two clocks. Event time is when the thing happened, as stamped by the phone; processing time is when the system sees it. They diverge whenever there is delay: a courier's phone in a lift sends events a minute late. Processing-time windows give different answers on replay; event-time windows give the same answer when the same events are replayed, at the cost of waiting.

    How long to wait? A watermark is the system's claim that event time has reached some point, so earlier events should have arrived; one common heuristic keeps it a fixed bound behind the latest event time seen. An event arriving after the watermark has passed its window is late. In Apache Flink, at the time of writing, late events are dropped by default unless the window has an allowed lateness, which lets it fire again with a corrected result.

    A worked example: one-minute tumbling windows of OrderPlaced events by event time, with a watermark 30 seconds behind the latest event time (illustrative).

    Arrival Event time Watermark What happens
    1 12:00:10 11:59:40 Goes to window 12:00
    2 12:00:50 12:00:20 Window 12:00 now has 2
    3 12:01:05 12:00:35 Goes to window 12:01
    4 12:01:35 12:01:05 Watermark passes 12:01:00: window 12:00 closes and emits 2
    5 12:00:58 12:01:05 Belongs to the closed window 12:00: late

    Event 5 is a real order. Dropping it leaves the dashboard one short; allowing lateness emits a corrected 3. A live view can accept "about right, quickly"; the warehouse recomputes later.

    Exactly-once effect through idempotent sinks

    After a failure some events are processed again, so an honest "exactly once" guarantee concerns the effect. Kafka's documentation describes an idempotent producer, so retried sends do not create duplicate log entries; transactions, which write to several partitions atomically; and a read_committed consumer setting that returns only committed transactional messages. Together they give exactly-once for a read-process-write cycle between Kafka topics, including in Kafka Streams; an external destination must cooperate, for example by storing the consumer's offset with its output.

    Anything outside the platform (a database, an email, a payment API) needs an idempotent sink: applying the same result twice leaves the same state. The simplest form writes a replacing result under its identity.

    -- One row per store per minute window.
    CREATE TABLE orders_per_minute (
      window_start timestamptz NOT NULL,
      store_id     bigint      NOT NULL,
      orders       integer     NOT NULL,
      PRIMARY KEY (window_start, store_id)
    );
    
    -- The stream job writes the full window result, not an increment.
    INSERT INTO orders_per_minute (window_start, store_id, orders)
    VALUES ('2026-11-07 12:00:00+00', 17, 3)
    ON CONFLICT (window_start, store_id)
    DO UPDATE SET orders = EXCLUDED.orders;

    Run it once or five times and the row says 3; a corrected window result overwrites it with 4. Write "the answer for this window", never "add 1": an orders = orders + 1 sink double-counts a replayed event, and no broker setting prevents that.

    The analytics pipeline: events to stream to warehouse

    Analytics answers business questions in four steps. Instrument: apps and services emit small, validated events (page_view, search, add_to_cart, order_placed), each with an event_id for de-duplication, an event_time from the sender, a received_at from the collector (so lateness is measurable) and a schema_version. Collect and stream: a collector validates events, writes them to a topic and replies quickly; consumers (a stream job, a loader, recommendation feedback) are independent. Land and transform: a loader writes raw events to object storage as files partitioned by day (the data lake); a batch job de-duplicates by event_id and loads modelled tables into the warehouse (Stage 3). Serve: dashboards read the warehouse for history and a small metrics store for real-time numbers.

    {
      "event_id": "e_5f1c0a9d",
      "type": "add_to_cart",
      "event_time": "2026-11-07T12:00:58Z",
      "received_at": "2026-11-07T12:01:20Z",
      "schema_version": 3,
      "user_pseudo_id": "u_a91f",
      "session_id": "s_33c2",
      "properties": { "product_id": "p_18342", "store_id": "store_17", "source": "search" }
    }

    Use a pseudonymous id, never an email or phone number: events are copied widely and kept long, so direct identifiers make privacy deletion hard. At an illustrative three events per page view, 40,000,000 page views a day is 120,000,000 events: about 1,390 per second on average and about 6,900 at the 5 times peak. The sizing question is storage and retention.

    Figure 6. QuickCart's analytics pipeline.

    Analytics pipeline: events to a stream, then a real-time path and a batch path to the warehouseApps and services to Event collector (events); Event collector to Event stream; Event stream to Stream job (seconds); Stream job to Metrics store (upsert); Event stream to Data lake (loader); Data lake to Warehouse (daily batch)Apps and services: emit eventsApps andservicesemit eventsEvent collector: validatesEventcollectorvalidatesEvent stream: retained logEvent streamretained logStream job: windows, event timeStream jobwindows, eventtimeMetrics store: real-time numbersMetrics storereal-time numbersData lake: raw files by dayData lakeraw files by dayWarehouse: modelled tablesWarehousemodelled tableseventssecondsupsertloaderdaily batch

    How to read this: one stream feeds two paths. The upper answers within seconds and may be slightly wrong; the lower is slower and complete. Both can be rebuilt by replaying the stream.

    Batch versus stream, lambda versus kappa

    Batch processing computes over a bounded dataset on a schedule; streaming computes over events continuously. Batch is simpler, easy to re-run and cheap per event; streaming gives freshness but needs the event-time, late-data and idempotency discipline above.

    The lambda architecture runs both over the same input and merges them at query time; Jay Kreps's essay "Questioning the Lambda Architecture" names its pain as maintaining "code that needs to produce the same result in two complex distributed systems". The kappa alternative keeps one stream path and reprocesses by replaying the retained log into a new output table (event streaming). QuickCart takes a hybrid: a few operational numbers go through the stream job, everything else is batch from the lake.

    Figure 7. Which path does a number take?

    flowchart TD accTitle: Choosing batch or stream for a metric accDescr: A decision flow that sends a metric to a stream job, to the source of truth, to the ledger or to the warehouse batch path. Q1{"Does someone act on it within minutes?"} -->|yes| Q2{"Is a small error or a late event acceptable?"} Q1 -->|no| Q3{"Is it money or a legal figure?"} Q2 -->|yes| S["Stream job with windows, reconcile in batch"] Q2 -->|no| T["Read the source of truth, not events"] Q3 -->|yes| L["Batch from the ledger and reconciliation"] Q3 -->|no| W["Batch from the lake into the warehouse"]

    How to read this: only numbers that drive an immediate action and tolerate small error earn a stream job. Revenue comes from the Stage 6 ledger, never from clickstream events.

    Metrics for the business

    Define each metric precisely and name its source, or two dashboards will disagree. Orders per daily active user is 500,000 / 2,000,000 = 0.25 (a ratio, not a conversion rate). Search success rate is searches followed by a click or add-to-cart, divided by all searches. Delivery time uses the median and 90th percentile, because averages hide pain (observability and SLOs). Revenue and refunds are sums of ledger entries, never events.

    Decisions and trade-offs

    Option Gain Cost When it is the wrong choice
    Index from outbox or CDC events No silent drift, replayable A pipeline to run; seconds of lag A tiny catalogue where a nightly rebuild is enough
    Popularity plus co-purchase recommendations Cheap, works at cold start Little personalisation Taste varies widely and history is rich
    Dedicated notification service Preferences, dedup, fallbacks in one place One more service; template governance One low-volume message type only
    Pre-signed direct upload Bytes off the app tier Two-step flow; validation after upload Files are tiny and the API is already private
    Stream job for live metrics Fresh operations view Late-data logic, an engine to run Nobody acts within minutes

    In practice: QuickCart at this stage

    NOTE

    This is a realistic scenario, not a case history. It is an educational reference design, and every number is an illustrative assumption.

    The situation

    QuickCart is at about 5,000,000 monthly active users and growing. Search is wrong after catalogue edits, a Saturday offer email sent through the order worker delayed orders by minutes, store owners say photo uploads time out on mobile, and operations wants an hourly orders-by-store view the nightly export cannot supply. Eight engineers own the work.

    Starting architecture

    It is Figure 1, plus the Stage 5 event stream and outbox, which are the foundation.

    Options considered

    Option What it fixes What it costs Risk
    Patch each pain (retry the index write, add a mail queue, raise the upload timeout) The loudest symptoms Four one-off fixes The coupling remains; drift and duplicates continue
    Event-driven consumers on the existing stream The shared cause Four pipelines to run Over-building before need

    The decision

    Field Decision record
    Context Four features produce derived data inside the request path and fail silently or slowly.
    Decision Four consumers on the existing stream: an indexer (versioned upserts, a rebuild path), a notification service (separate transactional and marketing queues, a circuit breaker per provider), media workers behind pre-signed uploads, and a stream job plus batch loader. Revenue stays on the ledger.
    Consequences, good A failing provider or index no longer slows checkout; every derived store can be rebuilt.
    Consequences, bad More to operate; stock changes show seconds late.
    Revisit when Index lag p99 stays above target for a week, or on-call load is unsustainable.

    Rollout and verification

    Ship each pipeline beside the old behaviour. Search: build products_v2, compare its top 10 with the live index for a sample of real queries (say 1,000, illustrative), then switch the alias. Notifications: dry-run mode, logging "would send", then one type for 5 percent of shoppers. Uploads: direct upload for 10 percent of stores, watching failures and dead-letter depth. Analytics: run the stream job beside the nightly export and reconcile orders per day. Dashboards: indexer lag, zero-result rate, notification queue age per priority, per-provider error rate, media DLQ depth and stream-job lag.

    The 2 a.m. failure

    At 02:00 the email provider starts timing out. Workers retry without a cap, and because the marketing digest and order receipts share one topic and consumer group, receipts queue behind 400,000 digest messages (illustrative). The page says "oldest transactional notification older than 60 seconds". First three checks: per-provider error rate and latency, consumer lag per queue (is transactional waiting behind marketing?) and circuit breaker state (it should have opened). Mitigation: open the breaker by hand, switch email to the secondary provider, pause the marketing queue and let receipts drain. Follow-ups: separate queues and consumer groups by priority, capped retries with jitter, and an alert on queue age rather than depth.

    Architecture after this stage

    Figure 8. QuickCart after Stage 7: the four new pipelines.

    QuickCart after Stage 7: search, notification, media and analytics pipelines on the event streamCatalogue store to Indexer; Indexer to Search index; Search index to Search service; Order events to Notification service; Notification service to Channel workers; Channel workers to Push, email, SMS; Store app to Object storage (pre-signed PUT); Object storage to Media workers (event); Media workers to CDN (by hash); Event collector to Event stream; Event stream to Stream job; Stream job to Warehouse (metrics, batch)SEARCH AND RECOMMENDATIONSNOTIFICATIONSMEDIAANALYTICSCatalogue store: from Stage 3Cataloguestorefrom Stage 3IndexerIndexerSearch indexSearch indexSearch service: plus recsSearch serviceplus recsOrder events: from Stage 5Order eventsfrom Stage 5Notification serviceNotificationserviceChannel workersChannelworkersPush, email, SMS: providersPush, email,SMSprovidersStore appStore appObject storage: incoming, quarantineObject storageincoming,quarantineMedia workers: scan, resize, hashMedia workersscan, resize,hashCDN: media by hashCDNmedia by hashEvent collectorEventcollectorEvent streamEvent streamStream jobStream jobWarehouse: from Stage 3Warehousefrom Stage 3pre-signed PUTeventby hashmetrics, batch

    How to read this: muted boxes existed before; accent boxes are new in Stage 7. Each row is one pipeline, left to right, from a source of truth to a derived view.

    What QuickCart looks like now

    Component Why it exists What it costs
    Indexer and rebuildable search index Search that follows the catalogue without drift A consumer, index storage, seconds of lag
    Ranking blend and two-stage recommendations Order results by what shoppers want; suggest items Offline jobs, a candidate store, tuning
    Notification service, workers, separate queues Preferences, dedup, fallbacks, receipts; campaigns cannot delay order updates A service, templates, provider fees
    Media workers and content-addressed store Safe, scalable uploads with immutable CDN URLs Compute, quarantine handling, reference counting
    Event collector, stream job, lake, warehouse Fresh operations metrics and a complete record A stream engine, storage, a retention policy

    Architect's notes

    • Decide the lag you promise before choosing the mechanism. "Stock changes appear in search within 5 seconds" can be alerted on; "near real time" cannot.
    • Keep one owner for each fact. The cart asks inventory about stock, not the index or a cache.
    • Separate priorities early. Transactional and marketing notifications on one queue is an avoidable incident.
    • Defer machine learning. Popularity, lift and a hand-tuned blend are measurable baselines.

    Common mistakes

    • Dual writing to the database and the index. It drifts silently; write once and publish an event.
    • Recommending by raw co-occurrence counts. Compare with the base rate.
    • Sending notifications from the order path, or on one queue for all kinds. A provider slowdown becomes a checkout slowdown, and marketing delays order updates.
    • Treating "accepted by the provider" as "delivered", or trusting an uploaded file's name, type or size. Use receipts; decode and scan on the server.
    • Counting by adding in a sink, or computing money from events. A replay double-counts; money comes from the ledger.

    Key takeaways

    • Derived views come from pipelines over durable facts, not from side effects of the request that created the fact.
    • A search index is a rebuildable projection, kept in sync by outbox or CDC events, versioned upserts and an alias-switch reindex.
    • Ranking blends text relevance with business and personal signals; recommendations work in two stages, with business rules no model overrides.
    • A notification service owns preferences, quiet hours, de-duplication, rate limits, fallbacks and receipts, and keeps transactional and marketing traffic apart.
    • Pre-signed uploads keep bytes off the app tier; scan, decode, resize and hash make the file safe, and content addressing makes CDN caching immutable.
    • Streams need event time, watermarks and a late-data policy; "exactly once" means an idempotent effect, not a broker setting.
    • Choose batch or stream per metric; money comes from the ledger.

    Practice exercises

    Exercise 1 (30 min): Add "Lactose-free milk 1 L" to the ranking table (text 0.89, popularity 0.3, not reordered). Compute its score with and without the personal signal and say where it ranks.

    Exercise 2 (1 hour): For the four order notifications, tabulate kind, channels, fallback, quiet-hours behaviour and de-duplication key.

    Exercise 3 (2 hours): Extend the window example with a sixth event at 12:01:50 and a seventh at 12:00:40 that arrives after it. Which windows close, and what does each late event do under "drop" and "allow lateness"? Write the final upserts.

    Exercise 4 (half day): Design the takedown flow for a photo shared by three products through one content-addressed object: the reference check, the deletion order, what each failure leaves visible, and how you prove completion.

    Official documentation

    Enroll to keep reading

    Lesson 7 of 11 · Backend System Design Project: Build QuickCart

    Add search and recommendations, a notification service, a safe photo upload pipeline and stream-based analytics to QuickCart, with the trade-offs of each.

    In the rest of this lesson

    • File upload and media processing
    • Stream processing and analytics pipelines
    • Decisions and trade-offs
    • In practice: QuickCart at this stage
    • Architecture after this stage
    • Architect's notes

    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