Skip to content

Dependency Graph

When your application contains several services, each service can depend on other registered services.

In Xeno.JS, you define these relationships explicitly in your dependency injection registrations.

For example:

UserController
|
v
UserService
|
v
UserRepository
|
v
UserDataSource

The dependency graph is expressed directly in the registration code:

services.addScoped('USER_SERVICE', (scope) => {
return new UserService(
scope.resolve('USER_REPOSITORY'),
)
})
services.addScoped('USER_REPOSITORY', (scope) => {
return new UserRepository(
scope.resolve('USER_DATA_SOURCE'),
)
})

You do not need decorators, runtime scanning, or a separate graph configuration.

You should already have:

  • a Xeno.JS application created with AppBuilder
  • the services you want to connect
  • service registrations for their dependencies
  • a clear lifetime for each service

For application-level registration, use AppBuilder.addServices():

const app = new AppBuilder()
.addServices((services) => {
// service registrations
})
await app.build()

addServices() gives you access to the configured IServiceContainer, where you can register singleton, scoped, and transient services.

See Registration for the registration API and Lifetimes for service lifetime details.

Register the dependency first conceptually, then resolve it from the dependent service’s factory.

For example, suppose UserService needs UserRepository.

services.addScoped('USER_REPOSITORY', (scope) => {
return new UserRepository(
scope.resolve('USER_DATA_SOURCE'),
)
})
services.addScoped('USER_SERVICE', (scope) => {
return new UserService(
scope.resolve('USER_REPOSITORY'),
)
})

The resulting dependency relationship is:

USER_SERVICE
|
v
USER_REPOSITORY
|
v
USER_DATA_SOURCE

The scope passed to every registration factory is the mechanism used to resolve dependencies.

A service can depend on more than one service.

For example:

services.addScoped('USER_SERVICE', (scope) => {
return new UserService(
scope.resolve('USER_REPOSITORY'),
scope.resolve('USER_VALIDATOR'),
scope.resolve('LOGGER'),
)
})

The graph is:

┌──> USER_REPOSITORY
|
USER_SERVICE ├──> USER_VALIDATOR
|
└──> LOGGER

Each resolve() call adds another dependency to the service’s composition.

Keep the dependencies explicit in the factory:

services.addScoped('USER_SERVICE', (scope) => {
const repository = scope.resolve('USER_REPOSITORY')
const validator = scope.resolve('USER_VALIDATOR')
const logger = scope.resolve('LOGGER')
return new UserService(repository, validator, logger)
})

This form can be useful when the registration becomes large or when you want to make the dependency list easier to inspect.

Dependencies can continue through several levels.

For example:

services.addScoped('USER_CONTROLLER', (scope) => {
return new UserController(
scope.resolve('USER_SERVICE'),
)
})
services.addScoped('USER_SERVICE', (scope) => {
return new UserService(
scope.resolve('USER_REPOSITORY'),
)
})
services.addScoped('USER_REPOSITORY', (scope) => {
return new UserRepository(
scope.resolve('USER_DATA_SOURCE'),
)
})
services.addScoped('USER_DATA_SOURCE', () => {
return new UserDataSource()
})

The resulting graph is:

USER_CONTROLLER
|
v
USER_SERVICE
|
v
USER_REPOSITORY
|
v
USER_DATA_SOURCE

This makes the composition of the application visible in the registration code.

When designing a service graph, register dependencies according to the direction in which your application needs them.

For example:

Controller
|
v
Application Service
|
v
Repository
|
v
Data Source

The controller depends on the application service.

The application service depends on the repository.

The repository depends on the data source.

The dependency direction is encoded directly in the factories:

services.addScoped('USER_CONTROLLER', (scope) => {
return new UserController(
scope.resolve('USER_SERVICE'),
)
})
services.addScoped('USER_SERVICE', (scope) => {
return new UserService(
scope.resolve('USER_REPOSITORY'),
)
})
services.addScoped('USER_REPOSITORY', (scope) => {
return new UserRepository(
scope.resolve('USER_DATA_SOURCE'),
)
})

If a dependency points in an unexpected direction, review the service boundary before adding another registration.

Use built-in Xeno.JS services as dependencies

Section titled “Use built-in Xeno.JS services as dependencies”

Xeno.JS registers framework services in the application registry.

For example, a service can depend on the request context or mediator:

