Blog Post
From Monolith to Modular: How to Refactor a MERN App Without Breaking Production

Don’t “rewrite” your MERN monolith. Refactor by slices using a strangler approach: carve out one module at a time behind stable APIs.
- Define boundaries + contracts first (routes, events, data ownership), then move code. Most refactors fail because boundaries are vague.
- Make it safe with feature flags, shadow reads, idempotent writes, and observability (correlation IDs + dashboards).
- Keep revenue flows boring: checkout, payments, webhooks, inventory should be the last things you touch, unless they’re already on fire.
The real problem with a MERN monolith (it’s not “code style”)
A monolith becomes expensive when:
- One small change breaks three unrelated flows (cart → checkout → admin → emails).
- Deploys feel risky, so releases slow down.
- Performance issues are hard to isolate because everything shares the same runtime, DB queries, and caching.
- New devs can’t find “where things live”, so they ship quick fixes instead of clean fixes.
In ecommerce/fintech-style apps, this gets worse because you have money + state + webhooks + retries. That combination punishes messy architecture.
My refactor rule: modularize by business capability, not by folder
If you split by technical layers too early (controllers/services/utils), you still have a monolith, just with nicer folders.
Instead, split by capabilities:
- Catalog (products, variants, pricing)
- Cart
- Checkout + Payments
- Orders
- Fulfillment/Shipping
- Inventory
- Customers/Auth
- Admin/Ops
- Integrations (Stripe, PayPal, courier APIs, ERPs)
Each module should answer:
- What data does it own?
- What APIs does it expose?
- What events does it emit/consume?
Step 0, Stabilize production before touching architecture
Refactoring without stability is how you create “modern” outages.
Minimum baseline I install first:
- Error tracking: Sentry (frontend + backend)
- Structured logs: pino/winston with request IDs
- A slow query view: MongoDB profiler or APM
- Basic dashboards:
- checkout attempts
- payment failures by code
- webhook failures
- order creation latency
Add a correlation ID early:
import { randomUUID } from 'crypto';
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] || randomUUID();
res.setHeader('x-request-id', req.id);
next();
});
This single move makes refactoring safer because every incident becomes traceable.
Step 1, Draw the boundary map (before you move a single line)
I do a 60–90 minute “boundary workshop” with the team and produce:
- Route inventory: every endpoint grouped by capability
- Data ownership: which collection belongs to which module
- Critical flows: checkout, refunds, webhooks, inventory, payouts
- Integration points: payment gateways, courier APIs, ERP exports
Deliverable: a one-page doc + a module skeleton.
Step 2, Create a modular skeleton inside the monolith
You can modularize inside the same repo first. You don’t need microservices to get modular benefits.
A practical folder structure:
src/
modules/
cart/
cart.routes.js
cart.service.js
cart.repo.js
cart.validators.js
orders/
orders.routes.js
orders.service.js
orders.repo.js
payments/
payments.routes.js
stripe.webhooks.js
shared/
db/
logger/
http/
app.js
Then wire modules into Express cleanly:
// src/app.js
import express from 'express';
import { cartRouter } from './modules/cart/cart.routes.js';
import { ordersRouter } from './modules/orders/orders.routes.js';
const app = express();
app.use(express.json());
app.use('/api/cart', cartRouter);
app.use('/api/orders', ordersRouter);
export default app;
This looks “simple”, but it’s a big deal: module code stops importing random files everywhere.
Step 3, Move one slice at a time using the Strangler pattern
Pick a slice that is:
- High pain (changes frequently)
- Low risk (not the core money flow)
- Easy to validate
Good first slices in ecommerce:
- Catalog read APIs
- Search/filter endpoints
- Admin listings
Avoid first: checkout + payments + refunds.
The strangler technique in MERN
You keep the old code alive while you route some traffic to the new module.
Example with a feature flag:
app.get('/api/products/:id', async (req, res) => {
const useNew = process.env.FEATURE_PRODUCTS_V2 === 'true';
if (useNew) return productsV2Handler(req, res);
return productsLegacyHandler(req, res);
});
Now you can:
- test V2 in staging
- enable V2 for internal users
- roll out gradually
- instantly rollback
Step 4, Untangle data dependencies (this is where refactors really live)
Most “modularization” fails because modules share collections in messy ways.
My approach:
1) Define “owned” collections
Example:
cartsowned by Cartordersowned by Orderspaymentsowned by Payments
2) Use references not shared write access
If Orders needs customer info, store a snapshot:
- customerId
- shippingAddress snapshot
Why snapshots matter:
- customer profile can change later
- your order history must remain consistent
3) Add migration-safe fields (don’t do big-bang migrations)
Instead of “rewrite every document”, do:
- add new fields
- write both for a while
- read preferred with fallback
- backfill in background
A safe read pattern:
const tax = doc.taxV2 ?? doc.tax ?? 0;
Step 5, Introduce internal contracts: events (even if you stay monolithic)
If you want modules to stay decoupled, use events internally.
For example:
order.paidorder.cancelledinventory.reservedrefund.created
You can implement this with:
- a simple in-process event emitter initially
- then graduate to a queue (BullMQ / RabbitMQ) when you need reliability
BullMQ example:
import { Queue } from 'bullmq';
export const eventsQueue = new Queue('events', {
connection: { host: process.env.REDIS_HOST, port: 6379 },
});
export async function emitEvent(type, payload) {
await eventsQueue.add(type, payload, {
attempts: 8,
backoff: { type: 'exponential', delay: 2000 },
});
}
This is the bridge from “modular monolith” → “service-ready” without forcing microservices today.
Step 6, Protect checkout and payments with reliability patterns
When you eventually refactor payments, these are non-negotiable:
- Idempotency keys for order finalization
- Webhook signature verification
- Event deduplication (unique index on gateway event IDs)
- Queue-based retries for finalization
- Reconciliation job: payments ↔ orders ↔ fulfillment
Because in fintech-style flows, it’s not enough to be “correct most of the time”.
Step 7, Rollout strategy that avoids breaking production
Here’s the rollout playbook I use:
- Shadow mode: run new code path, but don’t return it (compare outputs in logs)
- Canary: enable for staff/admin only
- Percentage rollout: 5% → 25% → 50% → 100%
- Rollback plan: one env var / flag flip
If you can’t rollback in 60 seconds, the refactor is too risky.
What “done” looks like (practical success criteria)
You’re winning when:
- Each module has a clear owner and minimal imports across boundaries
- A change in Cart doesn’t require reading Orders code
- Checkout incidents are traceable end-to-end with request IDs
- Deploys are boring again
- You can onboard a new dev in days, not weeks
Closing note (how I help)
If you’re sitting on a revenue-critical MERN monolith and every release feels risky, I can run a modularization + stabilization sprint:
- map boundaries + critical flows
- carve out the first safe modules
- add observability so refactors don’t create mystery outages
If you share your stack and the top 3 pain points (slow releases, bugs, scaling, payments), I’ll tell you the first slice I’d refactor, and why.