Modules & Dependency Injection
One @Module declaration says what exists, who owns it, and what it may reach. It is the only place ownership is decided: a class no module lists does not exist — the build refuses it by name, and it never reaches the route table, the dispatcher or the OpenAPI document. Being in a particular folder grants nothing.
A dependency is named by its constructor parameter's TYPE, and by nothing else. There is no @Inject, no useFactory, no forwardRef and no token registry, and none of them is needed.
The four lists
The file is named after the domain and lives with it: modules/notes/ holds the module, the controller, the service and the schemas that belong to notes, and nothing else.
// modules/notes/notes.module.ts
import { Module } from "@palbase/backend";
import { NotesController } from "./notes.controller";
import { DbNoteRepo, NoteService } from "./notes.service";
@Module({
controllers: [NotesController],
providers: [NoteService, DbNoteRepo],
exports: [NoteService], // the one class another module may reach
imports: [], // whose exports THIS module may reach
})
export class NotesModule {}
The entry point it names is an ordinary exported class. Nothing in it registers anything, and it takes the service it needs as a constructor parameter:
// modules/notes/notes.controller.ts
import { Controller, Get, NotFound, Param } from "@palbase/backend";
import { NoteSchema } from "./dto/note";
import { NoteService } from "./notes.service";
@Controller("/notes")
export class NotesController {
constructor(private readonly notes: NoteService) {}
@Get("/{id}")
async get(@Param("id") id: string): Promise<NoteSchema> {
const note = await this.notes.get(id);
if (!note) throw new NotFound("no note with that id");
return note;
}
}
The response schema it names is a zod value in the module's own dto/ — see Request Validation for that half:
// modules/notes/dto/note.ts
import { z } from "@palbase/backend";
export const NoteSchema = z.object({
id: z.string(),
user_id: z.string(),
body: z.string(),
created_at: z.string(),
});
export type NoteSchema = z.infer<typeof NoteSchema>;
Four lists answer four different questions, and nothing else answers them:
| List | The question it answers |
|---|---|
providers | which classes does this module own |
controllers | which entry points does it own |
exports | which of its own classes may another module reach |
imports | whose exports may this module reach |
@Job, @Webhook, @Hook and @Room classes go in providers, beside the services — they are classes the module owns, and the decorator is what says which kind they are. There is no jobs/, webhooks/ or hooks/ directory and nothing reads one. The only thing in the product read by location is the schema: db/*.ts, one file per schema.
Note: The lists are typed, so there is nothing to cast. Every entry is a
Token, which isabstract new (...args: never[]) => T— an identity you can name but not call. Any class satisfies it and no other value does, so a non-class in one of these arrays is a compile error on the line you wrote it. Casting an entry toTokenis not required by anything and only suppresses the check that makes the list worth having.
A class no module lists does not exist
This is the rule everything else follows from, and it has three shapes — one per kind of class — because the same silence used to be possible in three different ways.
An entry point nothing claims is refused when the container is built:
OrphanWebhook is decorated as an entry point but listed in no module, so nothing
decides whether it should be served.
→ add it to a module's `controllers` (for @Controller) or `providers`
(for @Room/@Job/@Hook/@Webhook)
An @Injectable() nothing claims is refused too, for the same reason and by name:
PricingService is marked @Injectable() but listed in no module's providers, so
nothing can reach it.
→ add PricingService to a module's `providers`
→ or delete the class — one no module lists is never built
A class listed by two modules is refused as well: which one owns it would have two answers. Keep one owner, export the class from it, and import that module from the other.
That the file system decides nothing is what closes an old failure. jobs/ used to be the discovery, and nothing reads it now: back then a class could carry @Job, sit outside the directory, and never run — or sit inside it, be claimed by nothing, and run anyway. The module answers ownership and the decorator answers kind; one declaration, one answer.
exports and imports — the boundary
A module's classes are private by default. Nothing outside it can depend on them until a name appears in exports, and the module that wants one has to name the owning module in its imports.
The scaffold ships the one worked example of this, and it is worth reading as a pair. NotesModule above puts NoteService in exports — that is the promise. A second domain then names the module in its imports:
// modules/digest/digest.module.ts
import { Module } from "@palbase/backend";
import { NotesModule } from "../notes/notes.module";
import { NotesDigestJob } from "./digest.job";
@Module({
controllers: [], // a domain need not have an HTTP surface
providers: [NotesDigestJob], // what THIS module owns
exports: [],
imports: [NotesModule], // may now reach NotesModule's exports
})
export class DigestModule {}
// modules/digest/digest.job.ts
import { Job, Log, type JobMeta } from "@palbase/backend";
import { NoteService } from "../notes/notes.service";
// A @Job is an ordinary class: its dependency arrives through the constructor,
// and another module owns it. Both halves are required — the export and the
// import — and the container says which one is missing.
@Job({ name: "notes-digest", schedule: "0 8 * * *" })
export class NotesDigestJob {
constructor(private readonly notes: NoteService) {}
async run(meta: JobMeta): Promise<void> {
Log.info(`notes digest: ${await this.notes.countAll()} note(s)`);
}
}
Miss either half and the build says which half:
NotesDigestJob constructor, parameter 0: NoteService is owned by NotesModule,
which DigestModule does not import.
→ add NotesModule to DigestModule.imports
NotesDigestJob constructor, parameter 0: DbNoteRepo is internal to
NotesModule — it is not exported.
→ use one of NotesModule's exported classes
→ or add DbNoteRepo to NotesModule.exports (and say why it should be public)
Two further rules keep the boundary honest:
- A module may only export what it owns. Re-exporting another module's class would put the owner's decision about who may reach it in somebody else's file.
- An
importsentry must BE a module. Naming a class there used to build cleanly and do nothing — the name simply never matched an owner, so every dependency it was meant to unlock kept being refused for a reason that pointed elsewhere. Importing yourself is refused too: it grants nothing you do not already have, so it is always a typo for another name.
An export is a promise. Add a name to exports when another module genuinely needs it, and say why in the commit.
Dependency injection: the constructor is the seam
A class names what it needs as ordinary constructor parameters, and the container supplies them. This is the file the module above lists — all of it, because the two classes in it are two halves of one idea and the next section is about the other half:
// modules/notes/notes.service.ts
import { Database, Injectable } from "@palbase/backend";
import type { Tables } from "@palbase/backend/env";
/** One row of `notes`, exactly as `db/public.ts` declares it. */
export type Note = Tables["notes"]["row"];
/** The seam, ONE table wide: a test fake implements three methods, not the
* whole Database. It is `abstract` so a test can substitute it BY NAME. */
export abstract class NoteRepo {
abstract findMany(where: { user_id: string }): Promise<Note[]>;
abstract findById(id: string): Promise<Note | null>;
abstract countAll(): Promise<number>;
}
@Injectable()
export class DbNoteRepo extends NoteRepo {
findMany(where: { user_id: string }): Promise<Note[]> {
// `where` is a NAMED field, not the whole argument: the same object also
// carries `orderBy`, `limit` and the operators.
return Database.public.notes.findMany({ where });
}
findById(id: string): Promise<Note | null> {
return Database.public.notes.findById(id);
}
async countAll(): Promise<number> {
// A job has no session, so the service role is resolved AT CALL TIME —
// holding `$asService()` in a field would capture the first request's client.
const rows = await Database.$asService().public.notes.findMany({ select: ["id"] });
return rows.length;
}
}
@Injectable()
export class NoteService {
// The container supplies this. Nothing registers it and nothing wires it.
constructor(private readonly notes: NoteRepo) {}
list(userId: string): Promise<Note[]> {
return this.notes.findMany({ user_id: userId });
}
get(id: string): Promise<Note | null> {
return this.notes.findById(id);
}
countAll(): Promise<number> {
return this.notes.countAll();
}
}
@Injectable() is the whole declaration: it says the class can be resolved. It does not decide ownership — a module's providers does that — but it is what makes TypeScript emit the constructor's parameter types, which is what the container reads.
Classes are singletons. There is no transient and no request lifetime: request scope already exists and it is an AsyncLocalStorage, not an object lifetime. What varies per request is the database handle and the claims, and the engine opens that scope around your handler.
That has one consequence worth stating on its own: a constructor is pure wiring. It runs once while the app comes up, it is synchronous, and it does no I/O. Real startup work goes in an onStart() method on the provider itself, which the container awaits before the first request and which refuses the boot by class name if it throws — rather than failing halfway through somebody's first call:
// modules/graph/graph.client.ts
import { Injectable, type OnShutdown, type OnStart } from "@palbase/backend";
@Injectable()
export class GraphClient implements OnStart, OnShutdown {
async onStart(): Promise<void> {
// awaited once, before the first request
}
async onShutdown(): Promise<void> {
// reverse construction order, before the pool closes
}
}
Nothing registers those two methods: the container calls them because the class is one of a module's providers and declares them. implements OnStart is optional — the container looks for the method by name — but it makes the intent readable and catches a typo in the method name. The module-scope onStart(name, hook) and onShutdown(name, hook) functions were removed in 41.0.0; they were the only global verb in the product, and a candidate release loaded beside the live one had to save and restore their registry.
What the container cannot inject
- An
interface. It has no runtime existence, so there is nothing to construct. Use aclassor anabstract class. The build says so: parameter 0 has no runtime class — an interface, a type alias, a union, or a data type. - A data type.
string,number,Date, an options object. An injectable's constructor takes dependencies, not data — move the value to a method argument. - A generic.
Repository<T>has no runtime type to read, so a generic repository is refused outright. - A platform service.
Database,Storage,Cache,Logand the rest are ambient: import them where you need them. They are request-scoped, so a boot-time singleton holding one would capture the first request's client forever. A test replaces them withwithServices({ Database: fake.raw }, …)rather than through the container — see Testing.
Database.public.notes is a value, not a class, so it cannot be a dependency on its own. That is what the next section is for.
The abstraction is the TOKEN, the implementation is the PROVIDER
That is the shape of notes.service.ts above: one narrow abstraction as an abstract class (NoteRepo), and one concrete class that extends it (DbNoteRepo). The abstraction names the three methods this vertical actually needs, so a test fake implements three methods rather than the whole Database, and abstract is what lets a test substitute it BY NAME.
The module lists DbNoteRepo, and does not list NoteRepo — read the providers line in the first example again: it names NoteService and DbNoteRepo, and the abstraction appears in neither list.
NoteService names NoteRepo in its constructor, and the container resolves the abstraction to the single owned class that extends it. The relationship is read from the prototype chain, which is what extends builds — a declaration the class makes about itself, not an inference from where its file sits.
Both failure modes are refused at build, by name:
| The graph | What the build says |
|---|---|
Zero classes extend NoteRepo | NoteRepo belongs to no module, and nothing in this graph extends it — so there is nothing to construct. |
Two classes extend NoteRepo | DbNoteRepo and MemoryNoteRepo both extend NoteRepo, so which one NoteService should receive has two answers. |
Listing the abstract class itself in providers is refused too, and that refusal is worth understanding: abstract is a type-level claim with nothing behind it at runtime. new NoteRepo() succeeds in JavaScript and returns an object missing every abstract member, which then gets injected into everything that asked for one — and the failure surfaces mid-request as repo.findMany is not a function, far from the file that caused it.
If the abstraction lives in another module, the implementation is what has to be exported: the container resolves NoteRepo to DbNoteRepo, and it is DbNoteRepo's visibility it then checks.
Why there is no @Inject, useFactory or forwardRef
Each of them exists in other containers to rescue a shape this one does not have.
-
@Inject(TOKEN)exists where a dependency has no runtime type to name it by. Here it always has one: the parameter's type. A second naming mechanism would be a second answer to the same question. -
useFactoryexists to build something the container cannot. The container builds classes; anything else you would produce in a factory is either data (pass it to a method) or a resource you open at boot (onStart). -
forwardRefexists to survive a circular import. A real cycle cannot be built here — an ESM cycle dies at import, before the container is ever consulted (Cannot access 'CB' before initialization). What can still be assembled is a cycle inside a single file, and that is refused with the path written out:the dependency graph contains a cycle: NoteService -> AuditService -> NoteService. → extract the shared part into a third class both can depend on → or invert one direction — have the callee raise an event the caller listens forThere is nothing for
forwardRefto rescue, so there is no way to keep a cycle.
emitDecoratorMetadata is what makes injection work
The container reads a constructor's parameter types from design:paramtypes, which only legacy decorators emit. The scaffold's tsconfig.json sets both flags for that reason:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Without the metadata every injected field arrives undefined. The build refuses rather than letting that ship, and it distinguishes the two faults, because they look identical one class at a time and lead to opposite fixes:
no class carries constructor metadata — every one of the 7 class(es) that asks
for a dependency is missing it, so this is the build, not the classes.
→ set `emitDecoratorMetadata: true` in the project's tsconfig.json
→ and import `reflect-metadata` before any decorated class evaluates
PricingService declares 1 parameter(s) but carries no metadata entries, so every
parameter would arrive as undefined.
→ add @Injectable() to PricingService — metadata is emitted for DECORATED
classes only
Metadata is emitted for decorated classes only, which is also why a provider carrying no decorator at all is refused: the container could know neither that it is constructible nor what its constructor asks for.
A domain is a folder. There is no root module.
There is no app.module.ts and nothing to mount one into. Other frameworks have a root module because they mount a tree: the app is what that one file lists. Here every *.module.ts is found wherever it lives, so a file named after the app would root nothing, list nothing, and teach a shape the runtime does not have.
The rule has no exceptions, which is the point: every domain is a folder under modules/, including the health probe the scaffold ships as modules/health/. Adding a domain is adding a folder — never a line in a file every branch edits, which is the merge conflict a module system exists to avoid.
modules/
├── notes/
│ ├── notes.module.ts # the declaration
│ ├── notes.controller.ts # listed in `controllers`
│ ├── notes.service.ts # listed in `providers`
│ ├── notes.service.test.ts # unit test, beside the code it tests
│ ├── notes.e2e.test.ts # the suite the DEPLOY runs
│ └── dto/
│ ├── create.ts # zod schemas — nothing discovers dto/
│ └── note.ts
├── digest/
│ ├── digest.module.ts # imports NotesModule
│ └── digest.job.ts # a @Job, listed in `providers`
└── health/
├── health.module.ts
└── health.controller.ts
The file suffix is a convention that helps a reader; it decides nothing. What decides is the module list a class appears in, and the decorator it carries.
Testing through the seam
Because the constructor is the seam, a test hands in a stand-in and never touches a database:
// modules/notes/notes.service.test.ts
import { describe, expect, it } from "bun:test";
import { isolated } from "@palbase/backend/test";
import { type Note, NoteRepo, NoteService } from "./notes.service";
class FakeRepo extends NoteRepo {
readonly seen: unknown[] = [];
async findMany(where: { user_id: string }): Promise<Note[]> {
this.seen.push(where);
return [];
}
async findById(): Promise<Note | null> {
return null;
}
async countAll(): Promise<number> {
return 0;
}
}
describe("NoteService", () => {
it("asks only for the caller's notes", async () => {
const repo = new FakeRepo();
const svc = isolated().with(NoteRepo, repo).get(NoteService);
await svc.list("u_1");
expect(repo.seen).toEqual([{ user_id: "u_1" }]);
});
});
isolated() rebuilds the graph with the override in place. The substitution is deep — it works the same when the substituted class is two hops below the class under test — and it touches no process-wide state, so the next test does not meet whatever this one substituted. It also builds its graph without consulting the module declarations at all, deliberately: a unit test is not a second opinion about the architecture.
See Testing for fakeDatabase() and the suites the deploy grades.
Related
- Overview — the authoring model, and what the deploy reads
- Controllers & Routing — the classes
controllerslists - Scheduled Jobs · Webhooks · Event Hooks — the surfaces that go in
providers - Testing —
isolated(),withServices(), and the deploy's own suites - Project Structure — where a module sits in the checkout
- Architecture — the layer contract this shape exists to hold