There is a tempting first read of KickJS where ctx looks like a friendlier wrapper around Express's req/res. It is not. Treating ctx as "req in disguise" is the single fastest way to write code that fights the framework — you reach for req.user, you read headers off req.headers, you sprinkle requireX(ctx) helpers around handlers, and the type system never quite catches up. Once you understand that ctx is a typed, contributor-populated object that lives on top of a per-request store — and that the lifecycle is staged across phases the framework actually controls — the whole stack starts pulling in the same direction. This article walks the KickJS request lifecycle end to end as a conceptual model you can apply to any KickJS app.
The phases, in firing order
A request to a KickJS app is not a flat chain. It moves through three distinct stages, in this order:
HTTP request
│
▼
┌─────────────────────── Adapter middleware ───────────────────────┐
│ beforeGlobal → before kickjs's framework middleware │
│ afterGlobal → after framework middleware, BEFORE routes │
│ beforeRoutes → per-router, before the matched handler │
│ afterRoutes → per-router, after the handler resolves │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────── Per-route contributors ───────────────────┐
│ Pipeline built from `bootstrap({ contributors: [...] })` │
│ Each contributor's `resolve(ctx)` runs once, result cached │
│ in the per-request store under its `key` │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────── Controller handler ───────────────────────┐
│ async handler(ctx: Ctx<KickRoutes.Foo['op']>) { │
│ const session = ctx.get('session') // typed, never unknown │
│ } │
└──────────────────────────────────────────────────────────────────┘
Adapter middleware is where infrastructure lives — body parsing, auth strategies, request shaping. Contributors are where derived, per-request values get computed, typed, and cached. The handler is where business logic actually runs. The line between these stages is rigid on purpose: middleware mutates req, contributors read ctx, handlers consume both via ctx.get() and the typed Ctx<T> shape from KickRoutes.
The four middleware phases — beforeGlobal, afterGlobal, beforeRoutes, afterRoutes — are not aesthetic. They give you a deterministic place to plug in things like "I need to run before kickjs registers its own framework middleware" (beforeGlobal) versus "I need every request to have a session attached, but I do not want to be inside any specific router yet" (afterGlobal).
Adapter middleware
A typical adapter mounts handlers at the phases it cares about:
middleware(): AdapterMiddleware[] {
return [
{
phase: 'afterGlobal',
handler: (req, _res, next) => {
req.session = lookupSession(req.headers.authorization)
next()
},
},
]
}
Two things matter here. First: the middleware does not touch ctx. It mutates the underlying Express Request only. Second: it is registered as phase: 'afterGlobal' precisely because it must run after KickJS's framework middleware (so that body parsing, request id, etc. are in place) but before any router or contributor sees the request. If you put it at beforeRoutes you would have to mount it on every router. If you put it at beforeGlobal you would race the framework's own initialization. afterGlobal is the right joint for cross-cutting infrastructure that every route depends on.
Use beforeRoutes/afterRoutes for per-router concerns (a logging shim around one feature module, a guard that only one router exposes). Use beforeGlobal sparingly — it sits in front of the framework's own request id, error handling, and body parsing, so most of the time it is not what you want.
The pattern to internalize is: adapter middleware stamps things onto req. Contributors lift those things into the typed ctx.
Contributors: typed ctx.get()
A contributor is just an object built by defineHttpContextDecorator. The minimal shape:
export const LoadSession = defineHttpContextDecorator({
key: 'session',
resolve: (ctx) => {
const session = ctx.req.session
if (!session) {
throw new HttpException(HttpStatus.UNAUTHORIZED, 'no session')
}
return session
},
})
Four properties matter:
-
key— the string that handlers will use inctx.get('session'). It is also the property in theContextMetainterface that gives the read its type. -
resolve(ctx)— the function that produces the value. Runs once per request, lazily, the first time something asks for it. The return value is cached in the per-request store underkey. -
optional— whentrue, a throw insideresolveis swallowed silently and the key is simply not set;ctx.get('key')returnsundefined. When false (the default), a throw aborts the request with whateverHttpExceptionyou raised. Useoptional: truefor values that legitimately may not exist (anonymous endpoints, public webhooks) so the request does not 500 just because the contributor cannot build its value. -
dependsOn— an array of other contributor keys that must resolve first. KickJS'sbuildPipelinetopologically sorts contributors using these dependencies. IfLoadProfiledeclareddependsOn: ['session'], the pipeline would guaranteeLoadSessionran first. If a contributor declared a dependency on a key that no contributor exposes,buildPipelinewould reject the registration at boot — the framework refuses to silently ignore a missing dependency.
Once you call bootstrap({ contributors: [LoadSession.registration, ...] }), those contributors run on every route automatically. Handlers consume the result with ctx.get('session') — typed — instead of reading ctx.req.session and reinventing the null check at every callsite.
The contract is simple: if you read a value via ctx.get, the framework either gave you that value or threw an HttpException with a message you can grep for. There is no "sometimes typed, sometimes undefined" middle ground muddying handler code.
Augmenting ContextMeta
ctx.get('session') is typed, but only because the app told the framework what session actually is. The augmentation lives in a .d.ts file your app owns:
import type { Session, Profile } from './types'
declare module '@forinda/kickjs' {
interface ContextMeta {
session: Session
profile: Profile
}
}
export {}
That declare module block is the one TypeScript trick that makes the entire ctx.get() story worth using. KickJS's ContextMeta interface is defined empty in the framework. When your app augments it, every call to ctx.get<K extends keyof ContextMeta>(k: K): ContextMeta[K] | undefined (or the non-undefined overload when the contributor is non-optional) returns the narrowed type instead of unknown.
The trailing export {} is required to keep the file a module rather than a script — without it, declare module does not augment correctly. If you skip the declare module block entirely, every ctx.get() returns unknown and your handlers fight the type system instead of leaning on it. This file is small but every line is load-bearing.
A useful discipline: keep one context-meta.d.ts per app. Domain packages that ship contributors should export the type they augment, and the app composing them does the actual declare module once. That keeps augmentation centralized and easy to audit.
The dependsOn trap
Here is the canonical bug. Suppose a contributor needs the user that an auth strategy populates onto req.user, and you write:
export const LoadProfile = defineHttpContextDecorator({
key: 'profile',
dependsOn: ['user'], // looks reasonable. it isn't.
resolve: (ctx) => buildProfile(ctx.req.user),
})
The intent is correct: the profile needs the user. The problem is that user is not a contributor. req.user is populated by the auth strategy's adapter middleware, which runs in the afterGlobal/beforeRoutes phase before any contributor pipeline executes. There is no LoadUser.registration in the bootstrap({ contributors }) array — there cannot be, because the auth adapter owns that responsibility.
buildPipeline checks every dependsOn key against the registered contributor set. When it sees dependsOn: ['user'] and finds no contributor with key: 'user', it raises a MissingContributorError at boot. The fix is to drop the false dependency and trust the lifecycle:
export const LoadProfile = defineHttpContextDecorator({
key: 'profile',
// no dependsOn — auth middleware ran in afterGlobal,
// so ctx.req.user is already populated by the time resolve runs
resolve: (ctx) => buildProfile(ctx.req.user),
})
The mental model to keep is: dependsOn is for ordering between contributors, not for declaring "I need something on ctx." If the value comes from middleware, you read it inside resolve and rely on phase ordering. If the value comes from another contributor, you declare the edge and let the framework topo-sort. Mixing the two produces boot-time errors that look mysterious until you remember which layer owns which keys.
Why contributors > helpers
Before contributors, the natural pattern is a requireX(ctx) helper imported into every handler that needs X:
const session = requireSession(ctx) // throws 401 if missing
That helper is fine in isolation. It is also duplicated logic, untyped at the framework level (the helper returns the right type only because the helper itself is typed — the framework does not know about it), and impossible to compose with auth or audit concerns without a second helper, then a third. The pattern makes the framework lifecycle invisible: the throw happens wherever the helper got called, so the same "no session" error surfaces in twelve different stack frames.
ctx.get('session') collapses all of that:
- The error message lives in one place — inside
LoadSession.resolve. Change it once, the whole app updates. - The retrieval is typed via
ContextMetaaugmentation. No helper indirection. - The framework decides when
resolveruns, caches the value, and integrates withoptionalfor routes that legitimately do not need it. - Adding a new derived value is a registration, not a new helper file with its own import surface.
A handler now just looks like this:
async create(ctx: Ctx<KickRoutes.WidgetsController['create']>): Promise<void> {
const widget = await this.createWidget.execute(ctx.body, ctx.get('profile')!)
ctx.created(toWidgetDTO(widget))
}
ctx.body is typed from the route's Zod schema. ctx.get('profile') is typed from ContextMeta. The ! is the explicit acknowledgement that this route is authenticated, so the optional contributor will have resolved. There is no helper, no requireSession, no per-handler null check. The framework handled it.
Putting it together
Read the request as a pipeline:
- Express receives the request. KickJS's framework middleware runs (
beforeGlobaladapter middleware first, then framework internals). -
afterGlobalmiddleware fires. Auth strategies, session resolvers, and other cross-cutting infrastructure stamp values ontoreq. - The route is matched.
beforeRoutesmiddleware runs (per-router auth guards, role checks, etc.). - The contributor pipeline executes. Each registered contributor's
resolveruns once, in topological order based ondependsOn. Results are cached in the per-request store. - The handler runs.
ctx.get(key)returns typed values;ctx.body/ctx.params/ctx.queryare typed fromKickRoutes. -
afterRoutesmiddleware fires for cleanup (logging, response shaping). The response goes back.
Each layer has one job. Middleware mutates the request. Contributors compute and cache derived values. Handlers consume typed ctx. The framework polices the boundaries — dependsOn enforces contributor ordering, ContextMeta enforces typed reads, MissingContributorError blocks boot when the wiring is wrong.
Once you internalize this, the helper-per-concept style starts to feel like writing your own framework on the side. Contributors are not magic; they are just a place for derived per-request state to live with type safety, lifecycle integration, and a single throw site. That is what makes ctx worth more than req.
Top comments (0)