Dependency Injection from Scratch: Building a DI Container for a DDD TypeScript Backend
Building the container in 4 iterations, wiring it into an onion architecture, and using one vocabulary of tokens across 7 processes and many test suites.
Part I, The Problem
1.1 From MVP to architecture
As business requirements grow, the software features required to satisfy them also grow, the more features the bigger the codebase, where architecture starts to matter a lot compared to the "quick to ship" code written in the MVP phase, and when codebases reach a certain size and a certain number of classes and objects floating around, refactoring the smallest class could trigger a domino effect that crashes the whole application, especially when each class manually constructs its dependencies internally, one signature change means N refactors (N is the number of dependents), and we haven't even mentioned the testing nightmare, when you have no way to access and mock your class's dependencies. This is why DI is probably the most important design pattern and architectural technique in your codebase, it's the backbone of maintainable enterprise-level software.
The domino effect, concretely:
export class CreateCategoryService {
async execute(command: CreateCategoryCommand): Promise<void> {
const categoryRepository = new PostgresCategoryRepository(drizzleDb);
// ...
}
}
Now imagine having 7 services that depend on PostgresCategoryRepository and construct it internally, the moment you decide to refactor its constructor signature, you will have to also refactor 7 different services.
And the testing nightmare: now imagine testing the CreateCategoryService class, you have no way of mocking the PostgresCategoryRepository behavior in the service since it's constructed internally, you will have to change the service code, replacing the PostgresCategoryRepository with a mock or else all your tests will become integration by default.
1.2 The thesis: three decisions fused into one new
Every new XXX statement inside a class is silently binding three different questions to the class code:
- What: which implementation of the dependency is used by the class,
PostgresCategoryRepositoryvsMongoDBCategoryRepositoryvsOracleCategoryRepository. - How: is the dependency constructed, the class now MUST know how to construct its dependencies, their constructor signatures,... etc.
- How Long: should that dependency live?
This post is about pulling those three decisions apart, and about the 170-line tool I built to do it.
Part II, First Principles
2.1 What a dependency actually is
A dependency is any external object a class needs to function, it could be a: service, repository, gateway, DB connection, event publisher,... etc.
2.2 Programs as object graphs
We can think of a running program as a graph of objects, a new edge is created in the graph when an object holds a reference to another, a CreateCategoryService object holds the reference of PostgresCategoryRepository, which holds the reference to a Postgres driver or an ORM.
2.3 The hard-coded edge
When the holder constructs what it holds, the edge bakes in a specific class, recipe, and lifetime, and refactoring the smallest node then threatens every edge pointing to it (depending on it, building it internally).
3. The lifecycle question: who is allowed to share my state?
All dependencies have what we call a "lifecycle", which is the duration of how long the dependency instance lives before being disposed and garbage collected in the process, this life duration is decided by a simple question: who can share an instance (state) of this class?
Answer 1, everyone, forever → singleton
Longest-lived; shares state across the whole process; justified by expensive-and-shareable (DB pool, redis client, authentication object) or stateless (repositories if you pass the tx via method parameters, query services, ...etc.).
Answer 2, everyone inside one unit of work → scoped
Created at the start of a unit of work (request, job, poll iteration), disposed at its end; justified by state that must not leak across units of work, and, honestly, it's also justified by defensive isolation even for stateless services.
Answer 3, nobody → transient
Built fresh at every injection point; cheap, stateless, share nothing.
Why three is the complete answer
Any other answer ("shared between these two requests but not those") is a concurrency incident waiting to happen. Three coherent answers, three lifecycles.
Part III, The Taxonomy: DIP, DI, DI Container, IoC
4. The vocabulary problem
People usually use 3 different words interchangeably even though they represent different concepts:
DIP is a principle that says: high level and low level modules (classes, layers) should depend on abstraction rather than concrete implementations, and abstractions must not depend on details. Your interfaces shouldn't be concerned with the details of how they're implemented: a UserRepository doesn't need to know what SQL is, or the database driver/ORM used, ...etc. The abstraction should be written in domain language (the methods of the implementing classes must receive and return domain objects). Abstractions are domain-shaped, detail-free. And notice: there is no dependency injection here yet, DIP is about the direction of dependencies in code, nothing more.
DI (Dependency Injection) is a mechanism/technique to decouple dependency construction from the dependent class, instead of internally constructing dependencies they're supplied from the outside via:
- constructor (most common)
- setter/property injection
- method/parameter injection
DI works well with DIP since DIP forces classes to rely only on the abstract contract, this allows DI to supply any kind of dependency implementation from the outside as long as it satisfies the defined contract (this is useful for testing, and lowers significantly the consequences of refactoring a dependency's signature), it also decouples dependency construction logic from business logic.
IoC (Inversion of Control) is a broader design concept: instead of your code calling libraries and frameworks, you surrender the control flow to an external framework to decide what code runs, when, and what it receives. Examples of IoC:
- Event loops and callbacks: you don't call express to execute your handlers, instead express decides when and which handler to run.
- DI containers: you register definitions, the container decides what objects to create and inject, you don't manually instantiate a class.
A DI container is one specific form of IoC, there are other forms of IoC. In this codebase IoC lives at the process edges, Express owns the request loop and calls my route handlers, BullMQ owns the consumption loop and calls my job processors, while the DI container lives inside, next to the process entry point, supplying the objects those handlers run with.
The taxonomy recap:
- DIP is the principle
- DI is the technique
- the container is the tooling
- IoC is the control-flow shape at the edges
5. Dependency Inversion Principle, the design rule
5.1 The naive top-down arrow
export class CreateOrderService {
constructor() {}
async execute(command: CreateOrderCommand): Promise<OrderId> {
// Replaceable infrastructure detail, frequently changes as the application scales
const orderRepository = new PostgresOrderRepository(drizzleDb);
const cartRepository = new PostgresCartRepository(drizzleDb);
const userRepository = new PostgresUserRepository(drizzleDb);
const fetchHttpClient = new FetchHttpClient();
const shippingProviderGateway = new WorldExpressShippingProviderGateway(
fetchHttpClient,
);
const productRepository = new PostgresProductRepository(drizzleDb);
const idempotencyKeysRepository = new PostgresIdempotencyKeysRepository(
drizzleDb,
);
const outboxRepository = new PostgresOutboxRepository(drizzleDb);
// Stable important business logic, rarely changes
// ...
}
}
The direction of the source-code dependency: the stable, important code (business logic) points at the replaceable detail (infrastructure).
5.2 Martin's two clauses
(a) High-level and low-level modules both depend on abstractions, in our previous implementation, we notice that this clause is violated since the high-level module CreateOrderService is depending on concrete implementations (PostgresOrderRepository, PostgresCartRepository, ...etc.) instead of abstractions (OrderRepository, CartRepository, ...etc. contracts).
(b) Abstractions must not depend on details, details depend on abstractions, the OrderRepository, CartRepository, ...etc. contracts must be written in domain language, they can't contain implementation details like SQL or a specific DB driver or ORM types/syntax.
5.3 The inversion
Before DIP, we had:
Service --imports--> Postgres
After DIP, we have:
Service --imports--> XXXRepository (abstraction) <--implemented by-- PostgresXXXRepository
The word "inversion" points to inverting the direction of the dependency: before, the service pointed at Postgres and conformed to what Postgres offered; now it's the inverse, Postgres (the detail) conforms to what the service defines in OrderRepository.
5.4 Who owns the abstraction
OrderRepository and ShippingProviderGateway contracts live under #/domain/, the contract belongs to the consumer's layer, not the implementer's. An interface sitting next to Postgres would be clause-(a)-compliant but clause-(b)-violating.
// create-order.service.ts
export class CreateOrderService {
constructor(
private db: DBClient,
private orderRepository: OrderRepository,
private cartRepository: CartRepository,
private userRepository: UserRepository,
private shippingProviderGateway: ShippingProviderGateway,
private productRepository: ProductRepository,
private idempotencyKeysRepository: IdempotencyKeysRepository,
private outboxRepository: OutboxRepository,
) {}
async execute(command: CreateOrderCommand): Promise<OrderId> {
// Business logic
// ...
}
}
// order.repository.ts
// defined in domain language
export type OrderRepository = {
find: (id: OrderId, tx?: TransactionClient) => Promise<Order | null>;
findByTracking(
trackingNumber: string,
tx?: TransactionClient,
): Promise<Order | null>;
findMany: (ids: OrderId[]) => Promise<Order[]>;
save: (order: Order, tx?: TransactionClient) => Promise<void>;
delete: (id: OrderId, tx?: TransactionClient) => Promise<void>;
};
// postgres-order-repository.ts
export class PostgresOrderRepository implements OrderRepository {
constructor(private db: DrizzleDBClient) {}
async find(orderId: OrderId, tx?: TransactionClient): Promise<Order | null> {}
// the rest of the methods
// ...
}
6. Onion architecture = DIP applied to the layer graph
In my current application I use Onion Architecture with DDD in the core + ports and adapters.
DDD is not an architecture, it's a way of modeling your business logic (Ubiquitous Language, Aggregates, Entities, Value Objects, Domain Services, Domain Events, Repositories as domain concepts, Bounded Contexts). It tells your onion/hexagonal architectures what goes in the center.
Onion architecture is what we get when we apply the Dependency Inversion Principle at more than two layers: outer layers depend on inner layers (inner layers' contracts) but not the other way around, and the domain layer should be the center.
6.1 My three layers
- Domain (entities, value objects, domain services, repository and gateway ports in domain language, domain errors)
- it doesn't depend on anything outside of its own.
- it doesn't know anything about any other layer.
- Application (application services, commands/queries, DTOs, application ports, query services)
- it depends on domain layer only (classes, types, errors and contracts)
- it doesn't know anything about infra.
- Infrastructure (config, ports implementations, mappers, http layer, messaging and workers, notifications and templates, ...etc.)
- it depends on both domain and application layers (classes, types, errors and contracts)
- it knows everything about domain and application.
6.2 Ports and adapters
A port is just an abstraction defined by the inside that the outside must satisfy.
- A port is simply an interface that defines what the domain needs from the outside world to fulfill business rules.
- All repositories and gateways are considered ports, their contracts should all be owned by the domain layer, but their implementations (adapters) should be in the infra layer.
- A port contract could also be owned by the application layer when that port doesn't exist to satisfy domain rules, instead its existence is to support the application infrastructure: things like the authentication API port, idempotency keys repository port, outbox repository port, query service ports for specific application consumers, event publishers, ...etc. Their adapters still belong to the infrastructure layer (Postgres, BullMQ, Brevo, WorldExpress, AWS S3, ...etc.).
- Don't allow a domain port to take an application-layer type. Application ports can reference domain types; domain ports must reference only domain types.
7. Dependency Injection, the technique
7.1 The definition
Dependency injection is a mechanism/technique to decouple dependency construction from the dependent class, instead of internally constructing dependencies they're supplied from the outside via the constructor (most common), setter/property injection, or method/parameter injection. The three fused decisions are pulled out of the consumer, which keeps only a contract-typed parameter.
7.2 Pure DI: no container needed
const orderRepository = new PostgresOrderRepository(drizzleDb);
const cartRepository = new PostgresCartRepository(drizzleDb);
const userRepository = new PostgresUserRepository(drizzleDb);
const fetchHttpClient = new FetchHttpClient();
const shippingProviderGateway = new WorldExpressShippingProviderGateway(
fetchHttpClient,
);
const productRepository = new PostgresProductRepository(drizzleDb);
const idempotencyKeysRepository = new PostgresIdempotencyKeysRepository(
drizzleDb,
);
const outboxRepository = new PostgresOutboxRepository(drizzleDb);
// Dependency Injection via constructor
const service = new CreateOrderService(
db,
orderRepository,
cartRepository,
userRepository,
shippingProviderGateway,
productRepository,
idempotencyKeysRepository,
outboxRepository,
);
This is considered DI too, injection is a discipline, not a library (Seemann calls this Pure DI).
7.3 Where Pure DI collapses
Pure DI fails not because it's wrong, but because it has no single place where the what/how/when decisions live. A route handler doing manual construction of an 8-dependency CreateOrderService:
router.post("/", authMiddleware, async (req, res) => {
const safeBody = validate(createOrderBodySchema, req.body);
const userId = req.user.id;
const command = new CreateOrderCommand(
safeBody.idempotencyKey,
userId,
safeBody.providedShippingPrice,
safeBody.selectedShippingProvider,
safeBody.shippingDetails,
);
const orderRepository = new PostgresOrderRepository(drizzleDb);
const cartRepository = new PostgresCartRepository(drizzleDb);
const userRepository = new PostgresUserRepository(drizzleDb);
const fetchHttpClient = new FetchHttpClient();
const shippingProviderGateway = new WorldExpressShippingProviderGateway(
fetchHttpClient,
);
const productRepository = new PostgresProductRepository(drizzleDb);
const idempotencyKeysRepository = new PostgresIdempotencyKeysRepository(
drizzleDb,
);
const outboxRepository = new PostgresOutboxRepository(drizzleDb);
const service = new CreateOrderService(
db,
orderRepository,
cartRepository,
userRepository,
shippingProviderGateway,
productRepository,
idempotencyKeysRepository,
outboxRepository,
);
const result = await service.execute(command);
res.status(200).json({ orderId: result.value });
});
Wiring duplicated at every call site, routes drowning in construction knowledge, no single place controlling lifetime, and every constructor change fanning out across the codebase.
8. DI container, the tooling
8.1 One-sentence definition
A DI container is a registry mapping a token to a recipe, plus caches implementing the three lifetime policies: register stores, resolve applies the policy, createScope manufactures units of work.
8.2 What it automates, and what it doesn't
It automates wiring and lifetime; it does not replace DIP (your contracts still do the design work). The container is optional tooling for a technique (DI), a technique that exists to serve a principle (DIP).
Part IV, Building the Container in Four Iterations
Each iteration is small enough to show whole.
9. Iteration 1, a Map and resolve (transient only)
export type InjectionToken<T> = symbol & {
readonly __type?: T;
};
export type Constructor<T> = new (...args: any[]) => T;
export type Token<T> = InjectionToken<T> | Constructor<T>;
export type Factory<T> = () => T;
export type DependencyLifeCycle = "singleton" | "scoped" | "transient";
export type Registration<T> = {
factory: Factory<T>;
lifecycle: DependencyLifeCycle;
};
export class Container {
private registry = new Map<Token<any>, Registration<any>>();
register<T>(
token: Token<T>,
factory: Factory<T>,
lifecycle: DependencyLifeCycle = "transient",
): this {
this.registry.set(token, { lifecycle, factory });
return this; // for chaining
}
resolve<T>(token: Token<T>): T {
const reg = this.registry.get(token);
if (!reg) throw new DependencyResolutionError(token);
const instance = reg.factory();
return instance;
}
}
// token usage example
export const USER_REPOSITORY = Symbol(
"userRepository",
) as InjectionToken<UserRepository>;
Token<T>: tokens are unique values bound to a contract, used to register dependencies in the container, it could be anInjectionToken<T>or a class constructor.InjectionToken<T>: since TS treats all symbols as the same type, we need a way to prevent a specific contract's symbol from being used for storing/resolving another contract's dependency, so we use type branding, we bind a specific symbol to a specific contract via a phantom field, this way TS will always be able to infer the contract bound to that symbol and stop you from using it in the wrong places, like using it to register a dependency that doesn't satisfy the contract defined by the token's phantom field (compile time error), and also it improves the DX experience, you get typed dependencies when callingcontainer.resolve(token)because the dependency type is inferred from the token itself.Registration<T>: a registration is basically a (dependency's factory + a lifecycle policy).Containerhas a:registry: (a Map of token -> registration), stores all the registered dependencies.register(): accepts a token, factory and a lifecycle policy, it adds the dependency to the registry.resolve(): accepts a token, which will be used to lookup a specific registration, instantiate it and return the instance.
- Our current implementation supports only one lifecycle policy, the
"transient".
One important detail: my contracts are type aliases, they evaporate at compile time and leave no runtime value to key a Map with. The runtime needs a handle, and that's exactly what the symbol is: nominal identity at runtime, with the phantom __type field coupling the handle to the contract. The as InjectionToken<X> cast at token definition is the single, auditable stitch between the two worlds, and resolve<T>(token: Token<T>): T recovers the type from the token, registration-side cast, inference everywhere else, zero annotations at use sites.
10. Iteration 2, the singleton cache
export class Container {
private registry = new Map<Token<any>, Registration<any>>();
private singletonCache = new Map<Token<any>, any>();
register<T>(...): this {...}
resolve<T>(...): T {...}
resolveSingleton<T>(token: Token<T>): T {
const reg = this.registry.get(token);
if (!reg) throw new DependencyResolutionError(token);
if (this.singletonCache.has(token)) return this.singletonCache.get(token);
const instance = reg.factory();
this.singletonCache.set(token, instance);
return instance;
}
}
singletonCache: Maps token to a singleton instance, we use it to register singleton instances after their first time instantiated.resolveSingleton: similar toresolvebut applies the "singleton" lifecycle policy. Notice this sequence has noawaitbetween the cache-miss check and the cache set, it's synchronous, therefore atomic on Node's event loop. Hold onto that observation, it becomes a rule later.
11. Iteration 3, Scope: the unit of work made tangible
11.1 createScope
export type Factory<T> = (scope: Scope) => T; // constructors now accept a scope parameter
export class Container {
private registry = new Map<Token<any>, Registration<any>>();
private singletonCache = new Map<Token<any>, any>();
register<T>(...): this {...}
// resolve is moved to the scope, container only resolves singletons now.
resolveSingleton<T>(...): T {...}
getRegistration<T>(token: Token<T>): Registration<T> | undefined {
return this.registry.get(token);
}
createScope() {
return new Scope(this);
}
}
export class Scope {
private scopedCache = new Map<Token<any>, any>();
constructor(private parent: Container) {}
resolve<T>(token: Token<T>): T {
const reg = this.parent.getRegistration(token);
if (!reg) throw new DependencyResolutionError(token);
if (reg.lifecycle === "singleton") {
return this.parent.resolveSingleton(token);
}
if (reg.lifecycle === "scoped") {
if (this.scopedCache.has(token)) return this.scopedCache.get(token);
const instance = reg.factory(this);
this.scopedCache.set(token, instance);
return instance;
}
// if reg.lifecycle === "transient"
return reg.factory(this);
}
}
Scope: an object that represents a unit of work, "scoped" dependencies are cached in thescopedCacheafter they get instantiated for the first time. A scope requires the parent container as an attribute.resolve: the brain of the DI container, this method gets the lifecycle policy of the dependency from the container's registry and decides to delegate the dependency resolving to the parent for singletons, or handle the caching and instantiation for scoped dependencies, or instantiate and return for transient dependencies.
11.2 dispose
export class Scope {
private scopedCache = new Map<Token<any>, any>();
constructor(private parent: Container) {}
resolve<T>(...): T {...}
async dispose(): Promise<void> {
const instances = [...this.scopedCache.values()];
this.scopedCache.clear(); // clear FIRST: even if everything explodes, the scope is dead
for (const instance of instances) {
if (isDisposable(instance)) {
try {
await instance.dispose();
} catch (err) {
// log errors
}
}
}
}
}
// helpers
export type Disposable = {
dispose(): Promise<void> | void;
};
function isDisposable(x: unknown): x is Disposable {
return (
typeof x === "object" &&
x !== null &&
typeof (x as Disposable).dispose === "function"
);
}
dispose: takes a snapshot of the scope cache, clears it first, then iterates over the snapshot's saved dependency instances and disposes them one after the other in a "non-blocking on fail" way, one bad citizen must not strand its neighbors.- The
Disposablecontract replaces duck-typing: the contract becomes visible at the type level and documented in one place.
12. Iteration 4, registerInstance: where async startup meets the sync graph
Construction of the DB/Redis/queues happens eagerly in the composition root, what enters the container is the ready instance:
export class Container {
private registry = new Map<Token<any>, Registration<any>>();
private singletonCache = new Map<Token<any>, any>();
register<T>(...): this {...}
registerInstance<T>(token: Token<T>, instance: T): this {
this.registry.set(token, {
lifecycle: "singleton",
factory: () => instance,
});
this.singletonCache.set(token, instance);
return this;
}
resolveSingleton<T>(...): T {...}
getRegistration<T>(...): Registration<T> | undefined {...}
createScope(...) {...}
}
The picture so far:
13. The factory parameter: recursive resolution through one scope
A factory is a recipe, and the recipe receives the scope so it can fetch its own ingredients through the same scope:
container.register(
CREATE_ORDER_SERVICE,
(scope) =>
new CreateOrderService(
scope.resolve(DB),
scope.resolve(ORDER_REPOSITORY),
scope.resolve(CART_REPOSITORY),
scope.resolve(USER_REPOSITORY),
scope.resolve(SHIPPING_PROVIDER_GATEWAY),
scope.resolve(PRODUCT_REPOSITORY),
scope.resolve(IDEMPOTENCY_KEYS_REPOSITORY),
scope.resolve(OUTBOX_REPOSITORY),
),
"scoped",
);
Because ingredients resolve through the same scope, a scoped service and its scoped dependencies share one unit of work, the lifetime boundary propagates transitively through the whole graph. And the registry defines the full graph while nothing is constructed until first resolve: each scope brings only the subgraph it needs to life.
Part V, Composition Roots and the Object Graph Per Process
14. What a composition root is
A composition root is the single "location/place" in the application where the object graph is assembled, where abstraction gets bound to concrete implementation and everything gets wired together. It's the only place in your codebase that is allowed to know about both sides of every port: the interface and the adapter.
The 3 rules:
- It's the only module that references both ports and adapters. Your domain and application layers import only interfaces. Your infra classes implement interfaces but don't know their consumers. Only the composition root touches both.
- It's the only place
new(or container registration) happens for cross-layer dependencies. Inner layers never construct their own dependencies, that's the whole point of DI. If a service doesnew PostgresRepo(), you've leaked composition into the wrong place. - It's as close to the entry point as possible, typically the
main()/index.tsof each process.
Composition root VS DI container: the composition root is a concept/pattern, the single place where wiring happens, a location in your code, and a DI container is a tool that automates wiring. You can have a composition root without a container (manual construction, sometimes called "pure DI" or "poor man's DI").
Each composition root reaches into the shared codebase and pulls down only the branch of the graph its entrypoint can touch. The graph isn't shaped by your folder structure, it's shaped by what each process can possibly do. That's all "process-shaped" means: each process only instantiates the subgraph it can actually reach.
Inside the root: only construction. Outside: only consumption. The moment a route or a service constructs something, the root is fragmented and every Part I problem returns.
15. My composition roots
The roster:
buildApiContainerbuildOutboxProcessorContainerbuildEmailQueueHandlerContainerbuildDomainEventsProcessorContainerbuildCleanOutboxContainerbuildResetStuckOutboxRowsWorkerContainerbuildOutboxHandlerContainerbuildIntegrationTestsContainerbuildUnitTestsContainer
15.1 The smallest root, in full
export function buildOutboxProcessorContainer(): Container {
const container = new Container();
registerSharedInfrastructure(container);
container.register(
OUTBOX_QUEUE,
(scope) => createBullMqOutboxQueue(scope.resolve(REDIS)),
"singleton",
);
container.register(
OUTBOX_REPOSITORY,
(scope) => new PostgresOutboxRepository(scope.resolve(DRIZZLE_DB)),
"singleton",
);
container.register(
OUTBOX_PROCESSOR_SERVICE,
(scope) =>
new OutboxProcessorService(
scope.resolve(OUTBOX_REPOSITORY),
scope.resolve(OUTBOX_QUEUE),
),
"scoped",
);
return container;
}
This process has never heard of CreateOrderService, decoupling you can read off the file.
15.2 Entry points
Composition happens once, at the edge:
// src/entrypoints/workers/outbox-processor.ts
const logger = createLogger("domainEventsProcessorEntrypoint");
const container = buildOutboxProcessorContainer(); // build the outbox processor specific DI container
const outboxProcessorWorker = new OutboxProcessorWorker(container, {
pollIntervalMs: 1000 * 60 * 3, // 3 minutes
sleepAfterFailMs: 5000,
maxPublicationAttempts: 5,
batchSize: 40,
}); // instantiate and configure the worker
outboxProcessorWorker.start(); // start the worker
const shutdown = async (signal: string) => {
logger.info(`Received ${signal}, starting graceful shutdown...`);
try {
await outboxProcessorWorker.stop();
logger.info("Outbox processor worker stopped gracefully.");
process.exit(0);
} catch (error) {
logger.error("Error during graceful shutdown", error as Error);
process.exit(1);
}
};
// Listen for termination signals
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
16. registerSharedInfrastructure: the shared seam
export function registerSharedInfrastructure(container: Container): void {
const db = createDrizzleDB({
connectionUrl: env.DATABASE_URL,
maxPoolSize: 10,
debug: env.DEBUG_DB,
});
container.registerInstance(DB, db);
container.registerInstance(DRIZZLE_DB, db);
const redisConnection = createRedisConnection({
host: env.REDIS_HOST,
port: env.REDIS_PORT,
maxRetriesPerRequest: null,
enableReadyCheck: false,
lazyConnect: true,
});
container.registerInstance(REDIS, redisConnection);
}
Two tokens for one database client, on purpose: in case I decided to switch to another ORM/DB I can register the new factory to the DB token and keep the drizzle factory registered to DRIZZLE_DB (for Drizzle-specific test helpers, ...etc.).
And notice that createDrizzleDB runs before registration, async work lives at startup, where ordering is explicit. The container never waits; it assembles pre-initialized parts. This becomes a formal rule in Part IX.
Part VI, Scopes in the Three Runtime Shapes
The scope is the same object in all three shapes; only the unit of work changes.
17. Scope per HTTP request
export async function createServer(container: Container) {
const app = express();
// ... other middlewares
app.use(scopeMiddleware(container));
// ... other middlewares
app.use("/api/v1", routes);
app.use(errorHandlingMiddleware);
return app;
}
export function scopeMiddleware(container: Container) {
return (req: Request, res: Response, next: NextFunction) => {
req.scope = container.createScope();
res.on("finish", () => {
req.scope.dispose().catch(console.error);
});
return next();
};
}
The unit of work begins when the middleware creates the scope and ends when the response finishes, one honest caveat: finish is where normal responses land; a client-aborted request fires close instead, so if you dispose scarce resources per request, listen to both.
Routes resolve from the scope, this is the single place in the codebase where a consumer pulls a dependency instead of receiving it (more on why that's acceptable in Part IX):
router.post("/", adminMiddleware, async (req, res) => {
const safeBody = validate(createCategoryBodySchema, req.body);
const service = req.scope.resolve(CREATE_CATEGORY_SERVICE); // resolved from the request scope
const command = new CreateCategoryCommand(safeBody.name);
const result = await service.execute(command);
res.status(201).json(result);
});
18. Scope per polling iteration
export class OutboxProcessorWorker {
// ...
constructor(private container: Container, ...) {
// ...
}
async runIteration(): Promise<void> {
const iterationId = `iter_${crypto.randomUUID().replace(/-/g, "")}`;
await runWithContext(
{ requestId: iterationId, startTime: performance.now() },
async () => {
const scope = this.container.createScope();
try {
const service = scope.resolve(OUTBOX_PROCESSOR_SERVICE);
await service.execute(
new OutboxProcessorCommand(...),
);
} finally {
await scope.dispose();
}
},
);
}
start(): void {
// ...
}
async stop(): Promise<void> {
// ...
}
private async loop(): Promise<void> {
while (this.running) {
try {
await this.runIteration();
} catch (error) {
// ...
}
// ...
}
}
}
runWithContext wraps the scope: a correlation ID per iteration for the logs, and the scope giving that iteration its isolated instances. runIteration is exposed directly so tests can invoke one iteration without going through the infinite loop.
19. Scope per BullMQ job
export class OutboxHandlerWorker {
private logger = createLogger("OutboxHandlerWorker");
private worker: Worker | null = null;
constructor(
private connection: Redis,
private buildContainer: () => Container = buildOutboxHandlerContainer,
) {}
start(): void {
// ...
this.worker = new Worker(
"outbox-queue",
async (job) => {
const requestId = `job_${job.id}`;
return runWithContext(
{
requestId,
jobId: job.id ?? "unknown",
queueName: "outbox-queue",
startTime: performance.now(),
},
async () => {
const container = this.buildContainer();
const scope = container.createScope();
// ...
try {
// ...
// uses the scope parameter to resolve the service internally
await executeOutboxHandler(outboxAction, scope, command, jobId);
// ...
} catch (error) {
// ...
} finally {
await scope.dispose();
}
},
);
},
{
connection: this.connection,
concurrency: 3, // each job has its own scope
lockDuration: 30000,
stalledInterval: 30000,
},
);
this.worker.on("failed", (job, err) => {
// ...
});
this.worker.on("stalled", (jobId) => {
// ...
});
}
async stop(): Promise<void> {
// ...
}
}
At concurrency: 3, three jobs run interleaved, three scopes mean zero shared state between them. This is the scoped lifecycle's justification in production, stronger than any abstract argument. And the finally guarantees the scope dies even when the handler throws, so BullMQ's retry gets a clean slate.
Part VII, The Boundary Rule
20. "If it satisfies the contract, you can inject it"
Consumers only ever name tokens and constructor parameter types; registrations bind tokens to factories, never consumers to implementations. Walk the chain: TypeScript's structural typing checks assignability, the container does the injecting, the composition root does the choosing, so anything assignable to the contract can be swapped without touching the consumer.
Look at the token file: registrations name ORDER_REPOSITORY, never PostgresOrderRepository, which is why production, in-memory, and vitest mocks are interchangeable.
21. Why contracts are type aliases, not interfaces
Structurally they're identical, with one practical difference: interfaces get declaration merging (someone can augment your contract elsewhere), type aliases don't, and mocking a type alias is trivial.
And remember the consequence: because a contract is a type alias it cannot serve as its own token (no runtime value), this is the deep reason the symbol token system from Part IV exists, and most DI articles miss it.
Part VIII, Testing: The Registration Matrix
22. The core insight
Unit tests, integration tests, and production are the same codebase differing only in what's registered under the same tokens, the test matrix is a registration matrix.
23. Integration tests: real database, fake edges
buildIntegrationTestsContainer uses real Postgres with maxPoolSize: 1 and real Redis with a distinct DB index per vitest worker, the first keeps the tests from drowning the pool, the second keeps parallel workers from stomping on each other's keys.
The edges are faked: FakeAuthPort/fakeBetterAuth let me run the full Express app over HTTP without Better Auth in the loop, and in beforeEach I re-register SHIPPING_PROVIDER_GATEWAY with a fake gateway, one line swaps the gateway for that test's fake (the mechanics are the subject of Part IX).
beforeEach(async () => {
await clearDatabase(container);
fakeGateway = createFakeShippingProviderGateway();
container.register(SHIPPING_PROVIDER_GATEWAY, () => fakeGateway, "scoped");
});
Walk one POST /api/v1/orders test end to end: supertest hits the real app, the scope middleware creates a scope, the route resolves the service, and the test asserts database state directly via container.resolveSingleton(ORDER_REPOSITORY), one test file exercising every claim of this post.
24. Unit tests: in-memory everything
buildUnitTestsContainer registers in-memory repositories, FakeQueue, a fake event publisher, no real DB/Redis, same tokens, different recipes.
The punchline: the difference between a unit test and an integration test, in my codebase, is the contents of two functions.
25. The matrix, on one table
| Token | API process | Integration tests | Unit tests |
|---|---|---|---|
DB | Drizzle + postgres-js (pool 10), real DB URL | Drizzle + postgres-js (pool 1), test DB URL | no-op tx stub |
REDIS | real connection | real connection, DB index per worker | ioredis-mock |
| Repositories | Postgres*XxxRepository | Postgres*XxxRepository | In-memory |
SHIPPING_PROVIDER_GATEWAY | WorldExpress | WorldExpress, per-test fake override | In-memory |
AUTH | BetterAuth adapter | FakeAuthPort | FakeAuthPort |
| Queues | BullMQ | BullMQ | FakeQueue |
EVENT_PUBLISHER | BullMQ flow producer | BullMQ flow producer | FakeEventPublisher |
One table carrying the entire argument.
Part IX, Rules of the Container
26. Re-registration as the override mechanism
register is last-write-wins: the most recent recipe for a token wins. That's a designed contract, not an accident, per-test gateway overrides and container specialization both ride on it, and lazy resolution guarantees post-hoc overrides are always coherent (nothing resolves until asked, so overriding after the container is built is always safe).
The guard: silent overrides are a feature in tests and a hazard for production typos, so a debug-mode warning when overwriting is cheap insurance, something to be implemented later.
27. Why factories must be synchronous
Walk resolveSingleton again: with no await between the cache check and the cache set, the whole sequence is atomic on the event loop, you got a mutex for free. If factories were async, two concurrent scopes resolving one lazily-built singleton would both miss the cache, both run the factory, and the last write would win, two instances of something defined to be one. The quieter variants are just as bad: a scoped double-build inside one request, or a dispose interleaving with a resolution.
The proper fix, caching promises instead of instances, cycle detection on promises, disposal coordination, is the complexity budget of a full framework, spent to enable something this codebase doesn't need.
So the rule is: factories are pure synchronous construction; async initialization happens before registration or via init() at the entrypoint. registerInstance is the seam where the async world (startup) meets the sync world (the graph).
Part X, What a Full Framework Gives You That This Doesn't
tsyringe/inversify/NestJS infer wiring from types via emitDecoratorMetadata, you trade that for zero reflection, zero decorators, and greppable plain-TypeScript wiring. Your cyclic factory would stack-overflow where a full container reports the cycle, a known gap, with the resolution-stack fix as future work. Interception pipelines, keyed/multi-registration, child containers: missing, and not needed so far.
The verdict: ~170 dependency-free lines, one vocabulary of tokens, nine composition roots. Buy framework features with the complexity they cost, only when a real requirement demands them.
Part XI, Close
Recap in one breath: principle (DIP) → technique (DI) → tooling (container) → per-process roots → scopes as units of work → the boundary rule.
The repo: github.com/djaouad10/DDD-E-commerce-Backend. Further reading: Mark Seemann's Dependency Injection Principles, Practices, and Patterns and the Composition Root essay.