23 MongoDB collections, Redis read/write separation, query optimization, write amplification, and connection pooling.
A hyperlocal commerce platform needs to model many independent domain entities. Here's the complete schema map:
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.
// 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'
});
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.
// 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
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 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.
// 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.
// 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)
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.
// 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 }));
// 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
});
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.