Advanced DI
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.
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:
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:
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.
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).
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],
})) {}
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.
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:
{
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 useFactory — createApp 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.
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:
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> {}
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:
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/requiresand the editor-vs-build-time DI checks. - Application Lifecycle —
OnInit/OnBootstrap/OnShutdown, the two-phase boot, and the full eager-provider pictureeager: trueopts into. - Error Handling — throwing framework errors,
ErrorFilter, and the RFC 9457 envelope this page's override section customizes. - Testing —
createTestApp({ overrides }), the test-only substitute for any provider form covered here, keyed by the same class-identity tokens.
AI Skills
The Heximon Agent Skill at docs/skills/heximon, its SKILL.md and references, and the llms.txt and MCP endpoints Docus serves for this site.
Send an Email
Inject the abstract EmailTransport DI token, compose an EmailMessage with Mustache placeholders, and send it — with a capture transport for dev/tests and ResendTransport as the one-line production swap.