services.addTransient('USER_CONTROLLER', (scope) => {
return new UserController(
scope.resolve('REQUEST_CONTEXT'),
scope.resolve('MEDIATOR'),
)
})

The built-in tokens are typed through the application registry, so the token determines the type returned by resolve().

Common built-in services include:

  • REQUEST_CONTEXT
  • MEDIATOR
  • LOGGER
  • CACHE
  • CONFIGURATION_SERVICE
  • SERVICE_CONTAINER
  • SERVICE_SCOPE_ACCESSOR
  • UNIT_OF_WORK
  • VALIDATOR_SERVICE

Only use a built-in token when the corresponding service is available in your application’s configuration.

For larger applications, related registrations can be grouped in a module instead of placing everything in one addServices() callback.

A module receives the same service container and can configure its own services.

Conceptually:

Application
|
+-- User Module
| |
| +-- UserController
| +-- UserService
| +-- UserRepository
|
+-- Order Module
|
+-- OrderService
+-- OrderRepository

Each module can own the registrations required for its feature.

This keeps the composition root manageable while keeping dependencies explicit.

See Registration for service registration and the module documentation for application module composition.

A circular dependency exists when services eventually depend on themselves.

For example:

SERVICE_A
|
v
SERVICE_B
|
v
SERVICE_A

The registrations might look like this:

services.addTransient('SERVICE_A', (scope) => {
return new ServiceA(
scope.resolve('SERVICE_B'),
)
})
services.addTransient('SERVICE_B', (scope) => {
return new ServiceB(
scope.resolve('SERVICE_A'),
)
})

Xeno.JS detects the circular dependency while resolving the services and throws an error similar to:

[DI Circular Dependency Error]: Detected circular dependency while resolving 'SERVICE_A'. Resolution path: SERVICE_A -> SERVICE_B -> SERVICE_A

Do not try to work around the error by adding another resolve() call.

Instead, identify why the two services require each other.

For example, if:

UserService -> NotificationService -> UserService

is required only because both services need a small shared operation, extract that operation into a separate service:

+------------------+
| SharedService |
+------------------+
^ ^
| |
UserService NotificationService

Then register the new dependency explicitly:

services.addScoped('SHARED_SERVICE', () => {
return new SharedService()
})
services.addScoped('USER_SERVICE', (scope) => {
return new UserService(
scope.resolve('SHARED_SERVICE'),
scope.resolve('NOTIFICATION_SERVICE'),
)
})
services.addScoped('NOTIFICATION_SERVICE', (scope) => {
return new NotificationService(
scope.resolve('SHARED_SERVICE'),
)
})

If the two services genuinely need each other, review the application boundary rather than attempting to hide the cycle inside the container.

See Captive Dependencies for lifetime-related dependency problems.

The dependency graph also includes the lifetime of each service.

For example:

Singleton
|
v
Scoped

is not a valid dependency relationship in Xeno.JS.

A singleton is created at the container level, while a scoped service belongs to an active scope. Allowing the singleton to retain the scoped service would make the scoped instance outlive its intended scope.

Xeno.JS detects this situation and throws a captive dependency error.

For example:

[DI Captive Dependency Error]: Attempted to resolve a scoped service 'REQUEST_SERVICE' from a singleton context. This can lead to captive dependencies. Resolution path: ...

If a singleton needs data that is specific to a scope, change the design so that the scoped value is resolved within the appropriate scope rather than stored by the singleton.

See Singleton, Scoped, and Captive Dependencies.

Use the service scope for scoped dependencies

Section titled “Use the service scope for scoped dependencies”

If a service depends on a scoped service, its factory must resolve the dependency from the active service scope.

For example:

services.addScoped('REQUEST_DATA', () => {
return new RequestData()
})
services.addScoped('USER_SERVICE', (scope) => {
return new UserService(
scope.resolve('REQUEST_DATA'),
)
})

Both services are resolved within the same logical scope:

Request scope
|
+--> USER_SERVICE
|
+--> REQUEST_DATA

This allows REQUEST_DATA to remain scoped to the current application execution.

See Scoped and Resolution.

Xeno.JS does not require a separate dependency graph declaration.

When investigating a service, start from its registration:

services.addScoped('USER_SERVICE', (scope) => {
return new UserService(
scope.resolve('USER_REPOSITORY'),
scope.resolve('LOGGER'),
)
})

The immediate dependencies are:

USER_SERVICE
├──> USER_REPOSITORY
└──> LOGGER

