Lead story dev.to 31 minutes ago Fresh today Unread
From a modular monolith to microservices without a rewrite
Most "monolith to microservices" stories go one of two ways. Either it's a rewrite that takes eighteen months and ships a system nobody asked for, or it's a big-bang split into fifteen services that immediately need a Kubernetes team. Both come from the same mistake: treating "process boundaries" and "module boundaries" as the same decision.
They aren't. You can draw the module boundaries now, inside the monolith, and move the process boundaries later — one service at a time, when you have a reason to. This article shows exactly that with a small Express app: three stages, the same service files in every stage, and real output at each step, including the one thing that does break when you finally run two copies of a service.
The starting point
A perfectly ordinary Express monolith. Three modules, wired together with
Nothing wrong with this. It's the right architecture for a team of three with one deployable. The problem only starts when
Stage 1: module boundaries become service boundaries — same process
Bring in a service broker (Moleculer's
Three things changed and they are all improvements you'd want anyway:
The dependency is explicit.
The input is validated at the boundary.
Mailer is no longer called — it's notified.
Express stays. The routes call the broker instead of the modules:
Same behaviour, same single process, same deploy.
This is what "modular monolith" should mean in practice — not a folder convention, but boundaries a runtime knows about.
Stage 2: extract one service — the one that actually needs it
Marketing week:
Two changes, neither of them in service code. First, give the app a transporter (a message broker — NATS here) and tell it which services to host locally:
Second, start
Now hit the same endpoints:
Look at what happened:
That's the whole method. The
Stage 3: split the rest, scale one — and meet the thing that breaks
Let's go all the way:
The good news: round-robin load balancing between the two
The bad news is in the first column. Order IDs 1, 1, 2, 2. Remember
This is the honest part of the article. Turning modules into services is nearly free. Running two copies of one is where the monolith's hidden assumptions surface. Here is the list I check before scaling any service past one instance:
Module-level state — counters, caches, "the current batch",
In-memory cache. Moleculer's default
Anything that assumes ordering across requests. Two instances process concurrently; if order mattered, you were relying on a single event loop.
Transactions across services.
None of these are Moleculer problems, and none of them are avoided by any other framework; they're the actual difference between one process and two. The point of the staged approach is that you hit them one service at a time, with a working system on both sides, instead of all at once on cut-over day.
The migration order that works
If I were doing this to a real codebase:
Stage 1 for everything, no exceptions. Broker in the monolith, every module a service, every cross-module call a
Extract the asynchronous ones first. Mailer, image processing, report generation, webhooks — anything that already receives events rather than answering calls. They have no callers to break and their latency doesn't sit on the request path.
Extract what needs to scale or deploy independently. Usually one or two hot services. Run through the "what breaks" list before starting the second instance.
Leave the rest together. A modular monolith with two extracted services is a legitimate end state, not a half-finished migration. Every boundary you didn't turn into a network hop is one that can't fail at 3 a.m.
And keep
Code in this article was run on Moleculer 0.15.2, Express 5 and Node.js 22, with NATS 2 for stages 2 and 3. The moleculer-examples repository has a
They aren't. You can draw the module boundaries now, inside the monolith, and move the process boundaries later — one service at a time, when you have a reason to. This article shows exactly that with a small Express app: three stages, the same service files in every stage, and real output at each step, including the one thing that does break when you finally run two copies of a service.
The starting point
A perfectly ordinary Express monolith. Three modules, wired together with
require(), one process.// lib/orders.js — calls users and mailer through plain imports. Tight coupling, zero ceremony.
const users = require("./users");
const mailer = require("./mailer");
let counter = 0;
const orders = [];
exports.create = ({ userId, item, amount }) => {
const user = users.get(userId);
const order = { id: ++counter, userId, item, amount };
orders.push(order);
mailer.send(user.email, `Order #${order.id} confirmed (${item})`);
return order;
};
// app.js
const express = require("express");
const users = require("./lib/users");
const orders = require("./lib/orders");
const app = express();
app.use(express.json());
app.get("/users/:id", (req, res) => res.json(users.get(Number(req.params.id))));
app.post("/orders", (req, res) => {
const userId = Number(req.header("x-user-id"));
res.status(201).json(orders.create({ userId, ...req.body }));
});
app.listen(process.env.PORT || 3000);
$ curl -s localhost:3000/users/1
{"id":1,"name":"Ada","email":"ada@example.com"}
$ curl -s -X POST localhost:3000/orders -H 'content-type: application/json' -H 'x-user-id: 2' -d '{"item":"Monitor","amount":329}'
{"id":1,"userId":2,"item":"Monitor","amount":329}
Nothing wrong with this. It's the right architecture for a team of three with one deployable. The problem only starts when
orders grows a queue consumer, mailer needs to scale independently because a marketing campaign sends 200k emails, and you realise nothing in the codebase says which module is allowed to call which.Stage 1: module boundaries become service boundaries — same process
Bring in a service broker (Moleculer's
ServiceBroker — think of it as an in-process service runtime with a registry) and turn each module into a service: a plain object with a name and actions (its callable endpoints). No transporter is configured, so every call is an in-memory function call. No network, no serialization, no new infrastructure.// services/orders.service.js — lib/orders.js as a service.
// No require("./users") any more: the dependency goes through the broker, and is declared.
let counter = 0; // module-level state — fine in a monolith; see "What breaks" below
const orders = [];
module.exports = {
name: "orders",
dependencies: ["users"], // broker waits for `users` before starting this service
actions: {
create: {
params: {
userId: { type: "number", convert: true },
item: "string",
amount: { type: "number", positive: true },
},
async handler(ctx) {
const user = await ctx.call("users.get", { id: ctx.params.userId });
const order = { id: ++counter, userId: user.id, item: ctx.params.item, amount: ctx.params.amount };
orders.push(order);
await ctx.emit("order.created", { order, user });
return { ...order, servedBy: this.broker.nodeID, usersServedBy: user.servedBy };
},
},
},
};
Three things changed and they are all improvements you'd want anyway:
The dependency is explicit.
dependencies: ["users"] is documentation the runtime enforces — orders won't start until users is available.The input is validated at the boundary.
params is a schema; a bad amount is rejected before the handler runs.Mailer is no longer called — it's notified.
orders emits order.created; whoever cares subscribes. That's the seam you'll use later to move it out.
// services/mailer.service.js — lib/mailer.js as a service. Instead of being called, it reacts to an event.
module.exports = {
name: "mailer",
events: {
"order.created"(ctx) {
const { order, user } = ctx.params;
this.logger.info(`→ ${user.email}: Order #${order.id} confirmed (${order.item})`);
},
},
};
Express stays. The routes call the broker instead of the modules:
// app.js — the same Express app, now with a broker inside. Routes call services instead of modules.
const express = require("express");
const { ServiceBroker } = require("moleculer");
const broker = new ServiceBroker({
nodeID: `app-${process.pid}`,
transporter: process.env.TRANSPORTER || null, // null = local bus, in-process only
logger: { type: "Console", options: { level: "info", formatter: "short" } },
});
const wanted = process.env.SERVICES === undefined ? ["users", "orders", "mailer"] : process.env.SERVICES.split(",").filter(Boolean);
for (const name of wanted) broker.createService(require(`./services/${name}.service.js`));
const app = express();
app.use(express.json());
app.get("/users/:id", async (req, res, next) => {
try { res.json(await broker.call("users.get", { id: req.params.id })); } catch (e) { next(e); }
});
app.post("/orders", async (req, res, next) => {
try {
const userId = Number(req.header("x-user-id"));
res.status(201).json(await broker.call("orders.create", { userId, ...req.body }));
} catch (e) { next(e); }
});
app.use((err, req, res, next) => res.status(err.code || 500).json({ error: err.name, message: err.message }));
broker.start().then(() => {
app.listen(process.env.PORT || 3000, () => broker.logger.info(`HTTP on ${process.env.PORT || 3000}`));
});
$ node app.js
[20:06:31.508Z] INFO BROKER: Node ID: app-3470689
[20:06:31.549Z] INFO ORDERS: Waiting for service(s) 'users'...
[20:06:31.557Z] INFO USERS: Service 'users' started.
[20:06:31.557Z] INFO MAILER: Service 'mailer' started.
[20:06:32.552Z] INFO ORDERS: Service(s) 'users' are available.
[20:06:32.554Z] INFO ORDERS: Service 'orders' started.
[20:06:32.554Z] INFO BROKER: ✔ ServiceBroker with 4 service(s) started successfully in 1s.
[20:06:32.557Z] INFO BROKER: HTTP on 3000
$ curl -s localhost:3000/users/1
{"id":1,"name":"Ada","email":"ada@example.com","servedBy":"app-3470689"}
$ curl -s -X POST localhost:3000/orders -H 'content-type: application/json' -H 'x-user-id: 2' -d '{"item":"Monitor","amount":329}'
{"id":1,"userId":2,"item":"Monitor","amount":329,"servedBy":"app-3470689","usersServedBy":"app-3470689"}
# app log:
[20:06:34.088Z] INFO MAILER: → linus@example.com: Order #1 confirmed (Monitor)
Same behaviour, same single process, same deploy.
servedBy and usersServedBy are the same node because everything is local. You could stop here for a year and still be better off than before: the boundaries are real, the contracts are validated, and nobody can sneak a require("../orders/db") across modules any more.This is what "modular monolith" should mean in practice — not a folder convention, but boundaries a runtime knows about.
Stage 2: extract one service — the one that actually needs it
Marketing week:
mailer needs to scale on its own and must not slow down the request path. So move only mailer out.Two changes, neither of them in service code. First, give the app a transporter (a message broker — NATS here) and tell it which services to host locally:
$ TRANSPORTER=nats://localhost:4222 SERVICES=users,orders node app.js
Second, start
mailer in its own process with moleculer-runner, the framework's CLI service host, pointed at the same services/ folder:// moleculer.config.js — for services that run OUTSIDE the app process, via moleculer-runner.
module.exports = {
nodeID: `svc-${process.env.SERVICES || "all"}-${process.pid}`,
transporter: process.env.TRANSPORTER || "nats://localhost:4222",
logger: { type: "Console", options: { level: "info", formatter: "short" } },
};
$ SERVICEDIR=services SERVICES=mailer npx moleculer-runner --config moleculer.config.js
Now hit the same endpoints:
$ curl -s -X POST localhost:3000/orders -H 'content-type: application/json' -H 'x-user-id: 2' -d '{"item":"Monitor","amount":329}'
{"id":1,"userId":2,"item":"Monitor","amount":329,"servedBy":"app-3470296","usersServedBy":"app-3470296"}
# mailer process log:
[20:03:06.188Z] INFO MAILER: → linus@example.com: Order #1 confirmed (Monitor)
Look at what happened:
mailer received the order.created event in another process, over NATS. The orders code that emits it didn't change.orders → users is still app-3470296 → app-3470296: in-process. The app is connected to NATS, but the registry's default preferLocal: true routes a call to a local instance whenever one exists. You extracted one service and paid the network cost for exactly one hop — the one you chose.That's the whole method. The
services/ folder is the same in both processes; which process loads which file is a deployment decision, made per environment with two environment variables. Your dev laptop runs node app.js with everything local; staging runs the hybrid; production splits further. Same code.Stage 3: split the rest, scale one — and meet the thing that breaks
Let's go all the way:
users, orders and mailer each in their own process, two instances of orders because it's the hot path, and the app hosting nothing but HTTP:$ SERVICEDIR=services SERVICES=users npx moleculer-runner --config moleculer.config.js
$ SERVICEDIR=services SERVICES=orders npx moleculer-runner --config moleculer.config.js
$ SERVICEDIR=services SERVICES=orders npx moleculer-runner --config moleculer.config.js
$ SERVICEDIR=services SERVICES=mailer npx moleculer-runner --config moleculer.config.js
$ TRANSPORTER=nats://localhost:4222 SERVICES= node app.js
$ for i in 1 2 3 4; do curl -s -X POST localhost:3000/orders -H 'content-type: application/json' -H 'x-user-id: 1' -d '{"item":"Cable","amount":9}'; echo; done
{"id":1,"userId":1,"item":"Cable","amount":9,"servedBy":"svc-orders-3470323","usersServedBy":"svc-users-3470321"}
{"id":1,"userId":1,"item":"Cable","amount":9,"servedBy":"svc-orders-3470322","usersServedBy":"svc-users-3470321"}
{"id":2,"userId":1,"item":"Cable","amount":9,"servedBy":"svc-orders-3470323","usersServedBy":"svc-users-3470321"}
{"id":2,"userId":1,"item":"Cable","amount":9,"servedBy":"svc-orders-3470322","usersServedBy":"svc-users-3470321"}
The good news: round-robin load balancing between the two
orders instances, calls to users over the wire, no code changes — all of it just works.The bad news is in the first column. Order IDs 1, 1, 2, 2. Remember
let counter = 0 at the top of orders.service.js? Each process has its own. In the monolith it was a perfectly good ID generator; with two instances it's a duplicate-key bug that would have shipped.This is the honest part of the article. Turning modules into services is nearly free. Running two copies of one is where the monolith's hidden assumptions surface. Here is the list I check before scaling any service past one instance:
Module-level state — counters, caches, "the current batch",
Maps of sessions. Every instance gets its own. Move it to the database or a shared store, or make it instance-safe (UUIDs instead of counters; here id: crypto.randomUUID() is the one-line fix).In-memory cache. Moleculer's default
Memory cacher is per node; with several instances you'll serve stale data from one and fresh from another. Switch the cacher to Redis — a broker option, not a code change.Anything that assumes ordering across requests. Two instances process concurrently; if order mattered, you were relying on a single event loop.
Transactions across services.
orders + inventory in one SQL transaction worked because they were one process on one connection. Across processes it's either a saga (compensating actions on failure) or you keep those two in the same service. Both are fine; pretending it's still atomic is not.ctx.meta instead of globals. The request-scoped things you used to keep in req or a module variable (user ID, locale, trace ID) now need to ride along explicitly. Moleculer propagates ctx.meta through every call and event automatically — put them there.None of these are Moleculer problems, and none of them are avoided by any other framework; they're the actual difference between one process and two. The point of the staged approach is that you hit them one service at a time, with a working system on both sides, instead of all at once on cut-over day.
The migration order that works
If I were doing this to a real codebase:
Stage 1 for everything, no exceptions. Broker in the monolith, every module a service, every cross-module call a
ctx.call, every fire-and-forget a ctx.emit. Ship it. It is a refactor with no infrastructure change, and it's where you discover which modules actually depend on which.Extract the asynchronous ones first. Mailer, image processing, report generation, webhooks — anything that already receives events rather than answering calls. They have no callers to break and their latency doesn't sit on the request path.
Extract what needs to scale or deploy independently. Usually one or two hot services. Run through the "what breaks" list before starting the second instance.
Leave the rest together. A modular monolith with two extracted services is a legitimate end state, not a half-finished migration. Every boundary you didn't turn into a network hop is one that can't fail at 3 a.m.
And keep
node app.js — the everything-local mode — working forever. It's how a new developer runs the whole system on a laptop with no Docker, and it's how you run integration tests without a message broker. The local bus is a feature, not a stepping stone.Code in this article was run on Moleculer 0.15.2, Express 5 and Node.js 22, with NATS 2 for stages 2 and 3. The moleculer-examples repository has a
run.sh that reproduces all three stages — including the duplicate-ID bug — in one go.Sep 22, 2026, 1:00 PMSignal 5