DEV Community

Cover image for Adopt: KickJS
Orinda Felix Ochieng
Orinda Felix Ochieng

Posted on

Adopt: KickJS

A field report from building — and stabilising — a large, multi-tenant
TypeScript backend on KickJS. No product internals here; just the framework,
the patterns it pushed us toward, and where it earned its keep.


Why write this

Most framework write-ups are written on day two, when the toy app compiles and
everything feels great. This one is written from the other end: after the
codebase grew into the hundreds of modules and thousands of routes, was
refactored repeatedly, and reached the boring, wonderful state where deploys are
uneventful. That is the only vantage point from which "should I adopt this?"
can be answered honestly.

Rounded, anonymised, the surface KickJS is carrying for us today:

Metric Order of magnitude
Modules ~90
Controllers ~290
Use-cases (services) ~820
DI services total ~860
HTTP routes ~1,760
Plugins / adapters ~12

That is not a demo. It is a real system that a team ships to production on. It
is stable. That stability is the thesis of this article.


The mental model KickJS nudges you into

KickJS does not invent a new paradigm. It takes the decorator-driven,
dependency-injected server model that people already reach for in TypeScript and
makes the ergonomics of it good enough that you stop fighting it. The shape it
rewards is a clean three-layer slice:

HTTP  →  Controller  →  Use-case (@Service)  →  Reader / repository
Enter fullscreen mode Exit fullscreen mode
  • Controllers stay thin. They validate input, call one use-case, shape the response. No business logic, no database.
  • Use-cases hold the behaviour. One responsibility each. Injected, testable in isolation.
  • Readers/repositories own the queries. Multi-source read logic lives here, away from the transport layer.

A representative controller — nothing exotic, and that is the point:

@ApiTags('Orders')
@Controller()
export class OrdersController {
  @Autowired() private readonly orders!: OrdersUseCase;

  @Post('/orders', { body: createOrderBody })
  async create(ctx: Ctx) {
    ctx.json(await this.orders.create(ctx.body), 201);
  }

  @Get('/orders', { query: listOrdersQuery })
  async list(ctx: Ctx) {
    ctx.json(await this.orders.list(ctx.query));
  }
}
Enter fullscreen mode Exit fullscreen mode

@Controller() takes no path — the mount prefix comes from the module's route
declaration, so a controller is portable and the URL map lives in one place. The
DTO passed to @Post('/orders', { body: ... }) is the single source of truth
for validation and the generated types on ctx.body. You write the schema
once; the framework threads it everywhere.


The patterns that made it scale

1. Dependency injection that is actually pleasant

DI is easy to get wrong: too much magic and it becomes undebuggable; too little
and you hand-wire graphs forever. KickJS lands in the middle. @Autowired()
resolves by type, @Service() registers, and when you need an explicit handle
the token grammar is disciplined:

<scope>/<PascalKey>[/<suffix>]
Enter fullscreen mode Exit fullscreen mode

Lowercase scope, PascalCase key. 'app/Users/repository',
'app/Cache/redis'. The convention is boring and therefore memorable, and it
scales to ~860 registered services without collapsing into a soup of ambiguous
providers. We have ~890 injection sites across the codebase and they read
the same everywhere.

2. Request-scoped providers as a first-class idea

The pattern that carried the multi-tenant design was request-scoped factories: a
provider whose lifetime is the request, resolved lazily, injected like anything
else. No ambient globals, no async-local-storage wrappers threaded by hand — the
per-request value is a dependency, so it participates in the same graph as
everything else and tests can substitute it trivially. If your system has any
notion of "the current X for this request," this alone is worth the price of
admission.

3. Context decorators with declared dependencies

This is the feature I did not know I wanted. Cross-cutting request work —
auth resolution, tenancy binding, entitlement gates, permission checks — is
expressed as context decorators that declare what they depend on and let the
framework order them:

const RequiresThing = defineHttpContextDecorator({
  key: 'thing_gate',
  dependsOn: ['actor', 'hostContext'],
  async resolve(ctx, deps, params) {
    // runs *after* 'actor' and 'hostContext' are populated
    return { thing: /* ... */ };
  },
});
Enter fullscreen mode Exit fullscreen mode

dependsOn is the whole game. Instead of a fragile, hand-ordered middleware
chain where a reorder silently breaks a downstream assumption, each unit names
its prerequisites and the framework computes the order. We have ~15 of these
composed freely across the route surface, stacked several deep on a single
handler, and they just resolve in the right sequence. This is the cleanest
solution to the "middleware ordering hell" problem I have used.

