How-to › Run message-based services

How to run service startup steps in a fixed order#

Turn an improvised startup function into an ordered list of named steps that can stop the boot and say why, and pin readiness to the store actually being open.

Audience
Library maintainer
Level
intermediate
Topic
Build message-based services
Languages
TypeScript and JavaScript
Verified

The startup function is 140 lines long. Someone adds a cache warm-up at the bottom, below the line that flips the health check to ready, and the first requests after a deploy answer from an empty cache. Someone else needs migrations to run after the store opens and before the handlers register, and the only way in is to edit the function every caller shares.

What you get

You will end up with an ordered list of named startup steps, each able to stop the boot with a reason, and a test that pins readiness to the store being open. This is for you if other modules add steps to a boot you own.

Short answer

Put each startup step in an ordered list under a name: read config, open the store, register handlers, announce readiness. A step adds to a shared context, and stops the boot by returning a reason or throwing, with the step named in the result. Insert a later step before or after another by name, so a plugin joins the sequence without editing it. Announce readiness only after the last step, and only after each step has awaited its resource.

You will need

Node 22 or later, and a service with more than three startup steps. The sample installs ordu for one row of the comparison and nothing else; the store is a stand-in whose connection comes up after a delay, which is how every real client behaves. Readiness matters because the platform believes it: a Kubernetes pod whose containers report ready receives traffic from that moment.1

Voxgig maintains ordu. This page compares it with NestJS lifecycle hooks and with a hand-written sequence of await calls.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hand-written async startup sequenceFewer than a dozen steps, all in one module you ownA list you maintain, and an insertion API you write and test yourself, forty lines hereSteps come from other packages, which need hooks, ordering and a log
NestJS lifecycle hooksThe service is NestJS, so every provider already has onModuleInit and onApplicationBootstrapOrder comes from the module import graph, not from a name, so placing a step between two others means moving modulesYou need a step placed relative to another by name, or the service is not NestJS
ordu task pipelinesSteps come from plugins, and you want before, after, a task log and a stop with a reason built inA dependency with its own task, operator and result vocabulary, for a list you could writeThe sequence is short and fixed, where the indirection costs more than it returns

The trade-off is what you can inspect against what you have to step through. A task list, hand-written or ordu’s, is a thing you can print, insert into by name, and read a log from. It is also one more layer between a stack trace and the line that failed, on the path you read most often when it is broken. NestJS gets its ordering from the module graph and gives up naming: a step goes where its module sits.

Name the steps and insert by name

The list is forty lines. Insertion by name is the part that earns it.

    add(step) {
      if (!step.name || typeof step.run !== 'function') throw new Error('a step needs a name and a run function')
      if (step.before) steps.splice(position(step.before), 0, step)
      else if (step.after) steps.splice(position(step.after) + 1, 0, step)
      else steps.push(step)
      return this
    },

position throws when the named step does not exist. A plugin that asks to run after a renamed step then fails when it is added, with the name in the message. It does not reach boot with its step appended to the end. That is the one check in the list that pays for itself on the first refactor.

A step is an object with a name and a run, and a plugin adds its own without touching the four the service started with.

export const runMigrations = {
  name: 'run migrations',
  after: 'open store',
  run: (ctx) => {
    ctx.migrated = ctx.store.query('create table if not exists product (id text)').length
  },
}

Stop with a reason, not a stack trace

A step stops the sequence in one of two ways, and the result names the step either way.

    async run(ctx = {}) {
      const log = []
      for (const step of steps) {
        try {
          const result = await step.run(ctx)
          if (result?.stop) {
            log.push({ step: step.name, ok: false, why: result.stop })
            return { ready: false, stoppedAt: step.name, why: result.stop, log }
          }
          log.push({ step: step.name, ok: true })
        } catch (err) {
          log.push({ step: step.name, ok: false, why: err.message })
          return { ready: false, stoppedAt: step.name, why: err.message, log }
        }
      }
      return { ready: true, log }
    },

Returning { stop } is for a failure the step expected and can name: the store did not answer, the certificate file is missing. Throwing is for everything else, and the loop treats both the same. ready is set by the caller from the result, never by a step, which is what makes the last section possible.

Run the list, then run it as ordu tasks

The demo builds the four core steps, inserts two more by name, and boots twice: once with a store that answers and once with one that does not. Then it does the same with ordu.

node demo.mjs
the hand-written list, with two steps inserted by name
  order   read config > open store > run migrations > register handlers > warm cache > announce readiness
  ready   true   6 steps ran
  ready   false  stopped at "open store": store unreachable   2 steps ran

the same list as ordu tasks
  order   read config > open store > run migrations > register handlers > warm cache > announce readiness
  ready   true   6 of 6 tasks ran
  ready   false  stopped at "open store": store unreachable   2 of 6 tasks ran

a step that returns before its resource is ready
  returns at once   announced ready: true   store open: false   first request: store not open
  awaits the store  announced ready: true   store open: true   first request: 1 row

