Dependency Injection
Introduction
Section titled “Introduction”Xeno.JS uses explicit dependency injection to compose application services.
The dependency graph is defined through TypeScript code rather than discovered through decorators or runtime constructor metadata.
The basic model is:
Application Registry │ ▼ Service Container │ ▼ Registration Factory │ ▼ Active Service Scope │ ├── resolve dependency A ├── resolve dependency B └── create serviceThis makes dependency construction visible at the composition boundary.
What dependency injection means in Xeno.JS
Section titled “What dependency injection means in Xeno.JS”Dependency injection separates two responsibilities:
- defining a service
- deciding how that service receives its dependencies
For example, a service can remain an ordinary TypeScript class:
class UserService { constructor( private readonly repository: UserRepository, ) {}
async getUser(id: string) { return this.repository.findById(id) }}Xeno.JS does not need to inspect this constructor at runtime.
Instead, the composition is declared explicitly:
container.addTransient('USER_SERVICE', (scope) => { const repository = scope.resolve('USER_REPOSITORY')
return new UserService(repository)})The dependency is visible exactly where the service is composed.
Explicit factories instead of hidden injection
Section titled “Explicit factories instead of hidden injection”The central difference is that Xeno.JS uses factory-based dependency registration.
A registration has three parts:
container.addTransient( 'USER_SERVICE', (scope) => { const repository = scope.resolve('USER_REPOSITORY')
return new UserService(repository) },)The first argument is the token:
'USER_SERVICE'The second argument is the factory:
(scope) => { const repository = scope.resolve('USER_REPOSITORY')
return new UserService(repository)}The factory receives the current IServiceScope.
Dependencies are then resolved explicitly from that scope.
This is intentionally different from a container that scans constructors and automatically determines their dependencies.
Injection tokens
Section titled “Injection tokens”Xeno.JS uses registry keys as dependency injection tokens.
The ApplicationRegistry defines the relationship between a token and its service type.
Conceptually:
interface ApplicationRegistry { USER_REPOSITORY: UserRepository USER_SERVICE: UserService}The container can then use those keys for typed resolution:
const service = container.resolve('USER_SERVICE')The TypeScript type of service is derived from the registry.
This means the token and the resolved service type remain connected at compile time.
Extending the registry
Section titled “Extending the registry”Applications can extend the Xeno registry with their own services.
For example:
type AppRegistry = XenoRegistry< Dictionary, { USER_REPOSITORY: UserRepository USER_SERVICE: UserService }>The resulting registry becomes the type contract for the application’s Service Container.
const container = new ServiceContainer<AppRegistry>()Now registrations and resolutions are checked against the application registry.
container.addSingleton( 'USER_REPOSITORY', () => new UserRepository(),)And:
const repository = container.resolve('USER_REPOSITORY')The registry therefore acts as the compile-time contract for dependency injection.
Registering dependencies
Section titled “Registering dependencies”Xeno.JS provides three registration methods:
container.addSingleton(...)container.addTransient(...)container.addScoped(...)Each method receives:
- a registry token
- a factory function
Singleton
Section titled “Singleton”container.addSingleton( 'CONFIGURATION', () => new ConfigurationService(),)The factory is evaluated once and the resulting instance is reused.
Transient
Section titled “Transient”container.addTransient( 'USER_SERVICE', (scope) => { return new UserService( scope.resolve('USER_REPOSITORY'), ) },)A new instance is produced on every resolution.
Scoped
Section titled “Scoped”container.addScoped( 'REQUEST_CONTEXT', () => new RequestContext(),)A scoped instance is created once within each scope.
For more details, see Service Lifetimes.
Resolving dependencies
Section titled “Resolving dependencies”The resolve() method is the explicit dependency resolution mechanism.
From a container:
const logger = container.resolve('LOGGER')From a scope:
const scope = container.createScope()
const context = scope.resolve('REQUEST_CONTEXT')The difference is important.
The root container can resolve singleton and transient services.
A scoped service requires an active scope:
container.addScoped( 'REQUEST_CONTEXT', () => new RequestContext(),)This is invalid:
container.resolve('REQUEST_CONTEXT')Xeno.JS throws an error indicating that the scoped service requires an active scope.
The correct form is:
const scope = container.createScope()
const context = scope.resolve('REQUEST_CONTEXT')Dependency chains
Section titled “Dependency chains”Dependencies can resolve other dependencies.
For example:
container.addSingleton( 'USER_REPOSITORY', () => new UserRepository(),)
container.addTransient( 'USER_SERVICE', (scope) => { return new UserService( scope.resolve('USER_REPOSITORY'), ) },)
container.addTransient( 'USER_CONTROLLER', (scope) => { return new UserController( scope.resolve('USER_SERVICE'), ) },)The resulting graph is:
USER_CONTROLLER │ ▼USER_SERVICE │ ▼USER_REPOSITORYThe container resolves the graph recursively through the factories.
Circular dependencies
Section titled “Circular dependencies”Dependency graphs can contain accidental cycles.
For example:
Service A │ ▼Service B │ ▼Service AXeno.JS tracks the current resolution path.
If the same token is encountered again during the active resolution chain, the container throws a circular dependency error.
For example:
container.addTransient('SERVICE_A', (scope) => { scope.resolve('SERVICE_B')
return new ServiceA()})
container.addTransient('SERVICE_B', (scope) => { scope.resolve('SERVICE_A')
return new ServiceB()})Resolving SERVICE_A produces a resolution path similar to:
SERVICE_A -> SERVICE_B -> SERVICE_AThis is preferable to allowing the cycle to fail later through a less descriptive runtime error.
Captive dependencies
Section titled “Captive dependencies”A captive dependency occurs when a service with a longer lifetime attempts to hold a dependency with a shorter lifetime.
The important case explicitly detected by the current Xeno.JS container is:
Singleton │ ▼ScopedFor example:
container.addScoped( 'REQUEST_CONTEXT', () => new RequestContext(),)
container.addSingleton( 'GLOBAL_SERVICE', (scope) => { return new GlobalService( scope.resolve('REQUEST_CONTEXT'), ) },)The container rejects this resolution.
The reason is that the singleton would capture a scoped instance beyond the intended scope boundary.
Xeno.JS reports this as a captive dependency error and includes the resolution path.
Dependency lifetime rules
Section titled “Dependency lifetime rules”The lifetime of the dependency is part of the dependency graph.
A simplified model is:
Singleton │ ├── Singleton ✓ ├── Transient ✓ └── Scoped ✗For scoped and transient services, Xeno.JS does not apply the same singleton-to-scoped rejection.
For example, a scoped service can resolve a transient service:
container.addTransient( 'VALIDATOR', () => new Validator(),)
container.addScoped( 'USER_SERVICE', (scope) => { const validator = scope.resolve('VALIDATOR')
return new UserService(validator) },)This is a valid composition.
The important rule is that a singleton must not capture a scoped dependency.
Scopes and dependency injection
Section titled “Scopes and dependency injection”Scopes provide the lifetime boundary for scoped dependencies.
const scope = container.createScope()A scoped service is cached within that scope:
const first = scope.resolve('REQUEST_CONTEXT')
const second = scope.resolve('REQUEST_CONTEXT')Both resolutions return the same instance within the scope.
Another scope receives another instance:
const scopeA = container.createScope()const scopeB = container.createScope()
const contextA = scopeA.resolve('REQUEST_CONTEXT')
const contextB = scopeB.resolve('REQUEST_CONTEXT')The instances are isolated:
Container │ ├── Scope A │ └── REQUEST_CONTEXT #1 │ └── Scope B └── REQUEST_CONTEXT #2Singleton instances remain shared between these scopes.
Dependency injection and request execution
Section titled “Dependency injection and request execution”Scopes are particularly useful when application execution has a logical boundary such as an HTTP request.
The general execution model can be represented as:
Incoming Request │ ▼Create Scope │ ▼Resolve Application Services │ ▼Execute Command / Query │ ▼Dispose ScopeThe Service Container itself does not require HTTP to exist.
The same composition model can be used by other application entry points where a logical scope is useful.
AsyncLocalStorage and resolution context
Section titled “AsyncLocalStorage and resolution context”The Xeno.JS Service Container uses Node.js AsyncLocalStorage internally to maintain resolution context.
The resolution context tracks information such as:
- the current dependency resolution stack
- the active lifetime
- the execution flow in which resolution is occurring
This is used primarily for correctness mechanisms such as circular dependency detection and lifetime validation.
For example, two parallel resolution flows should not accidentally share the same dependency-resolution stack.
The repository contains tests specifically covering parallel asynchronous resolution isolation.
Dependency injection and disposal
Section titled “Dependency injection and disposal”Xeno.JS can track disposable services.
A service implementing the disposable contract can participate in container or scope cleanup.
For example:
class DatabaseConnection { async dispose() { await this.close() }}Registered as a scoped service:
container.addScoped( 'DB_CONNECTION', () => new DatabaseConnection(),)When the scope is disposed, tracked disposable resources are disposed.
await scope.dispose()The same principle applies to container-level resources such as singleton instances.
await container.dispose()This makes service lifetime and resource ownership part of the same composition model.
Dependency injection through AppBuilder
Section titled “Dependency injection through AppBuilder”Most applications do not need to instantiate ServiceContainer manually.
AppBuilder exposes dependency registration through addServices():
const app = new AppBuilder()
app.addServices((container) => { container.addSingleton( 'USER_REPOSITORY', () => new UserRepository(), )
container.addTransient( 'USER_SERVICE', (scope) => { return new UserService( scope.resolve('USER_REPOSITORY'), ) }, )})The registrations are queued as part of application composition.
They are applied during:
await app.build()The resulting container is then available to the application runtime.
Custom modules and dependency injection
Section titled “Custom modules and dependency injection”For larger applications, dependency registrations can be grouped into modules.
app.addModule( 'UsersModule', async () => ({ async configure(container) { container.addSingleton( 'USER_REPOSITORY', () => new UserRepository(), )
container.addTransient( 'USER_SERVICE', (scope) => { return new UserService( scope.resolve('USER_REPOSITORY'), ) }, ) }, }),)This keeps related registrations together.
A module can therefore become the composition boundary for a feature:
UsersModule │ ├── UserRepository ├── UserService └── other feature dependenciesExplicit DI vs decorator-driven DI
Section titled “Explicit DI vs decorator-driven DI”Xeno.JS deliberately keeps dependency registration explicit.
There is no requirement to annotate classes such as:
@Injectable()class UserService {}or to rely on constructor reflection to discover dependencies.
Instead:
container.addTransient( 'USER_SERVICE', (scope) => new UserService( scope.resolve('USER_REPOSITORY'), ),)The advantage of this model is that the dependency graph is visible in normal TypeScript code.
It also means there is less runtime metadata involved in dependency discovery.
What Xeno.JS does not infer
Section titled “What Xeno.JS does not infer”Because the model is explicit, Xeno.JS does not need to infer:
- which constructor parameters are dependencies
- which class should implement an interface
- which decorator metadata describes a service
- which lifetime should be assigned automatically
The application declares these decisions at the composition boundary.
For example:
container.addScoped( 'USER_SERVICE', (scope) => { const repository = scope.resolve('USER_REPOSITORY')
return new UserService(repository) },)The code itself describes the composition.
Dependency injection architecture
Section titled “Dependency injection architecture”The complete model is:
Xeno Registry │ ▼ Typed DI Contract │ ▼ Service Container │ ┌───────────┼───────────┐ ▼ ▼ ▼ Singleton Scoped Transient │ │ │ └───────────┼───────────┘ ▼ Service Factory │ ▼ Service Scope │ ▼ Application ServiceThis makes dependency injection part of the application architecture rather than a separate magic layer.
Best practices
Section titled “Best practices”Keep registration at the composition boundary
Section titled “Keep registration at the composition boundary”Prefer:
app.addServices((container) => { // registrations})over constructing infrastructure dependencies inside business logic.
Resolve dependencies explicitly
Section titled “Resolve dependencies explicitly”Prefer:
const repository = scope.resolve('USER_REPOSITORY')over hidden global access.
Choose lifetimes deliberately
Section titled “Choose lifetimes deliberately”Use:
singletonfor shared application-wide resourcesscopedfor state that belongs to one logical execution scopetransientfor short-lived services that should be recreated on resolution
Avoid singleton-to-scoped dependencies
Section titled “Avoid singleton-to-scoped dependencies”Do not create:
Singleton → Scopedbecause the singleton can outlive the scope.
Keep the registry aligned with the application
Section titled “Keep the registry aligned with the application”The registry should describe the services that the application actually exposes through dependency injection.
Use modules as the application grows
Section titled “Use modules as the application grows”When registrations become feature-specific, move them into modules rather than allowing a single composition root to become unmanageable.
Summary
Section titled “Summary”Xeno.JS dependency injection is based on four ideas:
- Typed registry — tokens and service types are connected through TypeScript.
- Explicit factories — dependencies are resolved explicitly from the active scope.
- Explicit lifetimes — singleton, scoped, and transient behavior is declared at registration time.
- Runtime validation — the container detects missing registrations, circular dependencies, and singleton-to-scoped captive dependencies.
The central pattern is simple:
container.addTransient( 'USER_SERVICE', (scope) => { return new UserService( scope.resolve('USER_REPOSITORY'), ) },)There is no hidden dependency discovery here.
The application defines the dependency graph explicitly, and Xeno.JS provides the runtime that manages that graph.
Support Us
Section titled “Support Us”Xeno.JS is an MIT-licensed open source project. It can grow thanks to the support of these awesome people. If you’d like to join them, please read more at support section
