Modules & DI
A module groups one feature and lists what it owns. It's a class that extends Module({...}) with a
config object naming the classes the feature is built from — its services, its controllers, whatever it
exposes to other modules. That one list is the registration: there's no decorator to add to each class
and no central registry to keep in sync.
import { Module } from "@heximon/runtime";
import { UsersController } from "./users.controller";
import { UsersRepository } from "./users.repository";
export class UsersModule extends Module({
providers: [UsersRepository],
http: { controllers: [UsersController] },
exports: [UsersRepository],
}) {}
That's a complete feature: a repository it constructs, a controller it serves, and the repository made available to other modules. The class body stays empty — everything lives in the config, because that's the part Heximon reads to wire the app.
Providers are plain classes, wired by constructor
providers holds the plain classes the module constructs — services, repositories, factories. A class
becomes injectable simply by being listed here; there's no decorator and no token to declare. Declare
the dependency as a constructor parameter typed by the class you want, and the compiler matches the type
to a provider by identity and passes the right instance in — no token, no decorator.
import type { Controller, Get } from "@heximon/http";
export class UsersController implements Controller<"/users"> {
// resolved by class identity — no token, no decorator
constructor(private readonly users: UsersRepository) {}
public async list(_action: Get<"/", { scopes: ["public"] }>): Promise<User[]> {
return this.users.list();
}
}
The repository itself is just as plain — no Heximon imports at all. Heximon constructs it once and shares that single instance with every consumer.
export interface User {
readonly id: string;
readonly name: string;
}
export class UsersRepository {
private readonly usersById = new Map<string, User>();
public async list(): Promise<User[]> {
return [...this.usersById.values()];
}
}
UsersRepository is a provider; UsersController is listed under http. The distinction matters:
providers is for plain injectables, while controllers, event handlers, command handlers, and the like
go under their feature's key (http, events, cqrs, …) so Heximon knows what kind of thing each is.
That class is also a provider, resolvable exactly like an entry in providers, which is why you
never list a controller twice. Putting a controller in providers would construct it but never route to it.
Every module config draws from the same small set of kernel fields, all optional:
| Field | Purpose |
|---|---|
providers | The plain classes this module constructs and owns. |
imports | Other modules whose exported providers become visible here. |
exports | Providers (or whole modules) this module makes visible to its importers. |
requires | Tokens this module expects its host to supply, declared but not provided here. |
overrides | Test-only substitutes for real providers — see Testing. |
A module's classes can inject its own providers plus everything its imports export — nothing more.
That boundary is the point: it keeps each feature's internals private and makes every cross-module
dependency an explicit, visible edge.
Config errors are field-level. The Module(...) call is generically typed, so a broken dependency lands
as an editor error on the offending entry — naming the missing type or the misspelled key — rather than the
opaque "Expected 2 arguments, but got 1" an untyped guard produces. See
What the editor catches below for the full list.
Share a provider with imports and exports
A provider is private to its module by default. To let another module inject it, list it in exports;
to gain access, import the module that exports it. Both halves are required, so every shared
dependency is an intentional, two-sided agreement.
import { Module } from "@heximon/runtime";
import { DatabaseConnection } from "./database-connection";
export class DatabaseModule extends Module({
providers: [DatabaseConnection],
exports: [DatabaseConnection], // visible to any module that imports DatabaseModule
}) {}
import { Module } from "@heximon/runtime";
import { DatabaseModule } from "../database/database.module";
import { UsersRepository } from "./users.repository";
export class UsersModule extends Module({
imports: [DatabaseModule], // brings DatabaseConnection into view
providers: [UsersRepository], // its constructor can now inject DatabaseConnection
}) {}
Importing DatabaseModule makes only its exports visible — DatabaseConnection, not whatever else
DatabaseModule keeps to itself. If you export a token the module doesn't own (it isn't your provider
or an imported module's export), that's a build error, so a module can never promise something it can't
deliver.
A module can also re-export an imported module wholesale: list the module itself in exports, and its
entire export set flows onward to anyone importing yours. That's how a shared "infrastructure" module
forwards a database, a cache, and a clock as a single import.
Declare what you expect the host to supply: requires
requires is for a module that expects a token from outside itself — a published feature module, or a
bounded-context module that deliberately doesn't own its persistence choice. It's a promise in the other
direction from exports: instead of "here's something I provide", it's "here's something I need someone
else to".
import { Module } from "@heximon/runtime";
import { BillingDatabase } from "./billing-database";
import { BillingRepository, BillingService } from "./billing.service";
export class BillingModule extends Module({
requires: [BillingDatabase], // the composing app must provide this
providers: [BillingRepository, BillingService],
exports: [BillingService],
}) {}
The propagation rule: a token flows only along a declared edge, one hop at a time. If a module you
import declares a requires, your module must do one of two things — provide that token itself, or
re-declare it under your own requires so the obligation passes one hop further up. Nothing tunnels through
an imports edge unnoticed: the editor checks this the moment you compose, and the compiler enforces the
same rule at build time — a hop that drops a token fails the build naming the module that dropped it, and a
root module's leftover requires (nothing above it to pass to) fails the same way.
The flagship example's MonolithModule is the canonical case: it bundles bounded-context modules
(CatalogModule, OrdersModule, PaymentsModule, …), each of which declares its own persistence and
platform seams under requires — but MonolithModule itself binds no database and no platform adapter.
It must re-declare every one of those tokens under its own requires, so the app that finally composes
MonolithModule sees exactly what it owes:
export class MonolithModule extends Module({
requires: [
Storage,
Cache,
EventRepository, // required by the imported CatalogModule
OrderRepository, // required by the imported OrdersModule
Database, // required by the imported OrdersModule
PaymentRepository, // required by the imported PaymentsModule
PaymentGateway, // required by the imported PaymentsModule
BlobStore, // required by the imported BannerModule
],
imports: [
CatalogModule,
OrdersModule,
PaymentsModule,
// …
],
// …
}) {}
When a bundle forgets one of these — say it re-declares only Storage and Cache — the editor flags the
offending imports entry immediately, naming each unmet token, and a build reports the same hop as
graph.requires-not-propagated. The fix is always the same: provide the token in the bundle, or name it
under the bundle's own requires to pass the obligation up.
exports does not automatically include a module's namespace concept classes (controllers, handlers,
…) — only providers and re-exported imports. If a module wants an importer to reach a handler class
directly (for inspection in a test, say), it must list that class in exports explicitly, even though the
class is already DI-visible inside its own module.Compose the app from a root module
A root module composes feature modules through imports. It usually declares nothing of its own — it
just names the features that make up the app.
import { Module } from "@heximon/runtime";
import { CatalogModule } from "./catalog/catalog.module";
import { UsersModule } from "./users/users.module";
export class AppModule extends Module({
imports: [UsersModule, CatalogModule],
}) {}
The root is just the module no other module imports — Heximon picks it up as your app automatically, with
no entry file to register it. From here Heximon follows the imports to build one dependency graph for
the whole application: every constructor parameter across every module is resolved into the single
new Foo(bar) chain that boots your app.
A root module can also carry feature keys of its own. Module-level HTTP middleware, for instance, propagates down the import graph and wraps every inherited route — useful for a request logger that should run app-wide:
import { Module } from "@heximon/runtime";
import { RequestLogMiddleware } from "./request-log.middleware";
import { UsersModule } from "./users/users.module";
export class AppModule extends Module({
imports: [UsersModule],
http: { middlewares: [RequestLogMiddleware] }, // wraps every route under UsersModule
}) {}
How a module becomes wiring
A module config is a declaration, not code that runs. Heximon reads the imports, providers,
exports, and feature keys directly from your source to build the dependency graph — it never executes
the module to discover what's inside. Two consequences follow, and both are deliberate.
First, those arrays must be literal in the source — no conditionally assembled imports, no
computed provider lists. That's what lets the wiring be settled before your app starts, so there's no
container resolving the graph at runtime and nothing to warm up on boot.
Second, a class is only recognized by being listed under the right key — a controller because it
sits in http: { controllers }, an event handler because it sits in events: { handlers }. There's no
whole-codebase scan and no ambient magic: every part of your app is something a module explicitly named.
When you want to know what a feature contains, you read its module — the list is the truth.
What the editor catches, and what stays build-time
Module(...)'s generic typing is an authoring aid that runs entirely in your editor, before the compiler
ever sees your code. As you type, it catches: a constructor dependency (in providers, in a namespace
concept class, or in a useFactory's parameters) that nothing visible provides; an exports entry nothing
backs; a useValue/useFactory result that needs eager: true and doesn't have it; a misspelled top-level
key.
It also catches the requires propagation rule above — an imported module's unmet requirement.
The compiler enforces the propagation rule too (graph.requires-not-propagated per dropped hop,
app.unmet-requires for a root's own leftovers), so a hop the editor misses still fails the build.
A few things are deliberately left to the build-time compiler, which remains the real source of truth for the DI graph:
- Ambiguity and cycles. The editor check is existential — it only asks "is some visible provider a
match?", so it can't notice that two providers satisfy the same token, or that two modules import each
other. The compiler's
DiagnosticCode.AmbiguousProvidersandDependencyCyclediagnostics catch those. - Union-typed constructor parameters. A parameter typed as a union of classes is rejected by the editor check outright, rather than partially validated — the compiler's own resolution is the real check there.
- Factory-form module imports. Importing a parameterized module instance (
new FeatureModule(config)) neutralizes the editor's dependency and export checks for that one import — the compiler still validates it at build time. - Marker tokens with no fields. An empty class used purely as a DI marker structurally matches any
visible instance in the editor's eyes; the compiler's
NoProviderdiagnostic is the real backstop until the token carries at least one member.
None of these are silent in the end — every one still fails the build (pnpm check / vp dev / CI) if it's
wrong. The editor check just narrows how early you find out.
Compiled verdicts come back into the editor
The build-time-only list above shrinks in practice: every compile (vp dev / vp build) also writes a
generated declaration file that mirrors the compiler's error verdicts back to their natural user-code
sites.
A module whose last compile found a defect — an ambiguity, a transitive no-provider, an unmet root
requirement — gets the verdict reported at its own class declaration; a dirty precompiled module gets
it at the imports entry that pulls it in.
The same file brands every detected marker token with an optional heximonToken member, closing the
structural-match hole for tokens the compiler has seen. The file is additive-only and gracefully absent: on
a fresh clone (before the first compile) nothing fires, and a compile after a fix rewrites it clean. The
first compile adds the generated-directory include entry to your tsconfig.json for you.
Advanced provider forms
A bare class provider covers most of an app. Reach for the { provide, use* } form when the token and
the implementation should differ — an abstract base bound to a concrete class, a value computed at boot,
an existing instance reused:
| Form | When to use |
|---|---|
| Bare class | The class is its own token and Heximon can read its constructor. |
useClass | Bind an abstract token to a concrete, swappable implementation. |
useFactory | The constructor isn't Heximon-visible, or you need async or explicit build logic. |
useValue | Supply a pre-built value — a config object, a shared instance. |
useExisting | Alias one token to another provider's instance, with no second construction. |
Bind an abstract token to a concrete class — the repository pattern: the abstract base is the stable seam your service depends on, and the concrete class is swappable per environment (an in-memory store in tests, Drizzle in production) without touching a consumer.
providers: [{ provide: UsersRepository, useClass: DrizzleUsersRepository }]
export class UsersService {
// typed as the abstract UsersRepository; gets DrizzleUsersRepository at runtime
public constructor(private readonly users: UsersRepository) {}
}
Satisfaction follows the provider's whole extends/implements chain — including a chain that lives
entirely inside an installed package, read from its shipped type declarations. Two bindings reaching the
same base are an AmbiguousProviders build error, never a silent pick.
Generic tokens need a named subclass. Class identity is the only token, and type arguments are
erased for resolution — a parameter typed Repository<User> resolves the bare Repository identity,
so Repository<User> and Repository<Order> are the same token, and injecting a parameterized generic
base is a compile error. Bind a specific parameterization by subclassing the generic base into a named
concrete token and injecting that instead — the same pitfall shows up with @heximon/drizzle's generic
DrizzleLibSQLConfig, worked through in full on Advanced DI.
useFactory's parameter types are its dependency declaration, exactly like a constructor, so there's
no separate inject array; a factory result and a useValue value are opaque to the compiler, so
eager-boot work on either needs an explicit eager: true flag — see
Application Lifecycle.
useExisting aliases a second token to an already-provided instance with no second construction, and an
array provide collapses several aliases of the same provider into one entry.
Configure a module at its import site. Most modules build their dependencies from a fixed config
value and never need a parameter. When the same module must be imported with different values in
different places, use the factory form — Module((config) => ({...})) — and thread the supplied
config into a useValue or useFactory provider, configured per import with new SomeModule(config).
useFactory over an imported config value is the
simpler idiom — and the one most apps stay on.Swap a provider in a test with createTestApp({ overrides }), keyed by the class-identity token — an
override replaces a DI token everywhere it is injected, from same-module construction to app.get(Token).
See Testing for the full harness, including the one real limit: a precompiled
package is overridable only at its declared requires/exports seams, never its internals.
For multi-dependency factories, the generic-token pitfall worked through end to end, useExisting/array
provide with real fences, the factory-form module in full, and overriding the error envelope, see
Advanced DI.
See also
- Application Lifecycle —
createApp, the app handle, and theonInit/onShutdownhooks Heximon weaves into the wiring. - Controllers — what goes under the
httpkey, and the two ways to declare routes. - Testing —
createTestApp, overrides, and the full test harness. - Example L01 — minimal — one feature module plus a root module with app-wide middleware, the smallest complete app.
- Example L02 — HTTP validation — two feature modules composed by a root module, each owning its own providers and controller.
- Example L07 — auth
— async
useFactoryproviders that generate a signing key pair at boot and split it across a signer and a verifier.
Troubleshooting
Fixes for the common first-run issues — missing routes, unresolved dependencies, validation 400s, a hanging middleware, a failed build after an edit — plus deeper diagnostics for contracts, scopes, and context.
Controllers
Build the HTTP surface with Controllers — routes declared by the handler's action parameter type, the HttpAction dispatcher, middleware, error filters, and contract binding.