Then inspect the registrations for those dependencies:

services.addScoped('USER_REPOSITORY', (scope) => {
return new UserRepository(
scope.resolve('USER_DATA_SOURCE'),
)
})

Now the graph becomes:

USER_SERVICE
├──> USER_REPOSITORY
│ |
│ └──> USER_DATA_SOURCE
|
└──> LOGGER

This is the practical way to trace a dependency chain in Xeno.JS: follow each resolve() from the service registration to the next registration.

If a dependency is referenced but has not been registered:

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

and USER_REPOSITORY has no registration, Xeno.JS throws:

[DI Container Error]: Registration not found for token 'USER_REPOSITORY'. Ensure the service is registered before resolving.

Check that:

  1. the dependency has been registered;
  2. the token is spelled correctly;
  3. the registration is included in the application build;
  4. the module containing the registration is configured.

A token cannot be registered more than once in the same container.

For example:

services.addScoped('USER_SERVICE', () => {
return new UserService()
})
services.addScoped('USER_SERVICE', () => {
return new UserService()
})

causes:

[DI Container Error]: The token 'USER_SERVICE' is already registered in the container.

Keep a single registration for each token in a container.

If multiple implementations are required, use different tokens and choose explicitly which service each consumer depends on.

If a dependency is scoped but the service is resolved from the root container, Xeno.JS throws:

[DI Container Error]: Scoped service 'USER_SERVICE' requires an active scope.

Resolve scoped services through an IServiceScope instead.

const scope = container.createScope()
try {
const service = scope.resolve('USER_SERVICE')
await service.execute()
} finally {
await scope.dispose()
}

In request-driven code, use the request’s active service scope rather than creating an unrelated scope for every dependency.

See Resolution.

If the container reports:

[DI Circular Dependency Error]

follow the resolution path included in the error.

For example:

SERVICE_A -> SERVICE_B -> SERVICE_C -> SERVICE_A

Inspect those registrations and remove the cycle by changing the service boundary or extracting a shared dependency.

If the error contains:

[DI Captive Dependency Error]

check the lifetimes along the reported resolution path.

A singleton should not directly resolve a scoped service during its construction.

See Captive Dependencies.

The following example defines a small application graph with a controller, application service, repository, and data source:

import { AppBuilder } from '@xeno-js/core'
class UserDataSource {
async findById(id: string) {
return {
id,
name: 'Ada',
}
}
}
class UserRepository {
constructor(
private readonly dataSource: UserDataSource,
) {}
findById(id: string) {
return this.dataSource.findById(id)
}
}
class UserService {
constructor(
private readonly repository: UserRepository,
) {}
findById(id: string) {
return this.repository.findById(id)
}
}
class UserController {
constructor(
private readonly service: UserService,
) {}
handle(id: string) {
return this.service.findById(id)
}
}
const app = new AppBuilder().addServices((services) => {
services.addScoped('USER_DATA_SOURCE', () => {
return new UserDataSource()
})
services.addScoped('USER_REPOSITORY', (scope) => {
return new UserRepository(
scope.resolve('USER_DATA_SOURCE'),
)
})
services.addScoped('USER_SERVICE', (scope) => {
return new UserService(
scope.resolve('USER_REPOSITORY'),
)
})
services.addTransient('USER_CONTROLLER', (scope) => {
return new UserController(
scope.resolve('USER_SERVICE'),
)
})
})
const container = await app.build()

The dependency graph is:

USER_CONTROLLER
|
v
USER_SERVICE
|
v
USER_REPOSITORY
|
v
USER_DATA_SOURCE

The important part is that every edge in the graph is visible in the registration code.

When adding a new service, verify:

  • The service has a registration.
  • Every dependency has a registration.
  • Each dependency is resolved explicitly from the factory scope.
  • The service lifetime matches how it is used.
  • Scoped dependencies are resolved inside an active scope.
  • There is no circular dependency.
  • A singleton does not depend directly on a scoped service.
  • The dependency direction matches the application’s boundaries.
  • The registrations are included in the application build.
  • Service Container — understand the container used to register and resolve services.
  • Registration — register singleton, scoped, and transient services.
  • Singleton — configure services shared by the container.
  • Scoped — configure services tied to a logical scope.
  • Transient — create a new instance for each resolution.
  • Resolution — resolve services from the container or an active scope.
  • Captive Dependencies — fix invalid lifetime relationships.

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