The 23 MongoDB Collections

A hyperlocal commerce platform needs to model many independent domain entities. Here's the complete schema map:

users
stores
products
orders
orderItems
carts
riders
zones
addresses
payments
webhooks
sessions
auditLogs
notifications
settings
inventory
catalogs
categories
reviews
analytics
rateLimits
tokens
jobs
Interview Q&A

Q: Why so many collections? Why not embed more?

A: MongoDB embedding works well when the embedded data is always accessed with the parent (e.g., order items always fetched with their order). But separate collections make sense when: (1) the child documents grow unboundedly (audit logs), (2) you need to query the child independently (payments, riders), (3) the child has its own lifecycle (notifications). The rule: embed if always accessed together and bounded size; reference if queried independently or unbounded.

Key schema decisions: embed vs reference

// EMBEDDED: orderItems inside order
// Rationale: always fetched together, bounded (max 50 items per order)
const OrderSchema = new Schema({
  tenantId: ObjectId,
  customerId: ObjectId,
  storeId: ObjectId,
  status: String,
  items: [{ // EMBEDDED
    productId: ObjectId,
    name: String,       // denormalized — product name at order time
    price: Number,      // denormalized — price at order time
    quantity: Number,
  }],
  totalAmount: Number,
  __v: Number,          // optimistic locking version
});

// REFERENCED: payments separate from order
// Rationale: queried independently for reconciliation, has own lifecycle
const PaymentSchema = new Schema({
  tenantId: ObjectId,
  orderId: { type: ObjectId, ref: 'Order' }, // reference
  gatewayTxnId: String,
  idempotencyKey: String, // UUID — prevents double-processing
  status: String,
  amount: Number,
  gateway: String, // 'juspay' | 'phonepe' | 'razorpay'
});
Interview Q&A

Q: Why denormalize product name/price inside orderItems?

A: If we stored only productId and the product price changes later, historical orders would show the wrong price. Denormalizing captures the price at the time of order. This is standard e-commerce practice — think of it as a receipt, not a live reference. The tradeoff is slightly larger order documents, but the data integrity benefit is worth it.

Read/Write Separation

MongoDB replica set architecture

MongoDB Replica Set (3 nodes): ┌──────────────┐ │ PRIMARY │ ◀── all writes go here │ (r/w) │ replicates to secondaries └──────┬───────┘ │ replication (oplog streaming) ├──────────────────────┐ ▼ ▼ ┌──────────────┐ ┌──────────────┐ │ SECONDARY 1 │ │ SECONDARY 2 │ │ (read-only) │ │ (read-only) │ └──────────────┘ └──────────────┘ ▲ ▲ │ │ Read requests (catalog, store listings, analytics queries) use readPreference: 'secondaryPreferred'
// Mongoose read preference per query
// Reads: go to secondary (reduces primary load)
const stores = await Store
  .find({ tenantId, isActive: true })
  .read('secondaryPreferred');

// Writes and order status reads: always primary (strong consistency)
const order = await Order
  .findOneAndUpdate(filter, update, { new: true })
  .read('primary'); // default, explicit for clarity
Interview Q&A

Q: What's the replication lag risk with secondary reads?

A: MongoDB replication lag is typically 10-500ms. For store catalog reads, this is acceptable — if a price updates and a user sees the old price for half a second, that's fine. For order status reads (what's my order doing?), we always read from primary to guarantee the latest state. The rule: eventual consistency is okay for catalog/discovery, strong consistency required for financial and order state data.

Write Amplification & Redis

What write amplification is

Write amplification happens when a single logical write triggers multiple physical writes. In a commerce context: a rider updating their location every 5 seconds writes to riders, triggers a read of all nearby active orders, writes a notification document, pushes to WebSocket — one user action = 4+ DB operations.

How Redis solves it

// WITHOUT Redis: every product page load hits MongoDB
// At 1000 concurrent users browsing: 1000+ DB reads/second
app.get('/stores/:storeId/products', async (req, res) => {
  const products = await Product.find({ storeId, isActive: true }); // always MongoDB
  res.json(products);
});

