Heximon Logo
Under the Hood

Advanced DI

The nominal-satisfier walk (a concrete subclass satisfies an abstract token, AmbiguousProviders on a tie), array-provide, useExisting aliasing, useFactory with multiple dependencies, the generic-token pitfall (DrizzleLibSQLConfig and named subclasses), useValue with eager, factory-form Module((config) => ({...})) configs, and overriding the ErrorEnvelope.

Modules & DI covers the provider forms every app reaches for — bare classes, useClass, a single-dependency useFactory. This page is the full depth behind that survey: how providing one concrete class can satisfy an abstract token with no alias in sight, how to route several abstract tokens to one provider, how to alias one provider under a second name, why a generic class can't be a DI token, and how to reshape the error envelope every response renders through. None of this is exotic — it's the mechanics the framework itself uses once your app grows past its first few modules.

A provider satisfies its abstract base, no alias needed

Class identity is the only DI token, but that doesn't mean every consumer has to ask for the exact concrete class a module provides. When a constructor parameter or an exports entry is typed by an abstract base that no provider names as its own provide token, the compiler looks for the one provider in scope whose token — or useClass implementation — extends or implements that base anywhere up its chain, and binds it there.

examples/ladder/L07-auth/src/auth/auth.module.ts (excerpt)
export class AuthModule extends Module({
  providers: [
    UserAccountStore,
    { provide: PasswordHashingAlgorithm, useClass: Scrypt },
    JWTAuthMiddleware,
    {
      provide: IdentityKeyPair,
      useFactory: (): Promise<IdentityKeyPair> => IdentityKeyPair.resolve(),
    },
    {
      provide: JWTFactory,
      useFactory: (keys: IdentityKeyPair): JWTFactory => new JWTFactory(keys.privateKey),
    },
    {
      provide: AuthContext,
      useFactory: (context: Context, verifier: JWTVerifier): AuthContext =>
        new AuthContext(context, verifier, new CookieTokenSource("accessToken")),
    },
  ],
  http: { controllers: [AuthController, JwksController] },
  exports: [
    AuthContext,
    JWTAuthMiddleware,
    JWTVerifier,
    JWTFactory,
    UserAccountStore,
    PasswordHashingAlgorithm,
  ],
}) {}

No provider here names JWTVerifier as its provide token. JWTFactory extends JWTVerifier — it verifies with the same key it signs with — so the one JWTFactory instance this module constructs is also the provider that satisfies JWTVerifier: the AuthContext factory's verifier: JWTVerifier parameter above resolves to it, and the bare JWTVerifier entry in exports re-exports that same instance for another module to inject. This nominal-satisfier walk crosses a package boundary too — a provider your app lists can satisfy an abstract token a different package's shipped .d.ts declares, as long as its heritage chain reaches it.

Two satisfiers is a build error, not a guess

Exactly one satisfier binds silently. When two providers in scope both nominally satisfy the same abstract token, the compiler refuses to guess which one you meant:

packages/kernel/runtime/test/compile.test.ts (fixture, trimmed)
export abstract class AthleteRepository {
  abstract find(id: string): Promise<unknown>;
}

export class MemoryAthleteRepository extends AthleteRepository {
  async find(id) { return null; }
}

export class MySQL2AthleteRepository extends DrizzleBase implements AthleteRepository {
  async find(id) { return null; }
}

Listing both MemoryAthleteRepository and MySQL2AthleteRepository as bare providers in one module — neither is named AthleteRepository, but both reach it — fails the build:

error [resolve/ambiguous-providers]  AthleteService: ambiguous providers satisfying 'AthleteRepository'
(MemoryAthleteRepository, MySQL2AthleteRepository)
  ╰─ help: disambiguate with useClass/useExisting or narrow the scope.

Disambiguate with an exact binding — { provide: AthleteRepository, useClass: MySQL2AthleteRepository } (or useExisting) — naming the one you want.

That exact binding wins for a reason worth remembering: a { provide: Token, use… } entry for a token's own precise identity is always checked before the compiler starts the nominal-satisfier walk, so binding the abstract identity once is also how you break a tie the walk can't resolve on its own. This ordering is why the transactional outbox's relay in Integration Events can type its dependency by the abstract IntegrationEventTransport token and simply let an exact { provide: IntegrationEventTransport, useFactory: … } binding resolve it, with no bare-listed satisfier around to disambiguate from.

Array provide: alias several tokens at once

When a composition root must route several abstract tokens to the same provider, an array provide collapses what would otherwise be repeated useExisting lines into one entry. The compiler desugars it at parse time, so the implementation is still constructed exactly once no matter how many tokens alias it:

packages/kernel/runtime/test/fixtures/array-provide/array-provide.module.ts
export class ArrayProvideModule extends Module({
  providers: [
    TargetService,
    { provide: [AlphaService, BetaToken, GammaToken], useClass: AlphaService },
    { provide: [DeltaToken, EpsilonToken], useExisting: TargetService },
  ],
  exports: [AlphaService, BetaToken, GammaToken, TargetService, DeltaToken, EpsilonToken],
}) {}

