How-to › Run message-based services

How to split a monolith into message-based services with Seneca#

Put each module of a Node monolith behind a Seneca message pattern, keep its exports as a facade so callers and tests stay put, and find shared tables first.

Audience
Platform team
Level
intermediate
Topic
Build message-based services
Languages
JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Moleculer servicesYou want named actions, parameter validation and built-in error classes that survive a remote hopA service broker and its schema format, and every call site rewritten to broker.call with a dotted nameYou want to route on message content rather than on a service and action name
NestJS microservice modulesThe codebase is already NestJS, with its modules, decorators and dependency injectionDecorators, a transport even for one process, and replies wrapped in an RxJS ObservableThe monolith is plain Node, where adopting the framework is a second migration
Seneca actionsCallers should send data and let the pattern pick the handler, in process or over a transportUntyped messages, one handler per pattern you have to document, and remote errors you shape yourselfYou 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.

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

  1. 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

  2. The declaration, seneca.d.ts, opens, after two imports, with one function that takes two optional any arguments and returns any. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.