// WITH Redis cache-aside:
app.get('/stores/:storeId/products', async (req, res) => {
  const cacheKey = `catalog:${storeId}`;

  // 1. Check Redis first (sub-ms)
  const cached = await redis.get(cacheKey);
  if (cached) return res.json(JSON.parse(cached));

  // 2. Cache miss: fetch from MongoDB
  const products = await Product.find({ storeId, isActive: true });

  // 3. Write to cache (TTL 5 min)
  await redis.setex(cacheKey, 300, JSON.stringify(products));

  res.json(products);
});

// On product update: invalidate cache
async function updateProduct(productId, data) {
  const product = await Product.findByIdAndUpdate(productId, data, { new: true });
  await redis.del(`catalog:${product.storeId}`); // invalidate
  return product;
}

Result: 80-90% of product catalog reads served from Redis (~0.1ms) instead of MongoDB (~5-20ms). MongoDB write load drops proportionally.

MongoDB Index Strategy

// Compound indexes — field order matters (ESR rule: Equality, Sort, Range)
// Query: orders by store + status filter + date sort
Order.createIndex({ storeId: 1, status: 1, createdAt: -1 });

// Partial index — only index active stores (reduces index size by ~70%)
Store.createIndex(
  { tenantId: 1, location: '2dsphere' },
  { partialFilterExpression: { isActive: true } }
);

// TTL index — auto-delete expired sessions after 30 days
Session.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
// MongoDB checks TTL indexes every 60s and deletes expired docs

// TTL for rate limit keys
RateLimit.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 });

// Unique index — prevent duplicate idempotency keys
Payment.createIndex({ idempotencyKey: 1 }, { unique: true });
// Attempting to insert duplicate key → MongoDB throws E11000 → caught → 200 (dedup)
Interview Q&A

Q: What is the ESR rule for compound indexes?

A: ESR = Equality, Sort, Range. Put equality fields first (exact match: storeId = "abc"), then sort fields (createdAt: -1), then range fields (amount: {">"} 100). This ordering lets MongoDB use the index for all three operations in one pass. Wrong order = index works for fewer operations or not at all.

Redis Patterns

Patterns used beyond caching

// 1. SETNX for distributed locks (prevent double payment processing)
const lockKey = `lock:payment:${orderId}`;
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 30); // 30s TTL
if (!acquired) throw new ConflictError('Payment already processing');
try {
  await processPayment(orderId);
} finally {
  await redis.del(lockKey); // always release
}

// 2. Sorted set for rider leaderboard / analytics
// ZADD adds (score=totalDeliveries, member=riderId)
await redis.zadd('rider:leaderboard', deliveries, riderId);
// Top 10 riders
const top10 = await redis.zrevrange('rider:leaderboard', 0, 9, 'WITHSCORES');

// 3. Pub/Sub for WebSocket fan-out (covered in doc 05)
redis.publish('order:updates', JSON.stringify({ orderId, status, tenantId }));

Connection Pooling

// Mongoose pool: shared across all requests in the Node.js process
// Default pool size = 5, increase for high concurrency
mongoose.connect(MONGO_URI, {
  maxPoolSize: 20,       // max open connections to MongoDB
  minPoolSize: 5,        // keep this many warm (avoids cold connect)
  connectTimeoutMS: 10000,
  serverSelectionTimeoutMS: 5000,
  socketTimeoutMS: 45000,
  // heartbeatFrequencyMS: 10000 — ping MongoDB every 10s to keep alive
});

// ioredis pool (ioredis manages one connection per client instance)
// For Redis cluster, use ioredis.Cluster
const redis = new Redis({
  host: process.env.REDIS_HOST,
  port: 6379,
  maxRetriesPerRequest: 3,
  enableReadyCheck: true,
  lazyConnect: false, // connect immediately on startup
});
Interview Q&A

Q: What happens if the MongoDB pool is exhausted?

A: New requests queue up waiting for a connection to become available. If they wait longer than serverSelectionTimeoutMS (5s), they throw a timeout error which becomes a 503 response. To avoid this: (1) increase pool size, (2) optimize slow queries so connections return faster, (3) add a circuit breaker to shed load gracefully instead of queuing indefinitely.