Dependency injection: tsyringe with declarations, NestJS, manual wiring
Summary. Clean Architecture needs one thing from dependency injection: a single place, the
composition root, where adapters are bound to ports. This kit offers three ways to write that
place. With tsyringe and @ddd-ts-kit/di, classes declare what they are and the context is
assembled from the declarations, with wiring errors found before anything is instantiated.
With NestJS, the framework's container does the job and contexts are wired explicitly. With
manual wiring, a function builds the objects by hand. All three keep domain/ and
application/ free of any container; they differ in how much is checked for you and in how
much the kit can generate.
What is being decided
Section titled “What is being decided”Whatever the tool, the rules are the same (composition/only-in-module, layer/*,
ports/* in ARCHITECTURE.md and the
ports and composition page): a port is an abstract
class, a use case receives its ports by constructor, and only the composition root knows which
adapter stands behind which port.
The handler of the running example books an appointment. It names what it needs by port, in its constructor, and nothing else:
import { definePermissions } from '@ddd-ts-kit/auth';import { AuthorizedCommand, type Actor, type Clock, type IdGenerator } from '@ddd-ts-kit/kernel';import { CommandHandler, Requires } from '@ddd-ts-kit/kernel/declarations';
// contexts/appointments/domain/aggregates/appointment.aggregate.ts, reduced to what the sample usesdeclare class Appointment { static book(props: { id: string; clientId: string; startsAt: Date; bookedAt: Date }): Appointment; readonly id: string;}
// contexts/appointments/domain/repositories/appointment.repository.ts: a port is an abstract classexport abstract class AppointmentRepository { abstract save(appointment: Appointment): Promise<void>;}
// contexts/appointments/application/authorization/appointments.permissions.tsexport const appointmentsPermissions = definePermissions('appointments', { 'appointment.book': { description: 'Book an appointment' },});
// contexts/appointments/application/commands/book-appointment/book-appointment.command.ts@Requires(appointmentsPermissions['appointment.book'])export class BookAppointment extends AuthorizedCommand<string> { constructor( actor: Actor, readonly startsAt: Date, ) { super(actor); }}
// contexts/appointments/application/commands/book-appointment/book-appointment.handler.ts@CommandHandler(BookAppointment)export class BookAppointmentHandler implements CommandHandler<BookAppointment> { constructor( private readonly appointments: AppointmentRepository, private readonly clock: Clock, private readonly ids: IdGenerator, ) {}
async execute(command: BookAppointment): Promise<string> { const appointment = Appointment.book({ id: this.ids.next(), clientId: command.actor.id, startsAt: command.startsAt, bookedAt: this.clock.now(), });
await this.appointments.save(appointment);
return appointment.id; }}The decorator comes from @ddd-ts-kit/kernel/declarations. It is metadata only: it
instantiates nothing and imports no container. What the handler depends on is said once, by
its constructor: ddd-ts-kit docs reads the signature from the source and writes the list
the container injects into the generated file of the context
(ports and composition, "How the constructor reaches
the composition root", compares this with a list in the decorator and with
emitDecoratorMetadata, which holds in three of the ten compiler and runner combinations
measured). The same handler class, without the decorator, is what NestJS or manual wiring
would bind.
What the rules refuse is a use case that fetches its own dependencies. Instead of reaching for the container, or building the adapter, inside the application layer:
// application/commands/book-appointment/book-appointment.handler.ts: refused// layer/application-inward: a container package and an infrastructure file, imported by the applicationimport { container } from 'tsyringe';import { KnexAppointmentRepository } from '@/contexts/appointments/infrastructure/persistence/knex-appointment.repository';
export class BookAppointmentHandler { // composition/only-in-module: the adapter is chosen here, for good, and a test cannot replace it private readonly appointments = new KnexAppointmentRepository(); // a hidden dependency: the constructor no longer says what the handler needs private readonly clock = container.resolve(Clock);
async execute(command: BookAppointment): Promise<string> { /* ... */ }}write the constructor above: the handler receives its ports, a test gives it fakes, and the choice of the adapter stays in the composition root.
The same context, three composition roots
Section titled “The same context, three composition roots”The three styles bind the same classes. Only the place where the binding is written changes.
Declared (tsyringe and @ddd-ts-kit/di). Each class carries its declaration; the context is
assembled from the classes themselves. ddd-ts-kit docs writes, in the generated file of the
context, the list each constructor receives, and contextModule turns the declarations into
bindings:
import { AuthorizedCommand, Clock, IdGenerator, type Actor } from '@ddd-ts-kit/kernel';import { CommandHandler, Repository, injected } from '@ddd-ts-kit/kernel/declarations';import { contextModule, createApplication, kernelModule } from '@ddd-ts-kit/di';
// the classes of the first sample, reduced to their shapedeclare class Appointment { readonly id: string;}abstract class AppointmentRepository { abstract save(appointment: Appointment): Promise<void>;}declare class BookAppointment extends AuthorizedCommand<string> { constructor(actor: Actor, startsAt: Date);}
@CommandHandler(BookAppointment)class BookAppointmentHandler implements CommandHandler<BookAppointment> { constructor( private readonly appointments: AppointmentRepository, private readonly clock: Clock, private readonly ids: IdGenerator, ) {}
async execute(command: BookAppointment): Promise<string> { // the body of the first sample return `${this.ids.next()} booked at ${this.clock.now().toISOString()} for ${command.actor.id}`; }}
// contexts/appointments/infrastructure/persistence/in-memory-appointment.repository.ts@Repository(AppointmentRepository)class InMemoryAppointmentRepository implements AppointmentRepository { readonly saved: Appointment[] = [];
async save(appointment: Appointment): Promise<void> { this.saved.push(appointment); }}
// contexts/appointments/appointments.declarations.ts, generated: never written by handinjected(BookAppointmentHandler, [AppointmentRepository, Clock, IdGenerator]);
// main.tsexport async function main(): Promise<void> { const appointments = contextModule('appointments', { BookAppointmentHandler, InMemoryAppointmentRepository }); const app = createApplication({ modules: [kernelModule(), appointments] });
await app.start();}In a project, the composition root imports the whole generated barrel instead of naming the
classes: contextModule('appointments', appointments) after
import * as appointments from '@/contexts/appointments/appointments.declarations'.
Explicit (tsyringe and @ddd-ts-kit/di). Every binding is a statement of
<context>.module.ts, with the list of what each constructor takes. No decorator is needed:
import { definePermissions } from '@ddd-ts-kit/auth';import { AuthorizedCommand, Clock, IdGenerator, type Actor } from '@ddd-ts-kit/kernel';import { createApplication, defineContextModule, kernelModule } from '@ddd-ts-kit/di';
// the classes of the first sample, without their decoratorsdeclare class Appointment { readonly id: string;}abstract class AppointmentRepository { abstract save(appointment: Appointment): Promise<void>;}declare class BookAppointment extends AuthorizedCommand<string> { constructor(actor: Actor, startsAt: Date);}declare class BookAppointmentHandler { constructor(appointments: AppointmentRepository, clock: Clock, ids: IdGenerator); execute(command: BookAppointment): Promise<string>;}abstract class Database { abstract readonly url: string;}declare class KnexAppointmentRepository implements AppointmentRepository { constructor(database: Database); save(appointment: Appointment): Promise<void>;}const appointmentsPermissions = definePermissions('appointments', { 'appointment.book': { description: 'Book an appointment' },});
// contexts/appointments/appointments.module.tsexport const appointmentsModule = defineContextModule({ name: 'appointments', register(builder) { builder.bind(Database).toValue({ url: 'postgres://localhost/appointments' }); builder.bind(AppointmentRepository).toClass(KnexAppointmentRepository, [Database]); builder .command(BookAppointment) .requires(appointmentsPermissions['appointment.book']) .handledBy(BookAppointmentHandler, [AppointmentRepository, Clock, IdGenerator]); },});
// main.tsexport const app = createApplication({ modules: [kernelModule(), appointmentsModule] });The compiler holds each list to its constructor: [Clock, AppointmentRepository, IdGenerator]
(another order) or [AppointmentRepository, Clock] (one short) does not compile.
Manual (--di manual, and the contexts of --di nest). A function of the platform builds
the objects with new and registers each handler on the bus; the generator writes the
registrations between markers of <context>.module.ts:
import { definePermissions, requires, type UseCaseDeclaration } from '@ddd-ts-kit/auth';import { AuthorizedCommand, type Actor, type Clock, type IdGenerator, type InMemoryCommandBus } from '@ddd-ts-kit/kernel';
// the classes of the first sample, without their decoratorsdeclare class Appointment { readonly id: string;}interface AppointmentRepository { save(appointment: Appointment): Promise<void>;}declare class BookAppointment extends AuthorizedCommand<string> { constructor(actor: Actor, startsAt: Date);}declare class BookAppointmentHandler { constructor(appointments: AppointmentRepository, clock: Clock, ids: IdGenerator); execute(command: BookAppointment): Promise<string>;}declare class KnexAppointmentRepository implements AppointmentRepository { constructor(databaseUrl: string); save(appointment: Appointment): Promise<void>;}const appointmentsPermissions = definePermissions('appointments', { 'appointment.book': { description: 'Book an appointment' },});
// platform/platform.ts, reduced: what every context receivesinterface Platform { readonly commands: InMemoryCommandBus; readonly clock: Clock; readonly ids: IdGenerator; readonly databaseUrl: string;}
// contexts/appointments/appointments.module.tsexport function appointmentsModule(platform: Platform): { name: string; useCases: readonly UseCaseDeclaration[] } { const appointments = new KnexAppointmentRepository(platform.databaseUrl);
// ddd-ts-kit:registrations platform.commands.register(BookAppointment, new BookAppointmentHandler(appointments, platform.clock, platform.ids), { context: 'appointments', }); // /ddd-ts-kit:registrations
return { name: 'appointments', useCases: [requires(BookAppointment, appointmentsPermissions['appointment.book'])], };}Here the compiler checks the arguments of each new, and nothing else. A use case missing
from useCases is found by ddd-ts-kit check (authz/declared reads the module file); a
handler that is never registered is found by the first command that reaches no handler, not at
start.
In a test, a module of @ddd-ts-kit/di (declared or explicit) runs as it is, with a fake
bound under the port class, a frozen clock and sequential identifiers:
import { anActor } from '@ddd-ts-kit/auth/testing';import { AuthorizedCommand, Clock, IdGenerator, type Actor, type CommandHandler } from '@ddd-ts-kit/kernel';import { createTestApplication, defineContextModule, fake } from '@ddd-ts-kit/di';import { expect, it } from 'vitest';
// the port, the command and the handler of the first sample, in a few linestype Appointment = { readonly id: string; readonly bookedAt: Date };abstract class AppointmentRepository { abstract save(appointment: Appointment): Promise<void>;}class BookAppointment extends AuthorizedCommand<string> {}class BookAppointmentHandler implements CommandHandler<BookAppointment> { constructor( private readonly appointments: AppointmentRepository, private readonly clock: Clock, private readonly ids: IdGenerator, ) {}
async execute(): Promise<string> { const appointment = { id: this.ids.next(), bookedAt: this.clock.now() }; await this.appointments.save(appointment);
return appointment.id; }}const appointmentsModule = defineContextModule({ name: 'appointments', register(builder) { builder.command(BookAppointment).public().handledBy(BookAppointmentHandler, [AppointmentRepository, Clock, IdGenerator]); },});
// contexts/appointments/test/fakes/in-memory-appointment.repository.ts: a fake implements the portclass InMemoryAppointmentRepository implements AppointmentRepository { readonly saved: Appointment[] = [];
async save(appointment: Appointment): Promise<void> { this.saved.push(appointment); }}
// contexts/appointments/test/unit/book-appointment.handler.spec.tsit('books the appointment at the instant of the clock, under a new id', async () => { const appointments = new InMemoryAppointmentRepository(); const app = createTestApplication({ modules: [appointmentsModule], fakes: [fake(AppointmentRepository, appointments)], now: new Date('2026-03-02T08:00:00.000Z'), }); const client: Actor = anActor().named('ada').build();
const id = await app.commandBus.execute(new BookAppointment(client));
expect(id).toBe('id-1'); expect(appointments.saved).toEqual([{ id: 'id-1', bookedAt: new Date('2026-03-02T08:00:00.000Z') }]);});fake(AppointmentRepository, appointments) does not compile when appointments lacks a method
of the port. Manual wiring has no container to bind a fake in: its specs build the handler
with new and register it on createTestBuses() of @ddd-ts-kit/testing.
Comparison
Section titled “Comparison”tsyringe + @ddd-ts-kit/di |
NestJS container | Manual wiring | |
|---|---|---|---|
| Who assembles a context | the kit, from declarations (generated barrel or discovery) or from an explicit module | as generated, the same <context>Module(platform) function as manual wiring; Nest providers by factory when you bind in Nest modules yourself |
a <context>Module(platform) function in <context>.module.ts: plain calls to constructors, registrations between markers |
Style available (stack.style) |
declared or explicit, mixable |
explicit only |
explicit only |
| Injection token | the port class itself | Nest tokens; ports bound in the module | none: plain variables |
| What a class is given | what its constructor takes, each parameter typed by a port: read from the source into a generated file, never reflected | whatever the provider factory passes, or Nest's own design:paramtypes and @Inject when you declare providers by class |
the arguments of new Handler(a, b) |
| Decorators in your classes | declarations in application/ and infrastructure/, none in domain/; no parameter decorator, no reflection |
@Injectable() kept out of domain/ and application/ by rule; providers declared by factory |
none |
| Decorator mode | standard or legacy | legacy | not concerned |
| Errors found before start | duplicate binding, unbound dependency, cycle, duplicate handler, consumer or job, undeclared authorization, and more (WiringError with a code) |
Nest's own resolution errors at bootstrap | the compiler, for constructors; nothing for a forgotten handler |
| Cross-cutting hook | instrumentation of kernelModule (tracing, from outside), around(kinds, ...) in a module for every binding of a kind, .with(...) or the aspects of a declaration for one binding |
call the wrappers in the provider factory | call the wrappers by hand |
| Test application | createTestApplication with fake(Port, instance) |
Nest testing module, or createTestBuses |
createTestBuses |
| Extra runtime dependencies | tsyringe, reflect-metadata |
the Nest packages | none |
The underlying libraries, checked on 2026-10-10
Section titled “The underlying libraries, checked on 2026-10-10”- tsyringe (Microsoft, MIT): latest npm version 4.10.0, published 2025-04-16; about 14
million downloads a week. The repository is not archived, but its last functional commits
date from April 2025 (the only later commit, September 2026, pins CI actions), its last
GitHub release tag is v4.4.0 from 2020, and 82 issues are open. In plain words: widely
used, stable, in low-activity maintenance. It refuses to load without
reflect-metadata(0.2.2, March 2024). - How much the kit depends on it.
@ddd-ts-kit/diuses a tsyringe child container as a registry: ports are registered under a symbol identity, dependencies are named in lists and never reflected, no tsyringe decorator appears in your code, andreflect-metadatais imported by the package only because tsyringe demands it. The surface exposed to tsyringe's future is therefore small, and the wiring checks are the kit's own. It is still a dependency on a quiet project: a team for which that is unacceptable has the two other options, at the cost stated below. - NestJS (MIT):
@nestjs/core12.1.2, published 2026-09-30, about 16 million downloads a week, Node 20 or later, actively released. - Alternatives not supported by the kit, for context only: InversifyJS 8.2.3 and Awilix
13.0.5 are both maintained (releases in 2026). Neither has an adapter here; using one means
stack.di: manualsemantics from the kit's point of view.
Selection criteria
Section titled “Selection criteria”- Do you want the declared style? One decorator per class, no module file, no
dependency list: a generated barrel, which also registers what each constructor takes, kept
current by the generator (
declarations/barrel-current). Only tsyringe gives it, and it is its default: the CLI refuses--style declaredwith--di manualand--di nest, which default toexplicit.--di nestgoes with--http nestand the reverse. - Is NestJS already the framework? Then its container is there anyway (HTTP frameworks).
- How much checking at start do you want? The checks of
createApplication(a command with two handlers, two consumers sharing a name, a use case that declares no permission) are what manual wiring gives up. - Bundling and cold start. A barrel with
contextModuleis static and followed by a bundler; discovery reads directories at startup and cannot be bundled. - Dependency budget. Manual wiring adds nothing.
What this kit supports and verified
Section titled “What this kit supports and verified”From packages/di/README.md,
packages/kernel/README.md and
packages/cli/README.md:
- Constructor injection that holds whoever compiles: both decorator modes,
tsc6 and 7,tsx, Vitest and Jest with swc, because nothing is asked of the compiler at run time. - Compile-time checks, in both decorator modes: a generated or written dependency list that does not match the constructor, a class that does not implement the port it is declared the adapter of, a concrete class named where a port is expected.
- Load-time checks (
DeclarationError): a port with a body, an adapter that extends its port, two declarations on one class. - Start-time checks (
WiringError): duplicate or missing bindings, cycles between class bindings, duplicate handlers, consumers and jobs,rebindof nothing, a declared class whose constructor takes parameters and that no list covers, or a list of another length; withauthorization: 'required', a use case that declares neither a permission nor that it is public, and declarations that no module enforces. kernelModule()binds a working default for every kernel port (in-memory buses, unit of work, cache, lock, rate limiter, no-op logger and metrics), each replaceable by option or byrebind.- Two assemblies of a declared context (
stack.assembly):barrel(default) anddiscovery, with or without a broker.--assemblyis refused with--style explicit. - Proven end to end by the CLI (
proven-stacks.spec.ts, as its README lists them): generated projects in the declared style (the default stack; with ReBAC and RabbitMQ; with knex and discovery, alone and behind NATS), in the explicit style on tsyringe (the default stack; with ReBAC and RabbitMQ), and with--di manual(with ABAC and knex; with Fastify, knex, RabbitMQ and Redis). No generated NestJS project appears in that list: NestJS stacks are generated and held tocheckand to the type check, not installed and run. - Test application with fixed clock, sequential identifiers, in-memory buses and fakes bound under the port class.
The checks above catch each mistake at the earliest of four moments; only a dependency resolved inside a factory waits for the first message that builds the class:
Limits and when not to use it
Section titled “Limits and when not to use it”Stated by the package:
- A class plays one role: one declaration per class. An adapter that implements two ports is bound explicitly, once per port.
- A declaration is not inherited: a subclass declares itself or is bound explicitly.
- Names (consumer, policy, job, domain event) are written in the declaration, never derived from the class name.
- A port of your own is identified by its class: loaded from two copies of a package it is two
tokens, unless it declares a
tokenKey. An application compiled as one program never meets this. - Factories do not declare what they resolve: a cycle or a missing port going through a factory is reported when it is resolved, not at start.
- Discovery imports files that nothing else imports: knip and dependency-cruiser must be told, it runs sequentially at startup, and a bundler cannot follow it.
- A class decorator of another library that replaces the class must sit below the kit's declaration.
Of the alternatives:
- NestJS: explicit style only; the kit's routes bypass Nest's request pipeline; the legacy decorator mode applies to the whole program; the start-time checks above are replaced by Nest's.
- Manual: no check that every handler is registered or every use case protected beyond
what
ddd-ts-kit checkreads in the text (authz/declared,events/consumer-idempotent); cross-cutting wrappers are applied by hand, so one forgotten call is one untraced adapter.
Do not introduce a container at all in a script, a migration or a context of three classes run alone: constructing the objects by hand is the composition root.
Recommendation by situation
Section titled “Recommendation by situation”| Situation | Choice |
|---|---|
| New project on Express or Fastify | tsyringe with the declared style and a barrel: the default, the most generated, the most checked |
| Unusual wiring in one context (factories, conditional bindings) | the same, with an explicit module for that context, or include in a mixed module |
| Serverless or bundled deployment | tsyringe, contextModule with the barrel; avoid discovery |
| Existing NestJS application | NestJS container, explicit style, providers by factory |
| A policy against low-activity dependencies, or a very small service | manual wiring, knowing which start-time checks are given up |
Sources
Section titled “Sources”- tsyringe: https://github.com/microsoft/tsyringe (commits, releases and issues read on
2026-10-10); npm registry entry
tsyringe. - NestJS documentation, custom providers: https://docs.nestjs.com/fundamentals/custom-providers
- InversifyJS: https://inversify.io ; Awilix: https://github.com/jeffijoe/awilix
- TypeScript 5.0 release notes, decorators: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html
- Robert C. Martin, Clean Architecture, Prentice Hall, 2017, chapter 26 ("The Main Component").
- Mark Seemann, "Composition Root" (2011): https://blog.ploeh.dk/2011/07/28/CompositionRoot/
- Martin Fowler, "Inversion of Control Containers and the Dependency Injection pattern" (2004): https://martinfowler.com/articles/injection.html