DEV Community

Cover image for From v3 to v5 — Migrating a Production KickJS App, Slice by Slice
Orinda Felix Ochieng
Orinda Felix Ochieng

Posted on

From v3 to v5 — Migrating a Production KickJS App, Slice by Slice

If you maintain a KickJS v3 app, the v5 changelog probably reads like a wall of unfamiliar primitives — defineAdapter, defineHttpContextDecorator, Scope.REQUEST, definePlugin, ContextMeta augmentations. The temptation is to delay: "we'll do it next quarter." This is a conceptual walkthrough of what actually changes between v3 and v5, what to migrate in what order, and where the sharp edges hide. The goal is to leave you with a mental model, not a recipe.

The short version: a v5 bump is worth doing if you're still adding features. Per-request boilerplate shrinks, role decorators become typesafe, the dep tree gets smaller, and your codebase stops drifting away from where the framework's own docs and examples now live. The migration is mechanical but not trivial, and it goes much better as a planned slice-by-slice swarm than as a Saturday afternoon.

The shape of what changes

Three structural shifts define the v3 → v5 jump, with v4 sitting in the middle as a deprecation runway.

Adapters become functional. In v3 every cross-cutting concern — infrastructure, auth, observability, mailer, swagger — was a class implementing an AppAdapter interface with beforeStart and shutdown hooks. v5 introduces defineAdapter, a factory style where an adapter is just an object you assemble inline. The hook surface is similar; the difference is composability. You can return a configured adapter from a function, parameterize it cleanly, and stop fighting class-instance ergonomics in tests.

Per-request context becomes typed. v3 stamped values onto a request object via middleware, and you read them back through guard helpers — requireTenant(ctx), requireUser(ctx) — that threw on missing values and returned unknown on success. v5 introduces contributors via defineHttpContextDecorator. A contributor declares a key, an optional dependsOn graph, and a resolve function that runs once per request before the handler. Combined with a ContextMeta module augmentation, ctx.get('tenant') returns a fully typed value end-to-end, with the missing-value throw centralized rather than copy-pasted at the top of every handler.

Plugins replace bundled goodies. v3 shipped a first-party mailer package. v5 cuts it. The same fate hits a handful of other "kickjs-*" first-party packages that are now BYO via definePlugin. If you don't use those packages this is a non-event; if you do, expect to write a thin provider interface and a small plugin wrapper.

Two smaller-but-loud changes round it out: a request-scoped DI factory (Scope.REQUEST) that lets you inject per-request resources directly instead of wrapping them in helper closures, and an AuthUser augmentation hook that finally makes role decorators like @Roles('admin', 'worker') typecheck against your actual role union.

A sliced migration plan

Treat this as a one-week swarm, not a casual refactor. The slice order matters because each step depends on the interfaces of the previous one — so a revert at any point produces a working app on either side of the cut.

1. Write the plan first. Land a planning doc in the repo with acceptance criteria per slice. The criterion that matters: "test suite green at this commit, app boots either side of the revert." This single rule prevents you from accidentally coupling slice N+1 to the internals of slice N.

2. Bump to the latest v4.x first. Skipping straight to v5 is the single biggest mistake you can make. Roughly half of the deprecations land as v4 warnings, not v5 errors. By the time you're on v5 those warnings are gone, and the only signal that something's miswired is a runtime null. Sit on v4 long enough to read every warning. Then cut.

3. Land typed contributors before touching DI. Contributors should ship before request-scoped factories, because a Scope.REQUEST factory that resolves "the active tenant's database" reads the tenant out of the per-request context that a contributor populates. Get the contributor and its augmentation in first. Replace the in-handler guard helpers afterwards.

4. Migrate request-scoped resources one module at a time. This is where the bulk of the visible cleanup happens. Each module's mutation paths drop their withResource(id, deps, async (handle) => ...) wrappers in favor of @Autowired(RESOURCE) on a use-case field. One commit per module keeps reverts surgical and gives subagents (or pair programmers) clean work boundaries.

5. Port adapters to defineAdapter after the use-cases are clean. Adapters own the DI registrations the contributors and use-cases depend on, so it's easier to convert them once the consumers are already idiomatic v5. Doing it earlier means the adapter has to support both shapes simultaneously.

6. Cut the v5 dependency bump. With everything above already in v5-idiomatic shape against the v4.x compatibility shim, the actual bump is a one-line package.json change plus the AuthUser augmentation that types your role decorators. Boring on purpose.

7. Convert internal plugins last. Any first-party packages or helpers you'd already extracted get wrapped as definePlugin consumers, and any dropped first-party packages (like the mailer) get replaced with your own thin provider plus a plugin wrapper.

