Dependency Graph
Introduction
Section titled “Introduction”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 UserDataSourceThe 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.
Before you start
Section titled “Before you start”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.
Define a dependency between services
Section titled “Define a dependency between services”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 | vUSER_REPOSITORY | vUSER_DATA_SOURCEThe scope passed to every registration factory is the mechanism used to resolve dependencies.
Register multiple dependencies
Section titled “Register multiple 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 | └──> LOGGEREach 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.
Build a multi-level dependency graph
Section titled “Build a multi-level dependency graph”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 | vUSER_REPOSITORY | vUSER_DATA_SOURCEThis makes the composition of the application visible in the registration code.
Keep dependency direction explicit
Section titled “Keep dependency direction explicit”When designing a service graph, register dependencies according to the direction in which your application needs them.
For example:
Controller | vApplication Service | vRepository | vData SourceThe 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_CONTEXTMEDIATORLOGGERCACHECONFIGURATION_SERVICESERVICE_CONTAINERSERVICE_SCOPE_ACCESSORUNIT_OF_WORKVALIDATOR_SERVICE
Only use a built-in token when the corresponding service is available in your application’s configuration.
Register dependencies through modules
Section titled “Register dependencies through modules”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 +-- OrderRepositoryEach 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.
Avoid circular dependencies
Section titled “Avoid circular dependencies”A circular dependency exists when services eventually depend on themselves.
For example:
SERVICE_A | vSERVICE_B | vSERVICE_AThe 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_AFix a circular dependency
Section titled “Fix a circular dependency”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 -> UserServiceis required only because both services need a small shared operation, extract that operation into a separate service:
+------------------+ | SharedService | +------------------+ ^ ^ | | UserService NotificationServiceThen 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.
Respect lifetime boundaries
Section titled “Respect lifetime boundaries”The dependency graph also includes the lifetime of each service.
For example:
Singleton | vScopedis 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_DATAThis allows REQUEST_DATA to remain scoped to the current application execution.
See Scoped and Resolution.
Inspect the graph from your registrations
Section titled “Inspect the graph from your registrations”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 └──> LOGGERThen 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 | └──> LOGGERThis is the practical way to trace a dependency chain in Xeno.JS: follow each resolve() from the service registration to the next registration.
Common problems
Section titled “Common problems”Registration not found
Section titled “Registration not found”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:
- the dependency has been registered;
- the token is spelled correctly;
- the registration is included in the application build;
- the module containing the registration is configured.
Duplicate registration
Section titled “Duplicate registration”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.
Scoped service requires an active scope
Section titled “Scoped service requires an active scope”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.
Circular dependency
Section titled “Circular dependency”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_AInspect those registrations and remove the cycle by changing the service boundary or extracting a shared dependency.
Singleton depends on scoped service
Section titled “Singleton depends on scoped service”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.
Complete example
Section titled “Complete example”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 | vUSER_REPOSITORY | vUSER_DATA_SOURCEThe important part is that every edge in the graph is visible in the registration code.
Dependency graph checklist
Section titled “Dependency graph checklist”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.
Related docs
Section titled “Related docs”- 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.
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