4. Factory functions over base classes

Modules, adapters, and plugins are defined with factory functions —
defineModule({...}), defineAdapter({...}), definePlugin({...}) — not by
extending base classes or implementing marker interfaces. That sounds cosmetic.
It is not. Factory definitions compose, they are easy to type, they do not drag
in inheritance ceremony, and they give the tooling a stable shape to
auto-discover. A module entry file is small and declarative:

export const OrdersModule = defineModule({
  name: 'OrdersModule',
  build: () => ({
    routes() {
      return [{ path: '/orders', controller: OrdersController }];
    },
  }),
});
Enter fullscreen mode Exit fullscreen mode

5. Compile-time codegen that catches real bugs

KickJS generates types for routes, services, and env from the source. Two things
make this exceptional rather than a gimmick:

  • It is a build step you actually run, so drift between your handlers and your typed route map is caught before runtime.
  • It enforces invariants. When two decorated classes would claim the same DI token, codegen stops and tells you exactly which two files collide and how to resolve it. When two handlers claim the same verb+path, it flags the duplicate. These are the classes of bug that normally ship silently and page you later; here they fail the build with a precise message.

I hit both during a framework upgrade. The output was not a stack trace — it was
a diagnosis with named files and three ranked remedies. That is the difference
between a tool that generates types and a tool that understands your project.

6. Errors that are already RFC 7807

Structured problem+json error responses are built in. You do not hand-roll an
error-class hierarchy and a serializer; you throw, and the global handler renders
a spec-compliant problem document with a stable code discriminator the client
can branch on. Every error surface in the system speaks the same shape for free.

7. Uniform pagination

List endpoints share one pagination contract — parse, clamp, and page through a
single helper rather than re-deriving limit/offset/filters per endpoint. We
use it in ~76 places and every list endpoint behaves identically from the
client's perspective. Consistency at that scale is not something you get by
discipline alone; you get it because the framework made the consistent path the
easy path.

8. Vite-powered HMR and auto-discovery

Development runs on Vite. Modules are auto-discovered, and hot-reload is
genuinely hot — edit a handler, the change is live, state survives. At this
module count, a dev loop that stayed fast is not a luxury; it is what kept
iteration velocity from collapsing under the weight of the codebase.

9. First-party Swagger, testing, and CLI adapters

OpenAPI docs, an integration-test harness, and a CLI generator ship as
first-party adapters that speak the same conventions as the core. Docs stay in
sync because they are derived from the same decorated source the router uses.
New surface is scaffolded by the generator, so it is born matching house style
instead of drifting from it.

10. A typed asset manager

