Dossier › Document 03
DOCUMENT 03 · SYSTEM ARCHITECTURE

How QuickCart is built

The end-to-end system — from the React Native app on a shopper's phone, through the Node.js services and the mcp-adaptor aggregation layer, down to PostgreSQL, Redis and OpenSearch — plus catalogue management, missing-SKU discovery, analytics and payments.

8
Node.js services
3+
PostgreSQL · Redis · OpenSearch
<1.5s
Search p95 latency
5
MCP sources

QuickCart is a meta-marketplace: it holds a canonical product catalogue but sources live price, stock and delivery-time from five platforms — Blinkit, Zepto, BigBasket, Flipkart and Amazon — through a single aggregation service, the mcp-adaptor. The architecture below is organised around that fan-out.

System context

A thin, presentation-only mobile client talks to a single API Gateway / BFF, which composes responses from a set of focused Node.js services. Only the mcp-adaptor service ever speaks to external platform MCPs; every other service is internal and stateless where possible.

flowchart TB APP["📱 React Native App
(iOS / Android)"] APP -->|"HTTPS REST + WSS"| GW["API Gateway / BFF
(NestJS)"] subgraph SVC["Node.js services"] direction LR AGG["Aggregation
mcp-adaptor"] CAT["Catalogue"] SRCH["Search
indexer"] ORD["Order"] PAY["Payment"] AN["Analytics /
Events"] end GW --> AGG & CAT & SRCH & ORD & PAY & AN subgraph DATA["Datastores"] direction LR PG[("PostgreSQL
transactional")] RD[("Redis
price cache")] OS[("OpenSearch
product search")] S3[("S3
images")] KF[["Kafka
event bus"]] end CAT --> PG & S3 ORD --> PG PAY --> PG SRCH --> OS AGG --> RD AN --> KF subgraph MCP["Platform MCP servers"] direction LR B["Blinkit"] Z["Zepto"] BB["BigBasket"] FK["Flipkart"] AZ["Amazon"] end AGG -->|"MCP tool calls"| B & Z & BB & FK & AZ
Figure 3.1 — System context. The app has one entry point (the gateway); only mcp-adaptor reaches external platforms.
Design principle — one aggregation boundary Every external dependency (rate limits, outages, ToS constraints) is contained inside mcp-adaptor. The rest of the system treats "an offer" as a normalised internal object, so the product experience is unchanged whether a source is fast, slow or temporarily down.

How the mobile app connects to the backend

The client is a React Native app. It never talks to a database or to a platform MCP directly — it speaks only to the gateway over three channels:

🔗

HTTPS REST + JSON

The default. Versioned under /v1, cursor-paginated lists, ETag caching. A GraphQL BFF endpoint is optional for screens that need to compose many resources in one round-trip.

📡

WebSocket / SSE

Live order tracking (WS /v1/orders/:id/track) pushes courier location and status transitions without polling. Falls back to SSE where sockets are blocked.

🔐

Auth & transport security

OTP login → short-lived JWT access token + rotating refresh token. TLS 1.3 everywhere, certificate pinning in the app, secrets in a vault.

Connection & auth handshake

sequenceDiagram autonumber participant App as 📱 App participant GW as API Gateway participant Auth as Auth Svc participant Agg as Aggregation App->>GW: POST /v1/auth/otp {phone} GW->>Auth: request OTP Auth-->>App: OTP sent via SMS App->>GW: POST /v1/auth/verify {phone, otp} GW->>Auth: verify OTP Auth-->>App: access JWT (15m) + refresh (30d) Note over App,GW: All later calls carry Authorization: Bearer <jwt> App->>GW: GET /v1/search?q=butter GW->>GW: validate JWT + rate-limit GW->>Agg: search("butter", zone) Agg-->>GW: ranked offers[] GW-->>App: 200 { offers[] }
Figure 3.2 — OTP login issues a JWT pair; every subsequent request is validated at the gateway before fan-out.

Key API surface

Method & pathPurposeNotes
POST /v1/auth/otp · /verifyPasswordless loginIssues JWT access + refresh
GET /v1/search?q=Search productsReturns canonical products + best offer
GET /v1/products/:id/offersAll source offers for one productRanked; cached in Redis (see Doc 05)
POST /v1/cart · PATCH /v1/cartCart managementServer-authoritative cart
POST /v1/ordersPlace an orderRequires Idempotency-Key header
GET /v1/orders/:idOrder detailSnapshot state
WS /v1/orders/:id/trackLive trackingStatus + courier position stream

An auth-guarded route

services/gateway/routes/offers.ts
import { Router } from "express";
import { authGuard } from "../middleware/authGuard";
import { aggregation } from "../clients/aggregation";

const router = Router();

// GET /v1/products/:id/offers  — ranked offers for one canonical product
router.get("/v1/products/:id/offers", authGuard, async (req, res, next) => {
  try {
    const { id } = req.params;
    const zone = req.user.deliveryZone;              // from JWT claims
    const offers = await aggregation.getRankedOffers(id, zone);
    res.set("Cache-Control", "private, max-age=30"); // matches Redis TTL
    res.json({ productId: id, offers });
  } catch (err) { next(err); }
});

export default router;
services/gateway/middleware/authGuard.ts
import jwt from "jsonwebtoken";

export function authGuard(req, res, next) {
  const header = req.headers.authorization ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: "missing_token" });
  try {
    req.user = jwt.verify(token, process.env.JWT_PUBLIC_KEY, { algorithms: ["RS256"] });
    next();
  } catch {
    return res.status(401).json({ error: "invalid_token" });
  }
}
Resilience built into the client contract Orders require an Idempotency-Key so a retried request never double-charges. The app keeps an offline read cache of the catalogue and the last cart, so browsing degrades gracefully on a flaky connection.

