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.
- 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.
- 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.
- Uploads go through the app servers and are resized inside the request.
- 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.
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.
- One writer to the source of truth, one event per change. Consumers react to events; the request path does not call them.
- Consumers are idempotent. Delivery is at least once, so each tolerates the same event twice.
- Every derived store has a rebuild path. Without one it is a second source of truth.
- 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_inlists 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_minis 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_7dcomes 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.
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.
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.