(Proof: the official guide — https://kickjs.app/guide/asset-manager.html.)

Backends accumulate non-code files — email templates, PDF/HTML views, report
layouts — and the usual failure mode is a renderFile('./templates/foo.ejs')
string that survives static analysis and only explodes when the handler runs.
Worse, __dirname arithmetic breaks the moment you move to ESM, a monorepo, or a
bundled build. The asset manager kills all three problems. You declare namespaces
in kick.config.ts:

assetMap: {
  mails:   { src: 'src/templates/mails' },
  reports: { src: 'src/templates/reports', glob: '**/*.{ejs,html}' },
}
Enter fullscreen mode Exit fullscreen mode

and reference files through a generated, type-checked accessor (the keys
strategy — 'auto' | 'strip' | 'with-extension' — decides whether the key
keeps its extension):

import { assets } from '@forinda/kickjs';
const path = assets.mails.welcome();            // strip
const path = assets.reports['receipt.ejs']();   // with-extension
Enter fullscreen mode Exit fullscreen mode

There are four access patterns for different needs — the ambient assets proxy,
a useAssets() hook (handy for DI and testing), an @Asset('mails/welcome')
field decorator, and a resolveAsset(category, slug) resolver for dynamic
dispatch. The honest guarantee: kick typegen walks the assetMap and augments
the KickAssets interface, so the typed proxy gives you autocomplete and
catches property typos at compile time
; a file that is genuinely missing on
disk surfaces at resolution as a structured UnknownAssetError carrying the
namespace and key. That is the right split — the framework can prove the name
you typed is one it knows, and it fails loudly and specifically when the file
itself is absent.

Dev-time, kick dev re-runs typegen on change and synthesises an in-memory
manifest by walking the directories, so new templates are usable immediately.
For production, kick build writes a .kickjs-assets.json manifest and the
resolver honours KICK_ASSETS_ROOT; kick build:assets refreshes just the
manifest without a JS rebuild. It is a small feature that quietly deletes an
entire category of runtime bug.

11. The CLI is a task runner — and a genuine plugin platform

kick is not just scaffolding — it is the project's command surface, and it
extends at two levels.

Level one, from config. Custom commands are declared declaratively and then
behave like first-party ones (they show up in kick --help, support sequential
steps and aliases):

commands: [
  { name: 'test',     description: 'Run tests',   steps: 'npx vitest run' },
  { name: 'ci:check', description: 'Typecheck + format check',
    steps: ['npx tsc --noEmit', 'npx prettier --check src/'],
    aliases: ['verify'] },
]
Enter fullscreen mode Exit fullscreen mode

The payoff at scale is one entry point. Instead of a drift-prone spread of npm
scripts, a Makefile, and a shell folder, the team runs kick <thing> and the
config is the source of truth. New contributors discover the whole command
surface from kick --help.

Level two, a published contract. This is the part that makes it a platform
rather than a script bag. The extension contract lives in its own tiny,
dependency-free package — @forinda/kickjs-cli-kit (one commander peer) —
whose stated job is "the types and helpers a package implements to ship
kick-compatible commands, generators, and typegens without depending on the
full CLI." A plugin author imports the kit; app authors get the same helpers
re-exported from @forinda/kickjs-cli. (The split exists to break a dependency
cycle — the CLI already depends on its first-party plugins, so the contract is
hoisted into a package both sides import. That is a mature bit of architecture,
not an accident.)

The surface a plugin can contribute is broad:

import { defineCliPlugin, defineGenerator } from '@forinda/kickjs-cli-kit';

export const myPlugin = defineCliPlugin({
  name: 'my-org/tools',
  commands: [/* declarative shell commands */],
  register(program, ctx) {
    // full Commander access — ctx gives cwd, projectRoot, config, log
    program.command('hello <name>').action((n) => ctx.log(`hi ${n}`));
  },
  typegens: [/* your own codegen passes, run after the host's */],
  generators: [
    defineGenerator({
      name: 'widget',
      description: 'Scaffold a widget',
      args: [{ name: 'name', required: true }],
      files: (ctx) => [
        { path: `src/widgets/${ctx.kebab}.ts`,
          content: `export class ${ctx.pascal}Widget {}\n` },
      ],
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

Four extension points, all first-class: declarative commands, programmatic
Commander registration
, your own typegens (custom codegen that runs after
the framework's pass), and kick g <name> generators. Generators even
support out-of-band discovery — a third-party package advertises
"kickjs": { "generators": "./dist/generators.js" } in its package.json and
its scaffolders appear under kick g exactly like the built-ins. The generator
context hands you every naming variant you need (pascal, kebab, camel,
snake, plural forms) plus a resolved projectRoot, so a scaffolder writes to
the right place regardless of which subdirectory the command was typed in. And
consistent with the rest of the framework, duplicate plugin/command/typegen/
generator ids throw a KickPluginConflictError — the same "fail loudly on
ambiguity" discipline the DI layer enforces.

The lesson: the CLI is built on the exact contract it exposes to you. Extending
it is not a plugin API bolted onto a closed tool — it is the same mechanism the
framework's own database tooling uses.

For completeness, the full set of typed extension factories the CLI exposes —
every one an identity helper that just gives you inference and forward-compat:

Factory Extends Shape
defineConfig the whole kick.config.ts the KickConfig contract that carries all of the below
defineCliPlugin the CLI name + any of commands / register() / typegens / generators
defineGenerator kick g <name> { name, description, args?, flags?, files(ctx) } scaffolder
defineTypegen codegen { id, inputs, generate(ctx) } — a custom typed-artifact pass
defineDoctorExtension kick doctor { checks: DoctorCheck[] } merged after the built-ins
defineDoctorCheck kick doctor one (ctx) => DoctorResult health check

defineTypegen deserves a callout: an adopter plugin can emit its own typed
artifacts into .kickjs/types/ on the same watched pipeline the framework uses,
with a TypegenContext that hands you importTs() (load the adopter's schema at
generate time), writeFile(), a memoised getScanResult() (share the project's
AST walk instead of re-scanning), and a logger. That is how the database tooling
ships its schema types — and your team can ship theirs the same way. Config-level
extension points echo the same openness: modules.repo accepts a CustomRepoType
so you can register a repository backend the generators target, and every
duplicate id across plugins/commands/typegens/generators throws
KickPluginConflictError rather than silently last-wins.

12. Diagnostics: doctor, explain, tinker, codemod

The tooling story goes past build and run:

  • kick doctor — a pre-flight health check (Node version, required deps, decorator config, env wiring, typegen freshness). Run it on a fresh clone or in CI and it tells you what is wrong with the environment before you waste time debugging the app. Crucially it is extensible: adopters ship their own checks via doctor: { checks: [...] } in config (or the defineDoctorExtension / defineDoctorCheck helpers), each check receiving the same context the built-ins do and returning a pass/warn/fail with a fix hint. Your ORM-specific or secret-strength checks live in your config, and the core stays agnostic.
  • kick explain <error> — hands you a KickJS error and gets back a human explanation plus a suggested fix. The framework is self-documenting at the point of failure.
  • kick tinker — a REPL with the DI container and services already loaded. The Rails-console experience for a TypeScript backend: poke at a real service graph interactively.
  • kick codemod — AST-style rewrites for framework migrations, so version bumps that change conventions can be applied mechanically instead of by hand.

There is even an MCP server (kick mcp) so AI tooling can talk to the framework
on its own terms. The through-line: KickJS treats developer diagnostics as a
first-class part of the framework, not an afterthought.

13. Pluggable backends: swap an implementation without touching callers

This is the plugin/adapter system, and it is the pattern that kept the app's
edges clean. An adapter resolves a concrete backend at boot, validates its
config fail-fast, and registers it under a stable typed token. Everything
downstream injects the interface, never the implementation, and never
branches on environment.

The mailer is the canonical example. One adapter owns the decision:

export interface Mailer {
  readonly provider: string;
  send(message: OutboundEmail): Promise<SendResult>;
}
export const MAILER = createToken<Mailer>('app/Mailer');

export const MailAdapter = defineAdapter({
  // resolve provider from env, assert its secrets exist (crash boot if not),
  // register the concrete sender under MAILER
});
Enter fullscreen mode Exit fullscreen mode

Consumers are trivial and provider-agnostic:

@Autowired(MAILER) private readonly mailer!: Mailer;
// ... this.mailer.send(msg) — no idea whether it's SMTP, an API, or a fake
Enter fullscreen mode Exit fullscreen mode

Swapping the backend is a config change, not a code change: a dev runs a
console or fake mailer that logs to the app log or an in-memory store; CI
runs the fake and asserts on captured mail; production points at a real
provider. The call sites never move. We use the same shape for every external
dependency — object storage, queue, SMS, virus scanning, scheduling, payment
rails — each behind an adapter and a token. The result is that the core of the
app has no knowledge of any vendor, and any one of them can be replaced or
mocked without a ripple. Fail-fast config validation at boot is the other half:
a misconfigured production backend crashes startup with a named error, not the
first customer request.

Adapters and plugins are, again, factory-defined (defineAdapter({...}),
definePlugin({...})) and composed by name in their own aggregation files, so
adding or removing a backend is a local, declarative edit.

14. One validator, propagated everywhere

The single most leveraged decision in the codebase: request schemas are Zod, and
that one schema is the source of truth for both runtime validation and
the static types on the request context. You wire the validator once in config:

typegen: { schemaValidator: 'zod' }
Enter fullscreen mode Exit fullscreen mode

Then a DTO written once:

export const createOrderBody = z.object({
  sku: z.uuid(),
  quantity: z.number().int().positive(),
});
Enter fullscreen mode Exit fullscreen mode

attached to a route:

@Post('/orders', { body: createOrderBody })
async create(ctx: Ctx<KickRoutes.OrdersController['create']>) {
  // ctx.body is typed as { sku: string; quantity: number } AND already validated
}
Enter fullscreen mode Exit fullscreen mode

does three jobs from one definition: it rejects bad input at the boundary with a
structured error, it makes ctx.body/ctx.query/ctx.params fully typed with
no manual interface, and it feeds the generated OpenAPI docs. Change the schema
and every one of those updates together — there is no second type to drift, no
validator to forget. Across the system this is used in hundreds of DTOs and
~186 typed-context handlers, and the reason list endpoints, bodies, and docs
all stay honest is that they are all downstream of the same object.

And the validator itself is a swap, not a lock-in: typegen.schemaValidator
takes 'zod' (default), 'kickjs-schema' — which infers through Zod, Valibot,
Yup, or any Standard Schema v1 adapter — or false to opt out entirely. You get
the single-source-of-truth ergonomics without being married to one schema
library. If you adopt KickJS for one reason, make it this one.

15. Bring your own database and ORM

KickJS has no persistence baked into the core. It is deliberately
ORM-agnostic — the framework's own tooling says so out loud (its doctor checks
carry the note that "the framework stays ORM-agnostic — Prisma / Drizzle /
Mongoose-specific checks belong in their respective adapters or in adopter
config, not in core"). Data access enters the same way every other backend does
here: behind an adapter and a token (see §13). The core knows about controllers,
DI, and routes; it does not know or care what is storing your rows.

Concretely, the persistence layer is a choice you configure, not a mandate:

modules: {
  dir: 'src/modules',
  repo: 'drizzle',   // or 'inmemory', 'postgres', … or a CustomRepoType
}
Enter fullscreen mode Exit fullscreen mode

repo accepts a built-in string or a CustomRepoType you register, and the
generators scaffold against whatever you pick — kick g module order --repo postgres
generates a Postgres-shaped repository; the default inmemory gives you a
working module with zero database at all, which is superb for prototyping and
tests. If you have your own schema, defineTypegen's importTs() lets a plugin
read it at generate time and emit types for it, so even a bespoke data layer gets
first-class typed access.

For this system we made a specific, opinionated choice — Postgres with a typed
query layer — and it has served a large, multi-tenant workload well. But that is
our choice living in our adapters and config. Nothing in the framework forces
it. If your team wants Prisma, Mongoose, Kysely, raw SQL, or something exotic,
KickJS gets out of the way and lets you wire it the same clean way: an interface,
a token, an adapter that resolves the concrete backend at boot. You adopt the
framework without adopting its authors' database opinions.


Where it was genuinely exceptional

Refactor safety. The single most valuable property at scale. Renames,
module moves, and dependency reshuffles are backed by generated types and
codegen invariants, so large mechanical changes either compile and pass codegen
or fail loudly with a pointer to the exact problem. You refactor with the
confidence that the framework will not let a whole class of mistakes through.

Onboarding. Because every slice looks the same —
controller → use-case → reader, DI by convention, DTO-as-source-of-truth — a
new contributor learns one shape and can then read any of the ~90 modules.
The framework's opinions are the onboarding doc.

It gets out of the way. After the initial model clicks, you spend your time
on the problem, not on the framework. That is the highest compliment I can pay a
backend framework, and it is why the codebase reached "boring to deploy."


Sharp edges worth knowing (adopt with eyes open)

No honest adopt-report is all praise. None of these are dealbreakers; all are
worth knowing on day one:

  • Token uniqueness is enforced globally. Two classes with the same name in different modules will collide as DI tokens. This is a feature — it catches ambiguity — but it means your naming wants a domain prefix (OrdersInjuryService vs ClinicInjuryService) rather than bare, repeated names. Cheap to fix, better to know upfront.
  • Auto-discovery has rules. Module entry files and the glob that eager-loads your decorated classes must follow the framework's naming/registration conventions or things silently fall out of discovery (or degrade HMR to full restarts). The codegen advisories tell you when something is unregistered — read them.
  • Generated artifacts are part of the build. Treat codegen as a real step in CI and in your pre-push checks, not an afterthought. When it is wired into the gate, the invariants above protect you continuously.
  • A couple of doctor checks are shallow. In our setup kick doctor flagged tsconfig.json as missing even though a valid one — with both experimentalDecorators and emitDecoratorMetadata set — was present in the project root. Treat doctor as a fast smoke test of the environment, not a formal proof; when a check disagrees with reality, trust the build.

Every one of these is the framework being strict on your behalf. The strictness
is why the large system is stable.


Verdict

Adopt.

If you are choosing a TypeScript backend framework for something you expect to
grow — many modules, many routes, a team, a multi-year horizon — KickJS is a
well-thought-out choice. It does not dazzle you in a hello-world; it rewards you
at module two hundred. The decorator + DI + factory-module model is coherent,
the codegen catches the bugs that normally ship silently, the context-decorator
dependency model solves middleware ordering properly, and the whole thing stays
fast and refactorable at a scale where most stacks start to fray.

We built a large, real, multi-tenant system on it. It is in production. It is
stable. That sentence is the entire recommendation.


Learn more

#KickJS #TypeScript #NodeJS #BackendFramework #DependencyInjection #DeveloperExperience #Zod #ORMagnostic #CLI #OpenSource #WebDev #SoftwareArchitecture

Top comments (0)