Backend services

Each service owns one responsibility and (where it is stateful) one datastore. They communicate synchronously through the gateway for reads and asynchronously over Kafka for events.

API Gateway / BFF · NestJS

Auth, rate-limiting, request validation, response composition, WebSocket fan-out. The only public surface.

Aggregation · mcp-adaptor

Scatter-gathers to the five platform MCPs, normalises fields, matches to canonical SKUs, ranks offers. Cache in Redis. Detailed in Doc 05.

Catalogue

Owns canonical products, brand/category taxonomy, images and the platform source_map. Writes to PostgreSQL + S3.

Search indexer

Consumes catalogue changes, builds the OpenSearch index (analyzers, synonyms, typo-tolerance), serves query→product resolution.

Order

Cart→order state machine, idempotent placement, source hand-off / managed fulfilment, live tracking events.

Payment

PSP integration (Razorpay), webhooks, refunds, settlement & reconciliation. PCI-minimised via tokenisation.

Analytics / Events

Ingests the client + server event stream into Kafka, powers funnels, KPIs and the missing-SKU signal.

Notification

Push (FCM/APNs), SMS and email for OTP, order updates and back-in-catalogue alerts.

Technology stack

LayerTechnologyWhy
Mobile clientReact Native + TypeScriptOne codebase, native performance, OTA updates
API / servicesNode.js 20, NestJS, TypeScriptAsync I/O suits high-fan-out aggregation; strong typing
Transactional DBPostgreSQL 16ACID for orders/payments, JSONB for flexible attributes
Cache / hot dataRedis 7Sub-ms price/stock cache, rate-limit counters, sessions
SearchOpenSearchTypo-tolerant, synonym-aware product search
Object storageS3 + CDNProduct images, served at the edge
Event busKafkaDecouples analytics, search indexing, missing-SKU capture
WarehouseClickHouse / BigQueryFunnels, KPIs, cohort analysis
InfraKubernetes, containersHorizontal scale, rolling deploys, autoscaling
PaymentsRazorpay (UPI/cards/wallets)India-first PSP, UPI-native, webhooks

Data model

The catalogue is deliberately separated from the volatile per-source offer snapshots. A canonical product is stable; an offer (price/stock/ETA from one source) is short-lived and refreshed constantly.

