Your orders module imports charge from billing and getProduct from catalog, so changing any
of the three means testing and deploying all of them. You move billing into its own process, and
the first order after the move throws no orders row ord_1. Billing was also writing the orders
table, which its own process does not have.
What you get
You will end up with three Seneca services behind the monolith’s existing exports, one in its own process, and the original test suite passing unchanged against each stage. This is for you if a Node monolith has module boundaries in its folders and nowhere else.
Short answer
Put each module behind a Seneca message pattern and keep the module’s existing exports as a facade
that sends those messages, so callers and their tests do not change. Run all three services in
one process first, then move one out with seneca.listen and seneca.client. Before the cut,
list which module writes each table: a table with two writers breaks once the services stop
sharing a database.
You will need
Node 22 or later, a monolith with at least three modules you can name, and a test suite that
exercises them through their exports. The sample is a shop with catalog, orders and billing
folders over one in-memory database. Running services locally is covered in the prerequisite page
on running a service system on your machine, which is not published yet.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Moleculer services | You want named actions, parameter validation and built-in error classes that survive a remote hop | A service broker and its schema format, and every call site rewritten to broker.call with a dotted name | You want to route on message content rather than on a service and action name |
| NestJS microservice modules | The codebase is already NestJS, with its modules, decorators and dependency injection | Decorators, a transport even for one process, and replies wrapped in an RxJS Observable | The monolith is plain Node, where adopting the framework is a second migration |
| Seneca actions | Callers should send data and let the pattern pick the handler, in process or over a transport | Untyped messages, one handler per pattern you have to document, and remote errors you shape yourself | You want the framework to define your error types and validate parameters for you |
Voxgig maintains Seneca. This page compares it with NestJS microservice modules and Moleculer services.
All three move a function call behind a name. Moleculer keeps the closest thing to a function: a dotted action name, validated parameters, and an error class that is recreated on the far side. NestJS needs the most ceremony, and its errors arrive at the caller as plain objects. Seneca asks the least of a plain Node codebase and leaves the reply shape, including failures, entirely to you.
List who writes each table before you cut
A service boundary is a data boundary. Each table needs exactly one module that writes it, or two services will share a database for as long as they both exist. Find out before moving anything, by recording the module behind every write while the existing code runs.
export function traceWriters() {
const writers = new Map()
const onWrite = (table) => {
Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 50)
const frame = new Error().stack.split('\n').find((line) => MODULE.test(line))
const module = frame ? MODULE.exec(frame)[2] : 'unknown'
if (!writers.has(table)) writers.set(table, new Set())
writers.get(table).add(module)
}
return { writers, onWrite }
}
node writers.mjs monolith
monolith: the modules that write each table
invoices billing
orders billing, orders 2 writers, fix before the cut
products catalog
Billing marks the order paid. That made sense while the code was organized by checkout step, which is a code layer.1 Once orders and billing own separate data, only orders may write its table.
const invoice = await db.insert('invoices', { order: orderId, customer, amount })
// Billing finishes the checkout step, so it marks the order paid. The orders table
// belongs to the orders module, and this line is the second writer.
await db.update('orders', orderId, { status: 'paid', invoice: invoice.id })
Cut there anyway and the failure waits for the move. The same plugin runs twice in pitfall.mjs: once
sharing the orders database, then behind HTTP with a database of its own.
node pitfall.mjs
billing sharing the orders database
place order: ok
order ord_1 in the orders database: status paid
invoices in the billing database: 1
billing moved out with its own database
place order threw: no orders row ord_1
order ord_1 in the orders database: status new
invoices in the billing database: 1
The customer was invoiced, but the order still reads new. Fix the ownership first: billing
returns the invoice, and orders writes its own row.
Put each module behind a message pattern
Each module becomes a Seneca plugin that registers its functions as messages under one
fix prefix. Orders sends messages to the other two and imports
neither of them.
.message('place:order', async function place_order(msg) {
const found = await this.post('sys:catalog,get:product', { id: msg.sku })
if (!found.ok) return found
const reserved = await this.post('sys:catalog,reserve:stock', { id: msg.sku, qty: msg.qty })
if (!reserved.ok) return reserved
const total = found.product.price * msg.qty
const order = await store.insert('orders', { sku: msg.sku, qty: msg.qty, customer: msg.customer, total, status: 'new' })
const charged = await this.post('sys:billing,charge:order', { order: order.id, customer: msg.customer, amount: total })
if (!charged.ok) return charged
// Billing answers with the invoice; the orders module writes its own row.
return { ok: true, order: await store.update('orders', order.id, { status: 'paid', invoice: charged.invoice.id }) }
})
A refusal is data in the reply, { ok: false, why }, rather than a thrown error. That keeps the
contract the same whether billing runs in this process or another.
Keep the existing exports as a facade
Callers import shop.mjs. Point it at a facade that exposes the same four functions, each sending a
message, and turns ok: false back into the ShopError the callers already catch.
const send = async (pattern, msg, field) => {
let reply
try {
reply = await seneca.post(pattern, msg)
} catch (err) {
// A refused connection or a timeout is not a ShopError, and callers only catch
// those. Keep the transport error as the cause, for the logs.
throw new ShopError('unavailable', `${pattern}: ${err.code ?? err.message}`, { cause: err })
}
if (!reply.ok) throw new ShopError(reply.why ?? 'unknown', reply.detail)
return reply[field]
}
Move one service out of the process
The intermediate state loads all three plugins into one Seneca instance. Moving billing out is a
change to the facade alone: start billing in a child process that calls listen, and replace
use(billing) with a client for the sys:billing pin. The
getting started guide describes the pair: matching
listen and client parameters are all two services need. The child listens on port 0 and
sends the parent the port its listen callback reports, so no other process can take the port
between the choice and the bind.
node split.mjs
stage 1: three services in one process
order ord_1 paid, total 9000, invoice inv_1
billing ran in this process: yes
invoices in this process's store: 1
stage 2: billing in its own process, over HTTP
order ord_1 paid, total 9000, invoice inv_1
billing ran in this process: no
invoices in this process's store: 0
a declined card, as the caller sees it
monolith ShopError payment_declined, frames: charge < placeOrder
services ShopError payment_declined, frames: send
The last two lines are the price. The monolith’s stack trace ran from charge back through
placeOrder; the facade’s starts at send, because the failure crossed the boundary as data.
The types go the same way. Seneca 3.38.0’s declaration file types the instance as any, so every
reply is any until the facade says otherwise.2
The other two frameworks change the call site differently. compare/run.mjs runs the same billing
call in each, with a card billing refuses.
node compare/run.mjs
framework charged a declined card reaches the call site as
moleculer inv_1 thrown MoleculerClientError, code 402, type PAYMENT_DECLINED
nestjs inv_1 thrown plain object {"code":"payment_declined"}, not an Error
seneca inv_1 a reply, {"ok":false,"why":"payment_declined"}
The Moleculer call site stays closest to a function call. The sample’s broker is local. Across a
transporter, the broker rebuilds its built-in error classes on the calling node, and a custom class
needs a regenerator of your own, as the errors page
says.
NestJS returns an Observable from send, so the call gains firstValueFrom. An
RpcException payload arrives as the
object you passed in. Its class and its stack trace stay behind in the billing service.
Check it worked
Run the monolith’s own suite, unchanged, against all three stages. SHOP_IMPL picks which one
shop.mjs loads.
for stage in monolith services remote; do echo "$stage"; SHOP_IMPL=$stage node --test test/shop.test.mjs | grep -E '^# (pass|fail)'; done
monolith
# pass 5
# fail 0
services
# pass 5
# fail 0
remote
# pass 5
# fail 0
Then hold the boundaries with one dependency-cruiser
rule. The $1 and $2 in the target path are the groups captured from the importer’s path, which
the rules reference
calls group matching.
from: { path: '^(monolith|services)/(catalog|orders|billing)/' },
to: { path: '^$1/(catalog|orders|billing)/', pathNot: '^$1/$2/' },
npx depcruise monolith --config .dependency-cruiser.cjs
error no-cross-module-import: monolith/orders/index.mjs → monolith/catalog/index.mjs
error no-cross-module-import: monolith/orders/index.mjs → monolith/billing/index.mjs
x 2 dependency violations (2 errors, 0 warnings). 7 modules, 12 dependencies cruised.
npx depcruise services --config .dependency-cruiser.cjs
✔ no dependency violations found (9 modules, 10 dependencies cruised)
test/split.test.mjs asserts both counts, one writer per table after the split, and that a
killed billing process reaches the caller as ShopError with code unavailable. One more test
rebinds every port the test process releases, as a busy CI runner might, and checks that billing
still starts. A facade that picked a free port and passed it to the child failed that test. The
child reported ready on a port it never bound, so the facade sent billing’s messages to the
process holding it.
When it goes wrong
A caller gets ECONNREFUSED instead of a ShopError. The billing process is down and the facade
let the transport error through. Catch it in send, as the facade does, and keep it as the error’s
cause so the log still names the socket.
A call hangs for twenty-two seconds and then fails. Billing accepted the connection and never
replied, and Seneca 3.38.0 sets timeout: 22222 in the defaults in its
seneca.ts. Set timeout on the instance to what
the caller can afford.
The writers list shows unknown. The write happened in code outside the three module folders,
such as a migration or a shared helper, so no frame matched. Move the write into the module that
owns the table. The list also sees only the paths your scenario runs, so drive it with the full
test suite before trusting it.
When not to do this
Do not split a monolith whose modules share a transaction. A charge and a status update inside one
database transaction become two messages that can fail apart. After a declined card or a killed
billing process the order stays new with its stock reserved, because nothing undoes the steps
before the charge.
Do not adopt Seneca to get a facade. The facade is the part that keeps callers and tests still, and it works the same in front of Moleculer or a plain function. Seneca earns its place when routing on message content matters, or when you want one service to move between a process and a transport without its callers noticing.
Do not cut along a layer of code, such as handlers, logic and storage, or a step of a workflow. The writers list is the test: if a table has two writers, the boundary is in the wrong place.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, seneca 3.38.0, seneca-promisify 3.7.2,
dependency-cruiser 18.6.0, Moleculer 0.15.2 and @nestjs/microservices 12.1.3 with @nestjs/core and
@nestjs/common 12.1.3. Every output block is what the command preceding it printed, run in the
page’s code directory after npm ci. The NestJS claims were checked against the documentation’s
source files, because the documentation site renders in the browser.
Footnotes
-
The argument is from 1972. David Parnas divided the same program two ways, by the steps of its flowchart and by the decisions each module hides. He concluded in Communications of the ACM that it is almost always incorrect to begin the decomposition of a system into modules on the basis of a flowchart. The checkout step that marks an order paid is a flowchart box. The paper’s example is a keyword index that, it says, a good programmer could produce within a week or two. A large system being impractical to treat thoroughly, the paper treats the index as if it were one. ↩︎ Back to text
-
The declaration,
seneca.d.ts, opens, after two imports, with one function that takes two optionalanyarguments and returnsany. Every message, reply and plugin the framework handles passes through that signature, so the type checker accepts every call to every pattern without comparing it with anything. ↩︎ Back to text