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.
(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
mcp-adaptor reaches external platforms.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
Key API surface
| Method & path | Purpose | Notes |
|---|---|---|
POST /v1/auth/otp · /verify | Passwordless login | Issues JWT access + refresh |
GET /v1/search?q= | Search products | Returns canonical products + best offer |
GET /v1/products/:id/offers | All source offers for one product | Ranked; cached in Redis (see Doc 05) |
POST /v1/cart · PATCH /v1/cart | Cart management | Server-authoritative cart |
POST /v1/orders | Place an order | Requires Idempotency-Key header |
GET /v1/orders/:id | Order detail | Snapshot state |
WS /v1/orders/:id/track | Live tracking | Status + courier position stream |
An auth-guarded route
services/gateway/routes/offers.tsimport { 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" });
}
}
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
| Layer | Technology | Why |
|---|---|---|
| Mobile client | React Native + TypeScript | One codebase, native performance, OTA updates |
| API / services | Node.js 20, NestJS, TypeScript | Async I/O suits high-fan-out aggregation; strong typing |
| Transactional DB | PostgreSQL 16 | ACID for orders/payments, JSONB for flexible attributes |
| Cache / hot data | Redis 7 | Sub-ms price/stock cache, rate-limit counters, sessions |
| Search | OpenSearch | Typo-tolerant, synonym-aware product search |
| Object storage | S3 + CDN | Product images, served at the edge |
| Event bus | Kafka | Decouples analytics, search indexing, missing-SKU capture |
| Warehouse | ClickHouse / BigQuery | Funnels, KPIs, cohort analysis |
| Infra | Kubernetes, containers | Horizontal scale, rolling deploys, autoscaling |
| Payments | Razorpay (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.
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
| Field | Meaning |
|---|---|
canonical_sku | Stable internal identifier, e.g. QC-GRO-AMUL-BUTTER-500G |
title, brand, category | Normalised display + taxonomy |
pack_size / unit | Normalised pack (e.g. 500 g) so "butter 500g" matches across sources |
gtin / ean | Barcode — 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 |
status | active 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-size →
embedding similarity on title+image. High-confidence matches auto-link; the rest go to review.
{
"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 }
]
}
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.
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"])
Pipeline health metrics
| Metric | Target | What it tells us |
|---|---|---|
| Queue size | < 2,000 open | Backlog of un-catalogued demand |
| Auto-match rate | ≥ 70% | Share resolved without a human |
| Time-to-catalogue (p50) | < 24 h | Speed 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
| Event | Fires when | Key properties |
|---|---|---|
search_performed | Query submitted | query, results_count, zone |
offers_viewed | Compare screen opened | product_id, source_count |
source_selected | An offer is picked | product_id, source, rank |
add_to_cart | Item added | product_id, price_paise, source |
checkout_started | Checkout opened | cart_value, item_count |
order_placed | Order confirmed | order_id, total, fulfilment_mode |
order_delivered | Delivery complete | order_id, actual_eta_min |
missing_sku_logged | No/low match | query, best_confidence |
Headline KPIs
Conversion funnel
Where shoppers drop off between searching and ordering. Bars are one blue ordinal ramp; magnitude is the bar length.
Source win-rate
How often each platform provides the ranked-best offer at the moment of purchase.
GMV trend
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
Managed-checkout payment flow (UPI)
Refunds, reconciliation & scope
- Refunds/cancellations: if a source rejects or an item is out of stock at pick time, the order is auto-cancelled and refunded through the PSP; partial refunds for partial fulfilment.
- Reconciliation: a daily job matches PSP settlements against orders and source invoices; mismatches raise a finance alert.
- Idempotency: both order placement and webhook handling are idempotent (keyed by
Idempotency-Key/ PSP event id) so retries never double-charge or double-fulfil.
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).