Munchies Jun 2023 – Feb 2026
Breaking an Express monolith into six NestJS services
Munchies' backend started as a single Express app. I led the move to six NestJS microservices on Kubernetes, connected through RabbitMQ, BullMQ and Redis, and migrated live with a blue-green cutover and a seven-day data backfill.
- Architecture
- 1 Express app6 NestJS services
- Orders in peak hours
- Up to 10,000
- Load-tested capacity
- 100,000 orders
- Platform performance
- +40%
- Data migration
- 7 days, no big-bang cutover
- NestJS
- TypeScript
- Kubernetes
- RabbitMQ
- BullMQ
- Redis
- PostgreSQL
The problem
Munchies is a late-night food delivery service across Dhaka, serving 100+ restaurant partners and 10,000+ orders a month. The backend was one Express application. In a monolith, every part of the business shares one process and one deploy: a slow geo lookup or a payment provider timing out takes capacity from everything else, and a change to rider logic means redeploying ordering too. Late-night delivery is peaky by nature, so the whole app had to be sized for its busiest feature.
The limit we hit was cost. A monolith only scales one way: run more copies of all of it. To give ordering more capacity at peak, we paid to replicate auth, menus and everything else that wasn't under load. The fix for the bill and the fix for the coupling were the same: separate the parts so each can scale, deploy and fail on its own.
Drawing the boundaries
I split the backend into six NestJS services, each owning one part of the business:
- Auth: identity and sessions for customers, restaurants and riders.
- Restaurants: partners, menus and availability.
- Orders: the order lifecycle from placed to delivered.
- Payments: charging, confirming and refunding.
- Riders: rider status and assignment.
- Geo: locations, distances and delivery areas.
The boundaries follow how the business changes and how it fails. Payments and geo depend on outside systems and have their own failure modes, so isolating them stops a slow provider from stalling order taking. Orders and riders carry the late-night peaks, so they can scale out on Kubernetes without scaling everything else. Moving from Express to NestJS gave every service the same structure (modules, dependency injection, built-in microservice transports), which kept six codebases consistent for a team of four.
How the services talk
Services don't call each other in long synchronous chains. They communicate through messaging, using RabbitMQ, BullMQ and Redis, so a service that is slow or briefly down delays work instead of failing the customer's request.
- RabbitMQ carries events between services: an order is placed, a payment is confirmed, a rider is assigned.
- BullMQ runs background jobs and retries inside a service, so slow or flaky work happens off the request path.
- Redis handles caching and fast lookups that would otherwise hit PostgreSQL on every request.
The hardest part: orders and stock
The order event was the most expensive one in the system, and the one that went wrong. Message brokers deliver at least once: a consumer that crashes or times out gets the same message again. For most events that's harmless. For an order it decremented stock a second time, and inventory counts drifted from reality. A stock count you can't trust breaks ordering both ways: sold-out items still get ordered, and items still available show as sold out.
I fixed it with three pieces that only work together:
- A key per operation. Every order event carries a unique key, so the consumer can tell a redelivery from a new order.
- One transaction with idempotency. Recording that key and changing stock happen in the same database transaction. If the key is already there, the work has been done and the duplicate becomes a no-op.
- A journal instead of a counter. Stock stopped being a single number that gets overwritten. Every change is appended as an entry, and the current stock is the sum of the entries. Nothing is lost to a race, every change can be traced to the order behind it, and a wrong count can be explained, not just corrected.
// Simplified sketch of the pattern, not production code.
async function onOrderPlaced(event: OrderPlaced) {
await db.transaction(async (tx) => {
// The unique key makes a redelivered event a no-op.
const fresh = await tx.processedEvents.insertIfAbsent(event.key);
if (!fresh) return;
// Stock is a journal: append a movement, never overwrite a count.
for (const item of event.items) {
await tx.stockJournal.append({
itemId: item.id,
change: -item.quantity,
reason: "order",
orderId: event.orderId,
});
}
});
}Migrating without stopping orders
A food delivery business can't take an evening off for a migration, so the move was built around being reversible. We ran the new services as a separate green environment next to the old blue one and switched traffic over with a blue-green deployment. If the new system misbehaved, traffic could go straight back to the old one.
The data moved with a backfill over seven days: existing records were copied into the new services' data stores while the old system kept serving, instead of one risky big-bang export and import. Spreading it over a week kept migration load off the live system and left time to verify the copied data before traffic switched.
The result
Load testing put the new platform's ceiling at 100,000 orders, and in production it handled peak hours of up to 10,000 orders. Together with service-level caching, query optimisation and load balancing, platform performance improved by 40%, and the cost of scaling now followed the parts under load, not the whole application.
- Peak hours, productionPeak hours, production: up to 10,000up to 10,000
- Load-tested ceilingLoad-tested ceiling: 100,000100,000
Two different measurements: real traffic across peak hours, and the most the platform handled under load testing. The gap between them is headroom, about ten times.
- BeforeBefore: 100100
- AfterAfter: 140 (+40%)140 (+40%)
Indexed so the gain reads at a glance: +40% after caching, query optimisation and load balancing.
What I'd do differently
Six services is a lot to run for a team of four. The split paid off where load and failure modes really differed, around orders, payments and geo. If I did it again I'd keep the smaller, stable domains together longer and pull them out only when they earned it: every extra service adds a deploy, a dashboard and a failure mode someone has to own at 2 a.m.