DEV Community

Cover image for Dependency Injection in KickJS — Tokens, Scopes, and the Per-Request Factory
Orinda Felix Ochieng
Orinda Felix Ochieng

Posted on

Dependency Injection in KickJS — Tokens, Scopes, and the Per-Request Factory

Dependency injection in KickJS is small on purpose. There are four surfaces, two scopes, and one pattern — a per-request factory — that does most of the interesting work. Once you know how those pieces compose, you can wire anything from a process-wide HTTP client to a request-scoped derived value through the same container, and your services keep looking like the boring constructor-and-method classes you wanted them to be in the first place.

This article walks the four DI entry points, explains when to use a token versus a class, draws the line between singleton and request scope, and then shows the one pattern that ties it together: an adapter that registers a request-scoped factory which reads request state and returns a derived handle.

The four DI surfaces

KickJS exposes DI through four distinct entry points. Each suits a different role, and a typical class will use two or three of them at once.

1. @Service() — auto-registered class. Decorate a class and the container learns to construct it. From inside any other @Service() or @Controller(), you can declare a property of that class type and the container fills it in. This is the "I wrote a class, I want it injectable" path:

@Service()
export class OrderPricingService {
  price(items: LineItem[]): Money { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

2. @Autowired(Class?) — property injection by class type. Inside a service or controller, declare a property and the container fills it before the first method runs. With no argument it uses the property's type metadata; with a class argument it disambiguates explicitly. Use this when both ends of the wire are concrete classes you own.

3. @Inject(token) — constructor or property injection by token. Use this when the type is an interface, when the implementation lives in another package, or when there are multiple implementations of the same shape and the choice is made by an adapter. Constructor form composes cleanly with classes that already have a constructor:

constructor(@Inject(MAILER) private readonly mailer: MailerService) {}
Enter fullscreen mode Exit fullscreen mode

4. Container.registerInstance(token, value) / Container.registerFactory(token, fn, scope) — manual registration. This is the adapter's hook. When you have something the DI graph cannot construct on its own — a config value, a third-party client, a thing that needs async I/O at boot — you put it on the container imperatively. Use registerInstance for an already-built value that lives forever; use registerFactory when each resolve needs to compute a value (per-request derived state is the canonical case).

The first three live in service files. The fourth lives in adapters. Mix them freely: a controller that is @Service-decorated can @Autowired another service and @Inject a token in the same constructor.

Tokens vs classes

Why bother with createToken<MailerService>('MailerService') when you could @Autowired(MailerService) directly? Three reasons.

TypeScript has no nominal typing. Two interfaces with the same shape are the same interface. If you want DI to bind to "the mailer," not to "anything that happens to have send(message)," you need an explicit identity. createToken returns a frozen object that is identity-keyed — that is the whole point of it.

Cross-package interfaces. Implementations often live in adapter packages; consumers often live in domain or use-case packages. The use-case should not import the concrete class. It should import the interface (or a type-only re-export) plus the token. The token is the only runtime value crossing the boundary; everything else is a type.

Multiple implementations behind one interface. When you have a ConsoleProvider for dev and a real provider for prod, @Inject(MAILER) always resolves to whichever the adapter registered. The use-case never names a class, and swapping providers is a one-line change at the composition root.

Rule of thumb: classes for use-cases composing other use-cases inside the same app; tokens for anything that crosses an adapter boundary, has multiple implementations, or is interface-typed.

Singleton vs Scope.REQUEST

registerInstance is implicitly singleton — one value, lives until shutdown. registerFactory defaults to singleton too (the factory runs once, the result is cached), but pass Scope.REQUEST and the factory runs every request, with the result cached for the duration of that request frame.

When does each fit?

Singleton — app-level handles. A database pool, a mailer, a secrets provider, an HTTP client, a feature-flag SDK. Anything you build once, reuse forever, and tear down on shutdown. These have no per-request variance; they are shared, expensive to build, and rebuilding them between requests would be wasteful.

Scope.REQUEST — per-request derived state. Anything whose value depends on which request is in flight. A current-user object derived from the token. A request-scoped logger that prefixes log lines with the request ID. A correlation-context object. A "which one of these N pooled resources does this request want" selector. The factory runs once per request, the result is reused for that request's lifetime, and nothing leaks between requests.

The win of Scope.REQUEST over "stash it on the request object and pass it around" is that injection points stay clean. A use-case ten levels deep can @Autowired a request-scoped token and get the right value; no signature in the call chain has to thread request state through. The framework keeps a per-request DI scope alive between the request entering the kernel and the response being flushed.

Adapter-side registration

Adapters are KickJS's lifecycle primitive. They expose hooks (beforeStart, middleware, shutdown) that the kernel calls in a defined order. beforeStart({ container }) is where you populate the DI graph. The contract: beforeStart runs after every module's register() (so all @Service classes are known) but before the HTTP server listens (so the first request resolves a fully-wired graph).

The simplest adapter just registers a singleton instance:

const mailerAdapter = defineAdapter<MailerConfig>({
  name: 'MailerAdapter',
  build(config) {
    return {
      beforeStart({ container }) {
        container.registerInstance(MAILER, new MailerService(config))
      },
    }
  },
})
Enter fullscreen mode Exit fullscreen mode

Three things to notice. The adapter receives its config at factory invocation time, so the composition root decides whether dev gets a console provider and prod gets a real one. The only runtime side effect is the registerInstance call. And no use-case imports the concrete class — @Inject(MAILER) is the entire contract.

The per-request factory pattern

Now the centerpiece. The pattern shows up any time you have a process-wide pool of resources and a per-request rule for picking one of them. A few examples:

  • A multi-tenant app with one DB client per tenant, where the active tenant is resolved from the request.
  • A feature-flag SDK that needs to evaluate flags against the current user, where "current user" is per-request.
  • A logger that prefixes structured fields drawn from request context.
  • A unit-of-work / transaction scope that should be the same instance for the whole request.

The shape is always the same: one process-wide registry (singleton), one factory that reads request state and returns the right slice of it (Scope.REQUEST), one token that injection sites bind to.

export const REQUEST_DB = createToken<DbHandle>('RequestDb')

// inside an adapter's beforeStart
const registry = this.dbRegistry // singleton: a Map<key, DbHandle>
container.registerFactory(
  REQUEST_DB,
  () => {
    const ctx = getRequestValue('ctx')
    if (!ctx) throw new HttpException(400, 'REQUEST_DB used outside a request scope')
    return registry.get(ctx.key)
  },
  Scope.REQUEST,
)
Enter fullscreen mode Exit fullscreen mode

Three moving parts deserve attention.

The registry is a singleton. It is built once at boot and lives until shutdown. It might cache promises so concurrent first-time resolves share the same open. It typically exposes a closeAll() for the adapter's shutdown hook. The pool itself is shared; what is request-scoped is the selection.

The factory closure reads request state. getRequestValue('ctx') (or however the framework exposes per-request key/value storage) is populated upstream — by middleware, a contributor, or a guard that runs before any handler. By the time a use-case resolves the token, the value is already there, the registry hits its cache, and the factory returns the same handle every other in-flight request for the same key is using.

Failing loudly outside a request. Calling the token outside a request frame — a background worker, a CLI script, a test that forgot to set up the scope — has no request state to read. Returning undefined would push the failure into a confusing null-pointer crash deep inside a use-case. Throwing at resolve time makes the bug unmistakable: "you used this from outside a request scope." Background callers that legitimately need the resource have to either run inside an explicit request scope or call the registry directly. Either is fine; neither is a silent no-op.

The injection site stays oblivious to all of this:

@Service()
export class CreateOrderUseCase {
  @Autowired(REQUEST_DB)
  private readonly db!: DbHandle

  async execute(input: CreateOrderDTO) {
    return this.db.transaction(async (tx) => { /* ... */ })
  }
}
Enter fullscreen mode Exit fullscreen mode

No resolver call, no plumbing of context through the signature, no reaching for global state. Just an injected handle that happens to be the right one for this request.

The test seam

The same surface gives you a clean test seam. In a test adapter, register an instance instead of a factory:

container.registerInstance(REQUEST_DB, fakeHandle)
Enter fullscreen mode Exit fullscreen mode

registerInstance overrides whatever was registered before, so the stub wins over the real factory. Tests get a deterministic in-memory handle without spinning up infrastructure or mocking the request-state accessor. The use-case under test is none the wiser — same token, different binding, different scope (singleton in tests, request in production), and the production code does not change.

This is the payoff of token-based DI: the contract between caller and provider is one frozen object reference, and you can swap providers along any axis — environment, scope, mock vs real — without touching either side.

Putting it together

If you remember three things:

  1. Use the right surface for the right job. @Service and @Autowired for in-app composition; @Inject(token) for cross-boundary interfaces and pluggable implementations; registerInstance / registerFactory for adapters bringing in things the graph cannot build itself.
  2. Match scope to variance. Singleton for things that do not vary per request. Scope.REQUEST for things that do. The factory closure is your bridge between the two scopes.
  3. Keep injection sites scope-agnostic. A use-case asking for a request-scoped token should look identical to one asking for a singleton. The token is the contract; everything else is the adapter's problem.

Once those three habits stick, DI stops being a topic you think about and becomes the boring plumbing it is supposed to be — which is exactly when it earns its keep.

References

Top comments (0)