A short, generic example of the contributor shape, just to anchor the mental model:

export const LoadCurrentUser = defineHttpContextDecorator({
  key: 'currentUser',
  resolve: (ctx) => {
    if (!ctx.req.user) throw new HttpException(401, 'no auth')
    return ctx.req.user
  },
})

declare module '@forinda/kickjs' {
  interface ContextMeta { currentUser: AuthUser }
}
Enter fullscreen mode Exit fullscreen mode

After that augmentation, ctx.get('currentUser') is typed everywhere. No guard helper, no unknown.

Common gotchas

dependsOn only resolves contributor keys. A natural first instinct is to declare dependsOn: ['user'] on a contributor that needs the authenticated user. If user is stamped by an adapter middleware — which is true for most JWT integrations — the boot will crash, because dependsOn walks the contributor graph and user isn't in it. Adapter middleware runs before the contributor pipeline, which is the whole point of that ordering. Read the value directly off the request inside resolve and skip dependsOn for it.

The deprecated mailer is louder than the docs suggest. If your app sends transactional email, plan for a small extra slice: a MailProvider interface, a console implementation for dev/test, a real implementation (nodemailer or your provider of choice) for staging/prod, all wrapped in definePlugin so consumers register it via bootstrap({ plugins: [...] }). It's roughly an afternoon of work, but it's not "free with the bump."

Test fixtures need contributor registration. Any test harness that boots a mini app — createTestApp and friends — needs to receive contributor registrations explicitly. Forgetting this is the #1 cause of "everything works in dev, every test 400s." Note that the contributor object itself isn't what you pass; the framework exposes a .registration handle for test composition. Standardize a beforeEach that resets the container, otherwise registrations from a previous test leak into the next.

Library version skew during partial migrations. If your codebase pulls in a downstream package that itself depends on KickJS, that package's peer-dep range may not yet cover v5. During the v4 staging slice this is a non-issue. After the v5 cut, you'll either need to update the downstream package or temporarily pin it. Audit your dep graph before the cut, not after.

Process-wide caches versus naive Scope.REQUEST factories. A Scope.REQUEST factory that opens a fresh database pool per request will absolutely melt your connection pooler under load. The v5 way is a small registry — a process-wide map keyed by whatever you scope on — that hands out cached handles. Cache the promise, not the resolved handle, so concurrent first-resolves don't race. Drain the registry on adapter shutdown.

Don't share working trees across parallel migration agents. If you're dispatching multiple subagents (or human collaborators) to migrate modules in parallel, give each one its own git worktree. A helper-deletion job and a module-migration job sharing a tree will eventually delete a file the other one is still importing, and you'll lose half an hour figuring out why a clean build is suddenly red.

The wins

Compile-time @Roles. Augment AuthUser with your project's role union, and @Roles('admin', 'manager') becomes a typecheck. @Roles('mananger') becomes a compile error. This alone prevents a class of 403-in-prod bug that was historically only catchable via integration tests. It's the single change most worth shipping.

A smaller dependency tree. Dropping the bundled mailer (and, if applicable, any other first-party packages you weren't really using) reduces install size and unlocks a pnpm dedup you might have been carrying. Not glamorous, but real.

Idiomatic v5 patterns compound. Use-case bodies stop with eight lines of plumbing and start with the actual domain logic. New engineers can read a use-case without first learning the framework's wrapper conventions. Per-request DI means future features that need a new scoped resource (a tenant cache, a request-tied audit log, a feature-flag client) plug in through the same Scope.REQUEST factory shape — you write the pattern once and reuse it.

Better request latency on warm paths. The cached request-scoped resources mean a second request for the same scope skips lookups, secret fetches, and pool warmups. Concretely, expect tens of milliseconds shaved off cold paths and a handful off warm ones. It shows up in P95.

Should you do this?

If you're on v3 and shipping new feature work weekly, yes. Plan a focused week, slice the work as described above, and treat the v4 stop as non-negotiable. The win is real and the pattern carries forward to every feature you ship after.

If your v3 codebase is feature-frozen — nothing new in flight, just maintenance — skip it. The migration costs more than the marginal cleanup wins if you aren't writing new use-cases.

If you're starting a new app today, start on v5. Write contributors and defineAdapter from day one, augment AuthUser with your role union before your first @Roles decorator, and write a Scope.REQUEST factory the first time you reach for a request-scoped resource. You'll thank yourself the first time tsc catches a typo that would otherwise have shipped.

Slice by slice is the only sane way through. Smaller commits would be busywork, bigger ones would make reverts catastrophic, and the v4 staging slice catches roughly half the breakage before it can become a runtime mystery. Plan it, dispatch it, and merge it when every slice tip is green.

References

Top comments (0)