With useClass, the first listed token (AlphaService) gets the real binding and the rest (BetaToken, GammaToken) become useExisting aliases pointing back at it. With useExisting, every listed token (DeltaToken, EpsilonToken) aliases the named target (TargetService) directly — there's no "first token" distinction because useExisting never constructs anything of its own.

useExisting: alias one token to another provider's instance

useExisting makes a second token resolve to an already-provided instance, with no second construction — useful when a concrete implementation needs to answer to an abstract token too, without re-running its constructor:

providers: [
  DrizzleUsersRepository,
  { provide: UsersRepository, useExisting: DrizzleUsersRepository }, // same instance, two names
]

Both DrizzleUsersRepository and UsersRepository resolve to the one instance the compiler constructs for DrizzleUsersRepository — a consumer injecting either token gets the same object.

useValue and eager boot for opaque providers

useValue binds an already-built value to a token — most often a config object. The compiler can chain-walk a bare class or useClass provider's implementation to see whether it implements OnBootstrap and force-load it at boot automatically. A useValue value (and a useFactory result) is opaque to that walk — the compiler can't read methods off a value it didn't construct — so an opaque provider that implements OnBootstrap needs an explicit eager: true to be force-loaded at createApp instead of waiting for the first request that resolves it.

src/feed/feed.module.ts
import { Context, Module } from "@heximon/runtime";
import { FeedRegistration } from "./feed-registration";
import { ServiceRegistry } from "./service-registry";

export class FeedModule extends Module({
  providers: [
    ServiceRegistry,
    // `FeedRegistration` is built by a factory, so its hooks are opaque — `eager: true` opts it into the
    // boot force-load + onBootstrap dispatch. The TYPE system REQUIRES the flag here (the factory result
    // implements OnBootstrap), so forgetting it is a compile error, not silently-dropped boot work.
    {
      provide: FeedRegistration,
      useFactory: (context: Context, registry: ServiceRegistry) =>
        new FeedRegistration(context, registry),
      eager: true,
    },
  ],
}) {}

The flag is type-required the moment the produced value implements OnBootstrap — omitting it is a Module(...) config error naming the offending entry, so eager-boot work (a queue consumer's poll loop, a scheduler's timers) can never be silently dropped just because it happened to be built by a factory. A useValue/useFactory provider also runs onInit/onShutdown if its value declares them, with no flag needed for those two hooks — only onBootstrap needs eager: true. See Application Lifecycle for the full boot-phase picture this flag participates in.

Factory-form module configs

Most modules build their dependencies from a fixed config value and never need a parameter — a plain useFactory over an imported config value covers that case, and it's the idiom most apps stay on. When the same module must be imported with genuinely different values in different places, use the factory form instead: Module((config) => ({...})), configured at the import site with new SomeModule(config).

packages/kernel/runtime/test/fixtures/parameterized/auth.module.ts
import { Module } from "src/index";
import { AuthConfig } from "./auth-config";
import { AuthService } from "./auth-service";

interface AuthOptions {
  readonly publicKey: string;
}

export class AuthModule extends Module((config: AuthOptions) => ({
  providers: [
    { provide: AuthConfig, useFactory: () => new AuthConfig(config.publicKey) },
    AuthService,
  ],
  exports: [AuthConfig, AuthService],
})) {}
packages/kernel/runtime/test/fixtures/parameterized/app.module.ts
import { Module } from "src/index";
import { AuthModule } from "./auth.module";

export class AppModule extends Module({
  imports: [new AuthModule({ publicKey: "PK-REGISTER-TIME" })],
}) {}

The compiler reads the returned config object's static shape — providers, exports, everything else — the same way it reads a plain object config; the only difference is that the config supplied at the new AuthModule(config) import site is threaded into the module's useValue/useFactory providers, letting two imports entries for the same module class carry two different values.

Importing a parameterized module instance neutralizes the editor's dependency and export checks for that one import — the factory form's config isn't known until the import site, so the editor can't pre-validate it the way it does a plain object config. The compiler still validates it fully at build time.

A factory with several dependencies

A useFactory's parameter types are its dependency declaration, exactly like a constructor — there's no separate inject array to keep in sync. When two providers both need the same upstream value, declare it once and let each factory take it as a parameter; the compiler works out the construction order so both factories see the same instance — the same AuthModule from above is also this pattern in action:

examples/ladder/L07-auth/src/auth/auth.module.ts (excerpt)
{
  provide: IdentityKeyPair,
  useFactory: (): Promise<IdentityKeyPair> => IdentityKeyPair.resolve(),
},
{
  provide: JWTFactory,
  useFactory: (keys: IdentityKeyPair): JWTFactory => new JWTFactory(keys.privateKey),
},
{
  provide: AuthContext,
  useFactory: (context: Context, verifier: JWTVerifier): AuthContext =>
    new AuthContext(context, verifier, new CookieTokenSource("accessToken")),
},

