Skip to content

Dependency Injection

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 service

This 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:

  1. defining a service
  2. 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.


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.


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.


Xeno.JS provides three registration methods:

container.addSingleton(...)
container.addTransient(...)
container.addScoped(...)

Each method receives:

  1. a registry token
  2. a factory function
container.addSingleton(
'CONFIGURATION',
() => new ConfigurationService(),
)

The factory is evaluated once and the resulting instance is reused.

container.addTransient(
'USER_SERVICE',
(scope) => {
return new UserService(
scope.resolve('USER_REPOSITORY'),
)
},
)

A new instance is produced on every resolution.

container.addScoped(
'REQUEST_CONTEXT',
() => new RequestContext(),
)

A scoped instance is created once within each scope.

For more details, see Service Lifetimes.


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')

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_REPOSITORY

The container resolves the graph recursively through the factories.


Dependency graphs can contain accidental cycles.

For example:

Service A
│
▼
Service B
│
▼
Service A

Xeno.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_A

This is preferable to allowing the cycle to fail later through a less descriptive runtime error.


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
│
▼
Scoped

For 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.


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 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 #2

Singleton 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 Scope

The 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.


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.


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.


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.


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 dependencies

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.


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.


The complete model is:

Xeno Registry
│
▼
Typed DI Contract
│
▼
Service Container
│
┌───────────┼───────────┐
▼ ▼ ▼
Singleton Scoped Transient
│ │ │
└───────────┼───────────┘
▼
Service Factory
│
▼
Service Scope
│
▼
Application Service

This makes dependency injection part of the application architecture rather than a separate magic layer.


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.

Prefer:

const repository =
scope.resolve('USER_REPOSITORY')

over hidden global access.

Use:

  • singleton for shared application-wide resources
  • scoped for state that belongs to one logical execution scope
  • transient for short-lived services that should be recreated on resolution

Do not create:

Singleton → Scoped

because 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.

When registrations become feature-specific, move them into modules rather than allowing a single composition root to become unmanageable.


Xeno.JS dependency injection is based on four ideas:

  1. Typed registry — tokens and service types are connected through TypeScript.
  2. Explicit factories — dependencies are resolved explicitly from the active scope.
  3. Explicit lifetimes — singleton, scoped, and transient behavior is declared at registration time.
  4. 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.


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