erDiagram USERS ||--o{ ADDRESSES : has USERS ||--o{ CARTS : owns USERS ||--o{ ORDERS : places USERS ||--o{ SEARCH_LOGS : generates USERS ||--o{ EVENTS : emits PRODUCTS ||--o{ PRODUCT_SOURCE_MAP : "mapped to" PRODUCT_SOURCE_MAP ||--o{ OFFERS : "snapshots" PRODUCTS ||--o{ CART_ITEMS : "referenced by" PRODUCTS ||--o{ ORDER_ITEMS : "referenced by" CARTS ||--o{ CART_ITEMS : contains ORDERS ||--o{ ORDER_ITEMS : contains ORDERS ||--|| PAYMENTS : "settled by" SEARCH_LOGS ||--o{ MISSING_SKU_QUEUE : feeds USERS { uuid id PK string phone string name timestamptz created_at } PRODUCTS { uuid id PK string canonical_sku string title string brand string category string pack_size string gtin jsonb attributes string status } PRODUCT_SOURCE_MAP { uuid id PK uuid product_id FK string source string source_product_id float match_confidence } OFFERS { uuid id PK uuid source_map_id FK int mrp_paise int price_paise int stock_qty int eta_minutes timestamptz fetched_at } ORDERS { uuid id PK uuid user_id FK string state int total_paise string fulfilment_mode } PAYMENTS { uuid id PK uuid order_id FK string psp_ref string status int amount_paise } MISSING_SKU_QUEUE { uuid id PK string query_cluster int hits float best_confidence string status }
Figure 3.3 — Core relational model. Offers hang off the source-map, not the product, so a product can carry five live offers at once.

PostgreSQL

Users, catalogue, carts, orders, payments — anything that must be consistent and durable.

Redis

The hot offer cache (price/stock/ETA) with 30–60s TTLs, rate-limit counters, and pub/sub for tracking.

OpenSearch

The searchable projection of the catalogue — text queries resolve here to canonical product IDs.

Catalogue management

The catalogue is the backbone: every search result, comparison and order references a canonical product. Platform-specific listings are attached to it, never the other way round.

The canonical product model

FieldMeaning
canonical_skuStable internal identifier, e.g. QC-GRO-AMUL-BUTTER-500G
title, brand, categoryNormalised display + taxonomy
pack_size / unitNormalised pack (e.g. 500 g) so "butter 500g" matches across sources
gtin / eanBarcode — the strongest cross-source match key
attributes (JSONB)Flexible per-category attributes (flavour, wattage, veg/non-veg…)
images[]De-duplicated, re-hosted on our CDN
statusactive candidate retired

Mapping platform products → canonical

Each source listing is linked through PRODUCT_SOURCE_MAP with a match confidence. Matching runs a cascade: exact GTIN/EAN → fuzzy brand + normalised title + pack-sizeembedding similarity on title+image. High-confidence matches auto-link; the rest go to review.

canonical-product.example.json
{
  "canonical_sku": "QC-GRO-AMUL-BUTTER-500G",
  "title": "Amul Butter (Pasteurised, Salted)",
  "brand": "Amul",
  "category": "Dairy > Butter & Cheese",
  "pack_size": "500 g",
  "gtin": "8901020000123",
  "attributes": { "salted": true, "veg": true },
  "status": "active",
  "source_map": [
    { "source": "blinkit",   "source_product_id": "prod_88213", "confidence": 0.99 },
    { "source": "zepto",     "source_product_id": "z_44120",    "confidence": 0.97 },
    { "source": "bigbasket", "source_product_id": "BB_10029",   "confidence": 0.98 },
    { "source": "amazon",    "source_product_id": "B07XYZ",     "confidence": 0.91 }
  ]
}
Data governance Prices and stock are never authored by us — they are snapshots attributed to a source and timestamped. Catalogue edits are versioned and moderated; auto-created products enter as candidate and are only promoted to active after validation, so a bad auto-match can't silently corrupt the catalogue.

Missing-SKU discovery

QuickCart grows its catalogue from real demand. When a shopper searches for something we can't map, that gap is a signal — the same loop shown in Document 02.

Where gaps come from

🔍

User searches

Queries with zero or low-confidence matches are logged to the missing-SKU queue.

🗂️

Source browse

Scheduled category-listing walks via each platform MCP surface catalogue items we don't yet have.

📈

Trending queries

External trend feeds flag rising products to pre-empt demand.

flowchart LR Q["🔍 Search: zero /
low match"] --> LOG[("missing_sku_queue")] BR["🗂️ MCP category
browse"] --> LOG TR["📈 Trending
queries"] --> LOG LOG --> CL["Cluster &
dedupe"] CL --> EN["Enrich: pull data via
MCP + external"] EN --> SC{"Match
confidence?"} SC -->|"high ≥ 0.9"| PUB["Auto-create
candidate SKU"] SC -->|"low"| HR["Human review
queue"] HR --> PUB PUB --> IDX["Index in
OpenSearch"] IDX --> OK(["Searchable ✓
shopper re-notified"])
Figure 3.4 — The missing-SKU pipeline: capture → cluster → enrich → match → (review) → publish → searchable.

Pipeline health metrics

MetricTargetWhat it tells us
Queue size< 2,000 openBacklog of un-catalogued demand
Auto-match rate≥ 70%Share resolved without a human
Time-to-catalogue (p50)< 24 hSpeed from first miss to searchable
Review precision≥ 98%Correctness of published entries

Analytics

Every meaningful action emits a typed event from the client SDK or a service. Events flow through Kafka into both a real-time layer (dashboards, missing-SKU signal) and the warehouse (funnels, cohorts, KPIs).

Event taxonomy

EventFires whenKey properties
search_performedQuery submittedquery, results_count, zone
offers_viewedCompare screen openedproduct_id, source_count
source_selectedAn offer is pickedproduct_id, source, rank
add_to_cartItem addedproduct_id, price_paise, source
checkout_startedCheckout openedcart_value, item_count
order_placedOrder confirmedorder_id, total, fulfilment_mode
order_deliveredDelivery completeorder_id, actual_eta_min
missing_sku_loggedNo/low matchquery, best_confidence

Headline KPIs

₹4.8 Cr/mo
GMV
9,200
Orders / day
14.2%
Search→order conversion
₹560
Avg basket value

Conversion funnel

Where shoppers drop off between searching and ordering. Bars are one blue ordinal ramp; magnitude is the bar length.

Search 100,000 · 100% Offers viewed 78,000 · 78% Add to cart 34,000 · 34% Checkout 19,000 · 19% Order placed 14,200 · 14.2%
Figure 3.5 — Search-to-order funnel (illustrative weekly volumes). Biggest drop is offers-viewed → add-to-cart.

Source win-rate

How often each platform provides the ranked-best offer at the moment of purchase.

Blinkit Zepto BigBasket Flipkart Amazon
10% 20% 30% 40% 0 34% Blinkit 22% Zepto 12% BigBasket 16% Flipkart 16% Amazon
Figure 3.6 — Share of "best offer" by platform (illustrative). Quick-commerce players win on ETA; marketplaces win on price for long-tail goods.

GMV trend

0 ₹2Cr ₹4Cr ₹6Cr ₹4.8Cr FebMarApr MayJunJul AugSep
Figure 3.7 — Monthly GMV (illustrative). Single-series magnitude, one blue hue.

All figures on this page are illustrative design targets, not measured production data.

Payments

QuickCart supports two fulfilment-and-money models, chosen per source based on what that platform's MCP allows (see Document 04):

💳 Managed checkout

QuickCart collects payment, places the order with the source on the shopper's behalf, and settles to the source (net of commission). One cart can even span multiple sources. Requires a source MCP that exposes create_cart/checkout.

🔀 Assisted hand-off

Where a source only exposes read tools, QuickCart deep-links into that platform with the basket pre-filled; the source collects payment. QuickCart earns via affiliate attribution. No card data touches us.

Payment methods

UPICards (tokenised)Netbanking WalletsCash on delivery

Managed-checkout payment flow (UPI)

sequenceDiagram autonumber participant App as 📱 App participant Ord as Order Svc participant Pay as Payment Svc participant Rzp as Razorpay App->>Ord: POST /v1/orders (Idempotency-Key) Ord->>Pay: createPayment(orderId, amount) Pay->>Rzp: create PSP order Rzp-->>Pay: psp_order_id Pay-->>App: psp_order_id + checkout key App->>Rzp: UPI collect (Razorpay SDK) Rzp-->>App: authorised Rzp->>Pay: webhook payment.captured Pay->>Pay: verify HMAC signature Pay->>Ord: mark PAID Ord->>Ord: place order with source(s) Ord-->>App: confirmed (WebSocket push)
Figure 3.8 — Payment is confirmed by the server-verified webhook, never by the client's word alone.

Refunds, reconciliation & scope

Compliance — keep PCI scope minimal QuickCart never stores raw card numbers. Cards are tokenised by the PSP (SAQ-A scope); webhooks are HMAC-verified; all money movement is logged immutably. UPI and tokenised cards keep the app out of full PCI-DSS scope. Personal data handling follows India's DPDP Act (consent, purpose limitation, deletion on request).

Scaling, reliability & security

Scale

Stateless services autoscale on Kubernetes; Redis absorbs read fan-out; OpenSearch and Postgres use read replicas. Aggregation is I/O-bound and parallel.

Reliability

Circuit breakers + timeouts on every MCP call; partial results are first-class (a down source is simply omitted). Graceful degradation, health checks, blue/green deploys.

Security

JWT + refresh rotation, rate-limiting, WAF, cert pinning, secrets in a vault, least-privilege service roles, audit logs.

Observability

Structured logs, RED/USE metrics, distributed tracing across the gateway→service→MCP path, SLO alerting.

Data protection

Encryption in transit + at rest, PII minimisation, DPDP-aligned consent & deletion, tokenised payments.

Cost control

Aggressive caching (fewer MCP calls), request coalescing, and TTLs tuned per data volatility (price 30s, catalogue hours).

Read next Document 04 — MCP Integrations details exactly which tools each platform exposes and how far we can use them; Document 05 — Price & Availability Engine covers the scatter-gather, normalisation and ranking algorithm behind every offer shown here.