IdentityKeyPair is provided by an async useFactorycreateApp awaits it once at boot, so the whole app shares one freshly resolved key pair. Both JWTFactory and the AuthContext factory depend on downstream values built from that same pair (JWTFactory reads keys.privateKey; AuthContext takes the JWTVerifier that JWTFactory itself satisfies, per the nominal-satisfier walk above) — the compiler works out the construction order from the parameter types alone, so nothing needs a manual sequencing hint.

A generic class is not a token

Class identity is the only DI token, and type arguments are erased for resolution — a parameter typed Repository<User> resolves the bare Repository identity, so Repository<User> and Repository<Order> collapse to the same token. Injecting a parameterized generic base directly is a compile error, because the compiler has no way to know which specialization you meant.

@heximon/drizzle's DrizzleLibSQLConfig<TSchema, TRelations> is the concrete case every app with a database hits: the config class is generic over your schema and relations, so it can never be a token by itself.

examples/ladder/L04-database/src/database/database.config.ts
import { DrizzleLibSQLConfig } from "@heximon/drizzle/libsql/config";
import { relations, schema } from "./schema";

export const databaseConfig = new DrizzleLibSQLConfig(schema, relations, {
  dialect: "sqlite",
  schema: "./src/database/schema.ts",
  out: "./migrations",
  url: ":memory:",
});

export type DatabaseSchema = typeof schema;
export type DatabaseRelations = typeof relations;

databaseConfig is a value here, never injected — it's closed over by a factory, not listed in providers. The database itself needs its own token, and that token is a named subclass that fixes the generic base's type parameters once:

examples/ladder/L06-ddd/src/database/app-database.ts
import { DrizzleLibSQLDatabase } from "@heximon/drizzle/libsql";
import { relations } from "./relations";
import * as schema from "./schema";

// A named concrete token — fixes the generic base's type parameters once.
export class AppDatabase extends DrizzleLibSQLDatabase<typeof schema, typeof relations> {}
examples/ladder/L04-database/src/database/database.module.ts
import { type Context, Module } from "@heximon/runtime";
import { AppDatabase } from "./app-database";
import { databaseConfig } from "./database.config";

export class DatabaseModule extends Module({
  providers: [
    {
      provide: AppDatabase,
      useFactory: (context: Context) => new AppDatabase(databaseConfig, context),
    },
  ],
  exports: [AppDatabase],
}) {}

A repository then injects AppDatabase — a real class-identity token — never the type-erased generic base. This is the same pitfall behind "generic tokens need a named subclass" wherever you wrap a generic framework class: Repository<T>, DrizzleLibSQLDatabase<TSchema, TRelations>, any parameterized base. Fix it once, at the token declaration, and every consumer downstream injects the plain concrete class like any other provider.

Overriding the error envelope

Every HeximonError renders through one chokepoint: ErrorEnvelope, an abstract class bound by default to Rfc9457ErrorEnvelope — the RFC 9457 application/problem+json shape Error Handling covers. Bind your own subclass at the root module's providers to reshape every error response at once — a legacy API that must emit { error, code, message } instead of the standard envelope, say:

packages/api/http/test/error-envelope.e2e.test.ts (excerpt)
import { ErrorEnvelope, type ErrorRenderInput, type ErrorRenderMetadata, type ErrorSchemaDescription } from "@heximon/runtime/errors";

class LegacyEnvelope extends ErrorEnvelope {
  public readonly contentType: string = "application/json";

  public override format(
    input: ErrorRenderInput,
    _metadata: ErrorRenderMetadata,
  ): Record<string, unknown> {
    return { error: input.name, code: input.statusCode, message: input.message };
  }

  public override describe(): ErrorSchemaDescription {
    return {
      jsonSchema: {},
      validate: (value) => ({ value: value as Record<string, unknown> }),
    };
  }
}

Bind it with a provider override — providers: [{ provide: ErrorEnvelope, useClass: LegacyEnvelope }] — and the DI runtime substitutes your class everywhere the envelope is resolved, reshaping the wire body of every error response at once, including the unmatched-route 404.

An envelope override is runtime-only. @heximon/openapi and the FE self-client are both FE-safe, contract-only consumers of a contract's .responses() map; they reflect the default envelope's schema baked at module load. Overriding the envelope does not change the contract, the generated OpenAPI document, or the FE client type — an app that wants its API surface to match a custom envelope shape re-declares matching error schemas in its contracts.

See also

  • Modules & DI — the provider-form survey this page goes deeper on, plus imports/exports/requires and the editor-vs-build-time DI checks.
  • Application LifecycleOnInit/OnBootstrap/OnShutdown, the two-phase boot, and the full eager-provider picture eager: true opts into.
  • Error Handling — throwing framework errors, ErrorFilter, and the RFC 9457 envelope this page's override section customizes.
  • TestingcreateTestApp({ overrides }), the test-only substitute for any provider form covered here, keyed by the same class-identity tokens.
Copyright © 2026