Service Container
Introduction
Section titled “Introduction”Xeno.JS uses a service container to register and resolve the dependencies used by your application.
The container gives you explicit dependency injection without requiring decorators, runtime scanning, or implicit dependency discovery.
Use it when you need to:
- register application services;
- connect an interface or token to an implementation;
- define how long a service instance should live;
- resolve dependencies from other services;
- share dependencies across application components;
- keep dependency configuration in your application composition root.
For most applications, you interact with the container through AppBuilder rather than creating a ServiceContainer directly.
Before you start
Section titled “Before you start”You need @xeno-js/core installed:
npm install @xeno-js/coreThe usual application entry point is an AppBuilder:
import { AppBuilder } from '@xeno-js/core'
const app = new AppBuilder()AppBuilder owns the application’s service container and exposes the registration and resolution APIs you normally need.
Register Services
Section titled “Register Services”Register application services with AppBuilder.addServices().
The callback receives the service container:
import { AppBuilder } from '@xeno-js/core'
const app = new AppBuilder()
app.addServices((services) => { services.addScoped('USER_REPOSITORY', (scope) => { return new UserRepository( scope.resolve('USER_DATA_SOURCE'), ) })})The registration consists of:
- a token that identifies the service;
- a factory that creates the service;
- a lifetime that determines how the created instance is reused.
The factory receives the current service scope, so dependencies can be resolved explicitly:
services.addScoped('USER_SERVICE', (scope) => { return new UserService( scope.resolve('USER_REPOSITORY'), )})This makes the dependency relationship visible directly in the registration.
Register multiple services
Section titled “Register multiple services”You can register multiple services in the same addServices() callback:
const app = new AppBuilder()
app.addServices((services) => { 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'), ) })})addServices() is chainable:
const app = new AppBuilder() .addServices((services) => { services.addScoped('USER_REPOSITORY', (scope) => { return new UserRepository( scope.resolve('USER_DATA_SOURCE'), ) }) })Choose a Service Lifetime
Section titled “Choose a Service Lifetime”Every registration must use one of the available lifetimes:
| Method | Instance behavior | Typical use |
|---|---|---|
addSingleton() |
One instance for the container | Shared application services |
addScoped() |
One instance per scope | Request or operation-specific services |
addTransient() |
New instance for each resolution | Lightweight, stateless services |
For details, see:
Resolve a Service
Section titled “Resolve a Service”If you have an AppBuilder, you can resolve a registered root-level service with app.resolve():
const app = new AppBuilder() .addServices((services) => { services.addSingleton('CONFIG_SERVICE', () => { return new ConfigService() }) })
await app.build()
const config = app.resolve('CONFIG_SERVICE')AppBuilder.resolve() delegates resolution to its service container.
This is useful for services that can be resolved directly from the root container, such as singleton and transient services.
Scoped services are different: they require an active scope.
Resolve Dependencies Inside a Factory
Section titled “Resolve Dependencies Inside a Factory”The registration factory receives a service scope.
Use that scope to resolve the dependencies required to construct your service:
services.addScoped('USER_SERVICE', (scope) => { const repository = scope.resolve('USER_REPOSITORY') const logger = scope.resolve('LOGGER')
return new UserService( repository, logger, )})This is the standard Xeno.JS pattern for explicit dependency injection.
You do not need to manually construct the dependency graph at every call site:
// Avoid constructing the dependency graph manually.const repository = new UserRepository(...)const logger = new Logger(...)const service = new UserService(repository, logger)Instead, register the graph once:
services.addScoped('USER_SERVICE', (scope) => { return new UserService( scope.resolve('USER_REPOSITORY'), scope.resolve('LOGGER'), )})Then resolve the service where it is needed.
Use a Service Scope
Section titled “Use a Service Scope”A service scope is required when working with scoped services.
You can create a scope from the service container:
const container = await app.build()
const scope = container.createScope()
const repository = scope.resolve('USER_REPOSITORY')
await scope.dispose()A scope provides the lifetime boundary for scoped services.
Within the same scope:
const first = scope.resolve('USER_REPOSITORY')const second = scope.resolve('USER_REPOSITORY')
console.log(first === second) // trueA different scope receives a different scoped instance:
const scope1 = container.createScope()const scope2 = container.createScope()
const repository1 = scope1.resolve('USER_REPOSITORY')const repository2 = scope2.resolve('USER_REPOSITORY')
console.log(repository1 === repository2) // false
await scope1.dispose()await scope2.dispose()For request-scoped application code, Xeno.JS can manage the active scope through its context system. See Context & Scopes.
Resolve a Scoped Service with ContainerUtils
Section titled “Resolve a Scoped Service with ContainerUtils”If you already have the application container and need to resolve a scoped service from the current active scope, use ContainerUtils.resolveServiceScoped():
import { ContainerUtils } from '@xeno-js/core'
const repository = ContainerUtils.resolveServiceScoped( 'USER_REPOSITORY', app,)This is useful at application or transport boundaries where you have the application container but want the service associated with the current active scope.
A scoped service cannot be resolved directly from the root container:
app.resolve('USER_REPOSITORY')Instead, when an active scope exists:
ContainerUtils.resolveServiceScoped( 'USER_REPOSITORY', app,)If there is no active scope, resolveServiceScoped() throws:
Active service scope is required to execute Scoped service.See Context & Scopes for more information about active scopes.
Build the Application
Section titled “Build the Application”Service registrations added through AppBuilder.addServices() are applied when the application is built.
A typical bootstrap looks like this:
import { AppBuilder } from '@xeno-js/core'
const app = new AppBuilder()
app.addServices((services) => { 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'), ) })})
const container = await app.build()After build() completes, the application container is ready to resolve the registered services.
Complete Example
Section titled “Complete Example”The following example shows a small dependency graph:
USER_CONTROLLER | vUSER_SERVICE | vUSER_REPOSITORY | vUSER_DATA_SOURCERegister the services explicitly:
import { AppBuilder } from '@xeno-js/core'
const app = new AppBuilder()
app.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'), ) })})
await app.build()The dependency graph is defined entirely through registrations.
When USER_CONTROLLER is resolved, its factory resolves USER_SERVICE, which resolves USER_REPOSITORY, which resolves USER_DATA_SOURCE.
Use Custom Registries
Section titled “Use Custom Registries”Xeno.JS uses a typed registry to associate service tokens with their TypeScript types.
This means registrations and resolutions are type-checked.
For example, if your registry contains:
type ApplicationRegistry = { USER_REPOSITORY: UserRepository USER_SERVICE: UserService}then:
services.addScoped('USER_REPOSITORY', () => { return new UserRepository()})and:
const service = app.resolve('USER_SERVICE')are checked against the registered token types.
This allows the dependency injection API to remain explicit while preserving TypeScript type safety.
For applications extending the Xeno.JS registry, see Registration.
Registering the Same Token Twice
Section titled “Registering the Same Token Twice”A token can only have one registration in a container.
This is invalid:
services.addScoped('USER_SERVICE', () => { return new UserService()})
services.addSingleton('USER_SERVICE', () => { return new UserService()})The second registration throws a container error similar to:
[DI Container Error]: The token 'USER_SERVICE' is already registered in the container.If you need different implementations, use different tokens.
Missing Registrations
Section titled “Missing Registrations”Resolving a token that has not been registered fails.
For example:
const app = new AppBuilder()
app.addServices((services) => { services.addScoped('USER_SERVICE', () => { return new UserService() })})
await app.build()
app.resolve('USER_REPOSITORY')The container reports:
[DI Container Error]: Registration not found for token 'USER_REPOSITORY'. Ensure the service is registered before resolving.When this happens, check:
- the token used by
resolve(); - the token used during registration;
- that the registration is inside
addServices(); - that
app.build()has completed before resolving from the built container.
Token names must match exactly.
Scoped Service Resolution Errors
Section titled “Scoped Service Resolution Errors”This is invalid:
services.addScoped('USER_REPOSITORY', () => { return new UserRepository()})
const repository = app.resolve('USER_REPOSITORY')A scoped service requires an active scope.
The container reports:
[DI Container Error]: Scoped service 'USER_REPOSITORY' requires an active scope.Use an active scope instead:
const container = await app.build()const scope = container.createScope()
const repository = scope.resolve('USER_REPOSITORY')
await scope.dispose()Or, when an active application scope already exists:
const repository = ContainerUtils.resolveServiceScoped( 'USER_REPOSITORY', app,)Circular Dependencies
Section titled “Circular Dependencies”The container detects circular dependencies during resolution.
For example:
services.addTransient('SERVICE_A', (scope) => { return new ServiceA( scope.resolve('SERVICE_B'), )})
services.addTransient('SERVICE_B', (scope) => { return new ServiceB( scope.resolve('SERVICE_A'), )})Resolving SERVICE_A produces a circular dependency error:
[DI Circular Dependency Error]: Detected circular dependency while resolving 'SERVICE_A'.If this happens, inspect the dependency graph and remove the cycle.
See Dependency Graph for strategies for structuring service dependencies.
Captive Dependencies
Section titled “Captive Dependencies”A singleton must not depend on a scoped service.
For example:
services.addScoped('REQUEST_SERVICE', () => { return new RequestService()})
services.addSingleton('APPLICATION_SERVICE', (scope) => { return new ApplicationService( scope.resolve('REQUEST_SERVICE'), )})Resolving the singleton causes a captive dependency error.
[DI Captive Dependency Error]: Attempted to resolve a scoped service 'REQUEST_SERVICE' from a singleton context.If you encounter this error, review the lifetimes of the services involved.
See Captive Dependencies.
Dispose the Container and Scopes
Section titled “Dispose the Container and Scopes”The service container and scopes implement disposal.
Dispose a scope when it is no longer needed:
const scope = container.createScope()
try { const service = scope.resolve('USER_SERVICE')
// Use the service.} finally { await scope.dispose()}Dispose the application container during application shutdown:
await container.dispose()Services that expose a dispose() method can participate in container or scope cleanup.
This is particularly useful for services that own resources that must be released explicitly.
When to Use the Service Container Directly
Section titled “When to Use the Service Container Directly”Most application code should use AppBuilder.addServices() for registration.
Use the underlying container directly when you are building infrastructure, modules, or integration code that receives an IServiceContainer.
For example:
async function configureModule( container: IServiceContainer,) { container.addScoped('USER_REPOSITORY', (scope) => { return new UserRepository( scope.resolve('USER_DATA_SOURCE'), ) })}Application code generally does not need to instantiate ServiceContainer manually.
The normal application flow is:
AppBuilder | vaddServices() | vService registration | vbuild() | vService resolutionTroubleshooting
Section titled “Troubleshooting”Registration not found for token
Section titled “Registration not found for token”Check that the service has been registered using exactly the same token:
services.addScoped('USER_SERVICE', ...)must be resolved with:
scope.resolve('USER_SERVICE')not a different token.
Scoped service requires an active scope
Section titled “Scoped service requires an active scope”The service was registered with addScoped() but was resolved from the root container.
Use:
const scope = container.createScope()
const service = scope.resolve('USER_SERVICE')or use ContainerUtils.resolveServiceScoped() when an active scope already exists.
already registered
Section titled “already registered”The same token has been registered more than once.
Remove the duplicate registration or give the implementations different tokens.
DI Circular Dependency Error
Section titled “DI Circular Dependency Error”Two or more services depend on each other through their registration factories.
Inspect the resolution path in the error and remove the dependency cycle.
DI Captive Dependency Error
Section titled “DI Captive Dependency Error”A singleton is trying to resolve a scoped service.
Review the lifetimes of the services involved and move the scoped dependency to a scoped/transient component, or change the dependency boundary.
Resolution after disposal fails
Section titled “Resolution after disposal fails”A disposed scope can no longer resolve services.
Make sure the scope remains alive for the complete operation that uses its services.
Service Container Checklist
Section titled “Service Container Checklist”Before considering your dependency injection setup complete:
- Every service has a unique registration token.
- Every service uses the appropriate lifetime.
- Dependencies are resolved explicitly inside registration factories.
- Scoped services are only resolved from an active scope.
- The application is built before resolving services from the built container.
- There are no circular dependencies.
- Singletons do not depend on scoped services.
- Scopes are disposed when their work is complete.
- The application container is disposed during application shutdown when appropriate.
Related Documentation
Section titled “Related Documentation”- Dependency Injection
- Registration
- Lifetimes
- Singleton
- Scoped
- Transient
- Resolution
- Dependency Graph
- Captive Dependencies
- Context & Scopes
- App Builder
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