Adding run migrations with after: 'open store' and warm cache with before: 'announce readiness' left the four core steps and the caller unchanged. The second boot stops at the second step with the reason the store gave, and the four steps after it never run.

The ordu version is the same six steps wrapped as tasks.

    w.add({
      name: step.name,
      before: step.before,
      after: step.after,
      exec: async (spec) => {
        const result = await step.run(spec.data)
        if (result?.stop) return { op: 'stop', err: new Error(result.stop), why: result.stop }
      },
    })

What ordu adds over the hand-written list is the vocabulary the list would grow anyway. It has before and after placement, a stop operator that carries err and why, and a task log with timings. It has if, to run a task only when the data matches a pattern, and active: false, to keep a task in the list without running it.2 What it costs is that vocabulary, and a result whose shape, taskcount, tasktotal, task, tasklog, you read from its README rather than from your own code.

NestJS reaches the same order by a different route. It calls onModuleInit module by module, deepest imports first and the root last, awaits each module’s hooks before moving on, and then calls onApplicationBootstrap before it listens. A step that must run after the store opens goes in a module that imports the store’s module. There is no name to insert after, and no log of which hook stopped the boot beyond the rejected promise.3

Check the store is open before you say so

The third block of the demo is the pitfall. openStoreEarly calls connect() and returns, and every client works this way: the call schedules a connection and the connection arrives later. The step reported success, the list reached announce readiness, and the first request found a store that was not open. openStore awaits the store before returning, and returns a stop with the store’s own reason when it never comes up.

export const openStore = {
  name: 'open store',
  run: async (ctx) => {
    ctx.store = store(ctx.config).connect()
    try {
      await ctx.store.whenReady()
    } catch (err) {
      return { stop: err.message }
    }
  },
}

The rule for every step that opens something: return when the thing is usable, not when it has been asked for. A net server is usable at its listening event, not when listen returns. A node-postgres client is usable when the promise connect returns resolves. A step that returns before that point has told the list a thing that is not yet true, and the list has no way to know.

Check it worked

Eight tests, and the sixth is the one that pins the pitfall rather than the fix.

node --test startup.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 197.627902

The sixth test boots with openStoreEarly and asserts three things together: the list reported ready, the store reports not open, and the first request throws store not open. Keep that test. It documents the failure the awaited version prevents, and a future edit that makes it pass by accident has reintroduced the bug. The fourth asserts that nothing after a stop ran, by checking that ctx.handlers was never set, which is the property a stop has to have.

When it goes wrong

Readiness fires and the first requests fail. A step returned before its resource was usable. Find the step whose run does not await anything, and make it wait for the client’s own ready signal, which every client exposes under some name.

A plugin’s step lands at the end of the list. Its after names a step that was renamed, and a list that appends on a miss hides that. The list here throws at add, with the missing name in the message; if yours does not, add the check.

The boot stops and the log says nothing useful. A step threw an error with a generic message, or returned { stop: true } rather than a reason. Put the resource’s own message in the reason, and test that it comes through.

The order differs between two environments. A step is added conditionally, and the step it names is not there in one of them. Add every step unconditionally and give it active: false in ordu, or an early return in the hand-written version, so the list is the same shape everywhere.

When not to do this

Do not build a task list for three steps that never change. await readConfig(); await openStore(); listen() is the whole program, and it reads better than any list. The list earns its place when steps come from more than one module, or when somebody has to insert one in the middle.

Do not let a step announce readiness. A step that flips the health check is a step that can be placed before the store opens, by accident, by name. Readiness belongs to the caller, after the last step, from the result.

Do not adopt ordu to get ordering alone. Placement by name is six lines. The dependency is worth taking when you also want its task log, its operators, and the if and active filters, which is the case when plugins bring their own steps. ordu is the engine Seneca uses for its own extension points, and that is the scale of problem it is shaped for.

Do not reach for lifecycle hooks in a service that is not already NestJS. The hooks are good because the module graph already exists there; adopting the framework to get the hooks buys an import graph you did not have and did not want.

Last verified

Verified 2026-09-24 against Node 22.22.2 and ordu 4.3.0. Both output blocks are what the preceding command printed. The NestJS behavior is described from its documentation, not from a running NestJS service.

Footnotes

  1. The Kubernetes documentation says a readiness probe runs on the container during its whole lifecycle, and that a pod whose containers report not ready does not receive traffic through its services. The platform asks the process one question, and believes the answer. That is the whole of the contract, and the whole of the problem in the third block of the demo. ↩︎ Back to text

  2. ordu’s README describes it as the engine Seneca uses for its extension points, and mentions in passing that a Go port is available. A task runner whose first customer was its author’s other framework is a common shape for a small library, and an unusually well-exercised one, since every Seneca plugin boot runs through it. ↩︎ Back to text

  3. NestJS documents the order as distance from the root module: the most deeply imported modules go first, the root goes last, and each module’s hooks are awaited before the next. That is a topological sort of the import graph, which means the order of a NestJS boot is a data structure nobody drew. It is also the same order every time, which is more than most hand-written startup functions can say. ↩︎ 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.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.