If you have written an Express app, you already know the shape of a middleware: a function that gets (req, res, next) and runs once per request. You probably also know what a plugin looks like — a side-effecting register(app) call that mutates a global at boot. KickJS gives you a third primitive sitting between those two, and most of the framework's interesting behaviour lives there: the adapter. An adapter is not per-request like middleware, and it is not a one-shot side-effect like a plugin. It is a typed object with a lifecycle, composed in order, orchestrated by the framework so your app can stand long-lived infrastructure up and tear it down deterministically.
What an adapter is
An adapter is a unit of composable infrastructure plugged into the KickJS bootstrap pipeline. You reach for one whenever a concern needs more than a single request handler can give you:
- It owns a long-lived resource — a database pool, a tracer SDK, a queue client, a mailer transport.
- It needs to register tokens in DI so services and controllers can
@Inject(...)them. - It contributes middleware to a specific phase — before global middleware, after global, after routes.
- It needs a shutdown so SIGTERM doesn't drop in-flight work or leak sockets.
The crucial mental shift from "middleware" is that the adapter is created once per process, not once per request. The crucial shift from "plugin" is that it has a typed contract — the AppAdapter interface — and the framework calls each hook for you, in order, with a context object that exposes the DI container.
You will compose multiple adapters for any non-trivial app. The pattern that scales is one adapter per concern, each in its own file, all exported as a single ordered list that the bootstrap entry imports. Construction details stay encapsulated — the entry point doesn't need to know which adapter wires up tracing or which one drains a connection pool. It just hands the array to bootstrap() and lets each adapter's lifecycle run.
That encapsulation is the real win. You can move an adapter between apps, you can unit-test one in isolation, and you can read your boot sequence as a list of names rather than a 200-line index.ts.
The lifecycle, in firing order
Every adapter that satisfies AppAdapter can opt into four hooks. They fire in this order, exactly once per process:
beforeMount— runs before modules register their controllers and routes. This is the slot for adapters that need to inject middleware before the route table exists, or that need to mount their own routes ahead of any user-defined handler. An OpenAPI/Swagger UI adapter is a typical example: it wires/docsand/openapi.jsonhere so they sit in front of any catch-all.beforeStart— runs after all modules have registered, after routes are mounted, and before the HTTP server begins listening. This is the canonical spot to construct resources and callcontainer.registerInstance(TOKEN, value). By the time the first request arrives, every downstreamresolve()will see the registered value. Most of your adapter logic lives here.middleware()— called once at adapter-setup time and returns an array of{ phase, handler }entries. The closures it returns are captured, so they can read fields populated inbeforeStartlazily, at request time. Phases let you target the slot you actually want —beforeGlobal,afterGlobal, orafterRoutes.onRouteMount— fires per controller as routes are registered. Most adapters ignore it; collectors (OpenAPI metadata, route inventories) use it to walk every controller as the framework discovers it.
After every adapter's beforeStart finishes, the server starts listening and request handling begins. There is no afterStart hook in the interface — the lifecycle splits naturally between "before listen" (boot) and "after listen" (every request that follows).
-
shutdown— runs on SIGTERM/SIGINT. The framework calls every adapter'sshutdown()concurrently underPromise.allSettled, so a slow drain in one adapter doesn't starve the others, and a thrown error doesn't skip later adapters' cleanup.
That last point is the killer feature. Before adapters, the typical Node app had its own process.on('SIGTERM') block somewhere in index.ts, papered over with a Promise.all and a comment apologising for the order. With adapters, every concern that owns a resource brings its own teardown along, and the framework runs them all without you wiring it up.
Why ordering matters
Adapters are an ordered list, not a bag. Hooks fire in registration order — beforeMount for adapter 0, then 1, then 2; same for beforeStart. Get the order wrong and an upstream adapter hasn't published its DI token by the time a downstream adapter tries to read it, or — worse — auto-instrumentation hooks don't get applied to modules that already imported the un-patched version.
A few generic rules of thumb that apply to almost every app:
-
Observability first. If you use OpenTelemetry auto-instrumentation, its initialisation has to run before the framework imports Express/HTTP. That means the adapter that owns it goes at index 0, and its constructor (not a
beforeStartbody) fires the init. - Infrastructure (DB, secrets, config) before everything that needs it. Anything that wants to resolve a connection pool from the container in a request handler has to come after the adapter that registered the pool.
- Auth before feature modules. A JWT-verifying middleware has to be in place before the controllers it gates start handling traffic. If your auth adapter runs late, you have a window where decorated routes pass through unchecked.
-
Doc/metadata collectors last. OpenAPI generators rely on every controller having mounted, so an
onRouteMountcollector should sit at the end of the list.
If you find yourself wanting to "just put this small thing first because it doesn't matter" — don't. The order is the contract.
Two adapter shapes: factory and class
KickJS v5 ships two ways to build an adapter, and both produce the same shape. The framework only cares that the result satisfies AppAdapter.
The class form gives you private fields, a constructor that runs early, and methods you can override or unit-test. Use it whenever your adapter has non-trivial state lifecycle, multiple resources to coordinate, or initialisation that must happen before the framework runs any other code:
export class TracingAdapter implements AppAdapter {
name = 'TracingAdapter'
private readonly handle: TracingHandle
constructor(input: TracingInput) {
// Init runs here, before bootstrap() patches express/http.
this.handle = initTracing(input)
}
beforeStart({ container }: AdapterContext): void {
container.registerInstance(LOGGER, this.handle.logger)
}
async shutdown(): Promise<void> {
await this.handle.shutdown()
}
}
The factory form, defineAdapter<TConfig>(), is much terser when the adapter is essentially "register one thing in DI, maybe drain it on shutdown." It takes a name and a build(config) that returns the lifecycle hooks as a plain object:
const mailerAdapterFactory = defineAdapter<MailerConfig>({
name: 'MailerAdapter',
build(config) {
return {
beforeStart({ container }) {
container.registerInstance(MAILER, new Mailer(config))
},
}
},
})
Rule of thumb: factory form for "stateless registrations + maybe a shutdown"; class form when you need a constructor body, multiple private fields, or methods worth testing in isolation. Don't agonise — both are valid forever, and you can swap one for the other without changing how the framework consumes them.
DI registration patterns
Adapters and the DI container are co-designed. The AdapterContext you receive in beforeMount and beforeStart carries the container, and you have two main tools:
container.registerInstance(TOKEN, value)— bind a token to an already-constructed value. Eager, cheap, and the right answer for anything that should be a process-wide singleton: a connection pool, a mailer, a feature-flag client, a metrics emitter. Build it inbeforeStart, register it, move on.container.registerFactory(TOKEN, () => value, scope)— bind a token to a function the container calls at resolution time. Combined withScope.REQUEST, this is how you give every request its own short-lived value derived from per-request state. The factory closes over whatever long-lived state you need (caches, registries) and reads request context at the moment of resolution.
Per-request factories are the right tool whenever the value depends on something only known at request time — the active tenant, the current user's permissions, a per-request transaction. A typical pattern:
container.registerFactory(
TENANT_DB,
() => {
const tenant = getRequestValue('tenant')
if (!tenant) throw new Error('TENANT_DB resolved outside a tenant-scoped route')
return registry.get(tenant.id)
},
Scope.REQUEST,
)
The factory hands back a cached client keyed off the active request. Resolving outside a request frame throws with a clear message — a much better failure mode than a silent undefined in a service three layers down.
A useful discipline: every adapter that calls registerInstance should also have a shutdown() that disposes the same value. If you opened it, you drain it. If a registry caches a fleet of pools, the adapter that owns the registry calls closeAll() on the way out, and it does so before the lower-level resource the pools depend on is closed. Order shutdown the same way you'd order init, just in reverse.
Ask yourself before building an adapter
Before reaching for a new adapter, run through these:
- Is this truly process-wide? If the work is per-request, write middleware. If it's a one-shot mutation at boot with no teardown, a plugin or a plain function call may be enough.
-
What do I construct, and where? Constructor (must run before the framework imports anything else) or
beforeStart(everything else)? -
What tokens do I publish in DI? And does each one have a clear consumer story — eager singleton via
registerInstance, or per-request viaregisterFactory(..., Scope.REQUEST)? -
What middleware phase do I need?
beforeGlobal,afterGlobal, orafterRoutes? If you can't answer this in one sentence, the phase is probably wrong. - How do I drain it? What's the shutdown order relative to the adapters around me? What do I leak if I skip it?
Get those five right and the lifecycle does the rest. Adapters are the place KickJS expects you to put the boring, important plumbing — and the more you let the lifecycle do the orchestration, the smaller and saner your index.ts stays.
Top comments (0)