Deep Dive: Dependency Injection
Technical reference for the DI system in IGNIS - managing resource lifecycles and dependency resolution.
Files:
packages/inversion/src/modules/container/index.ts- BaseContainerandBindingclassespackages/inversion/src/modules/registry/index.ts- BaseMetadataRegistrypackages/inversion/src/modules/metadata/injectors.ts- Base@injectdecoratorpackages/inversion/src/common/types.ts-BindingScopes,BindingValueTypes,BindingKeys,IProviderpackages/core/src/helpers/inversion/container.ts- ExtendedContainerwithApplicationLoggerpackages/core/src/helpers/inversion/registry.ts- ExtendedMetadataRegistry(singleton, with model/repository/datasource mixins)packages/core/src/base/metadata/injectors.ts- Core@inject(wired to extended registry)
Quick Reference
| Component | Purpose | Key Methods |
|---|---|---|
| Container | DI registry managing resource lifecycles | bind(), get(), gets(), instantiate(), resolve(), findByTag(), isBound(), unbind(), clear(), reset() |
| Binding | Single registered dependency configuration | toClass(), toValue(), toProvider(), setScope(), setTags(), getValue(), clearCache() |
| @inject | Decorator marking injection points | Applied to constructor parameters and class properties |
| MetadataRegistry | Stores decorator metadata | Singleton - base via metadataRegistry export, core via MetadataRegistry.getInstance() |
| BindingKeys | Utility for building namespaced keys | BindingKeys.build({ namespace, key }) |
| Boot System | Automatic artifact discovery and binding | Integrates with Container via tags and bindings |
Prerequisites
Before reading this document, you should understand:
- TypeScript Decorators - How decorators work in TypeScript
- IGNIS Application basics - Application lifecycle and initialization
- Services and Controllers - Basic understanding of IGNIS architecture (REST controllers)
- Inversion of Control (IoC) pattern - Martin Fowler's article
Container Class
Heart of the DI system - registry managing all application resources.
File: packages/inversion/src/modules/container/index.ts (Base) & packages/core/src/helpers/inversion/container.ts (Extended)
The base Container extends BaseHelper (which provides scope and identifier properties). The core Container extends the base and adds a Logger instance.
Constructor
const container = new Container({ scope: 'MyApp' }); // scope is optional, defaults to "Container"Key Methods
| Method | Signature | Description |
|---|---|---|
bind | bind<T>({ key: string | symbol }): Binding<T> | Creates and registers a new Binding for the given key. Returns the Binding for fluent configuration. |
get | get<T>({ key, isOptional? }): T | Retrieves a resolved dependency. key can be a string, symbol, or { namespace, key } object. Throws if not found and isOptional is false (default). Returns undefined if isOptional is true and not found. |
gets | gets<T extends unknown[]>({ bindings }): { [K in keyof T]: T[K] | undefined } | Resolves multiple dependencies at once. Each entry in bindings accepts { key, isOptional? }. Returns a tuple-preserving array where missing bindings resolve to undefined. |
getBinding | getBinding<T>({ key }): Binding<T> | undefined | Returns the raw Binding object without resolving it. key accepts string, symbol, or { namespace, key }. |
set | set<T>({ binding: Binding<T> }): void | Directly sets a pre-built Binding into the container. |
isBound | isBound({ key: string | symbol }): boolean | Checks if a binding exists for the given key. |
unbind | unbind({ key: string | symbol }): boolean | Removes a binding. Returns true if it existed. |
resolve | resolve<T>(cls: TClass<T>): T | Alias for instantiate(). Creates a new instance of the class with DI. |
instantiate | instantiate<T>(cls: TClass<T>): T | Creates a new instance of a class, injecting constructor parameters and property dependencies from the container. |
findByTag | findByTag<T>({ tag, exclude? }): Binding<T>[] | Finds all bindings tagged with tag. Optionally exclude specific binding keys via exclude (accepts Array<string> or Set<string>). |
clear | clear(): void | Clears cached singleton values on all bindings (does not remove bindings). |
reset | reset(): void | Removes all bindings entirely. |
getMetadataRegistry | getMetadataRegistry(): MetadataRegistry | Returns the metadata registry. The core Container overrides this to return MetadataRegistry.getInstance(). |
Instantiation Algorithm (Two-Phase)
When container.instantiate(MyClass) is called:
- Constructor injection - Reads
@injectmetadata from the class by parameter index (theReflect-stored array is already index-keyed, so there is no sort step), resolves each dependency from the container, and passes them as constructor arguments. If any index in range has no@injectmetadata,instantiate()throws immediately rather than passingundefined. - Property injection - After the instance is created, reads property metadata, resolves each dependency, and assigns them directly to the instance properties.
// Both constructor and property injection in action
class UserController {
@inject({ key: 'services.NotificationService' })
private notificationService!: NotificationService; // Property injection
constructor(
@inject({ key: 'services.UserService' })
private userService: UserService, // Constructor injection
) {}
}IMPORTANT
Every constructor parameter of a container-instantiated class must carry @inject. Mixing decorated and undecorated parameters is forbidden - @inject stores its metadata at the parameter's index, so an undecorated parameter leaves a hole in that array, and there is no channel through which the container could supply it anyway (it would resolve to undefined). instantiate() refuses the shape by class name and parameter index instead of silently dereferencing the hole:
[NoteController] Constructor parameter 0 has no @inject | Every parameter of a container-instantiated
class must be decorated - the container cannot supply an undecorated oneThe check lives in instantiate(), not in the @inject decorator itself: parameter decorators run right-to-left, so when @inject on parameter 1 runs, parameter 0 has not been visited yet and nothing at that point can know whether it will end up decorated.
This does not apply to @repository-decorated classes whose constructor appears undecorated at index 0 - the @repository decorator programmatically writes that inject metadata (registry.setInjectMetadata({ target, index: 0, ... })) even though no literal @inject appears in source. See Repositories.
Binding Class
A Binding represents a single registered dependency in the container. It provides a fluent API to configure how a dependency should be created and managed.
File: packages/inversion/src/modules/container/index.ts
The Binding class extends BaseHelper.
Constructor
const binding = new Binding<MyService>({ key: 'services.MyService' });When a binding key contains a dot (e.g., services.MyService), the namespace portion (services) is automatically added as a tag. This enables findByTag({ tag: 'services' }) to work without manual tagging.
Configuration Methods
| Method | Signature | Description |
|---|---|---|
toClass | toClass(value: TClass<T>): this | Binds to a class. The container will instantiate it (resolving constructor and property dependencies) when requested. |
toValue | toValue(value: T): this | Binds to a constant value (e.g., a config object, string, number). |
toProvider | toProvider(value: ((container) => T) | TClass<IProvider<T>>): this | Binds to a factory function or a class implementing IProvider<T>. |
setScope | setScope(scope: TBindingScope): this | Sets the lifecycle scope ('singleton' or 'transient'). |
setTags | setTags(...tags: string[]): this | Adds one or more tags to the binding. Tags are additive - calling this multiple times adds more tags. |
getValue | getValue(container?: Container): T | Resolves the binding's value. For CLASS and PROVIDER types, a container argument is required. Respects singleton caching. |
clearCache | clearCache(): void | Clears the cached singleton instance (if any). Next getValue() call will re-create it. |
hasTag | hasTag(tag: string): boolean | Checks if the binding has a specific tag. |
getTags | getTags(): string[] | Returns all tags as an array. |
getScope | getScope(): TBindingScope | Returns the current scope setting. |
getBindingMeta | getBindingMeta({ type }): TClass<T> | T | ... | Returns the raw resolver value. Throws if the requested type does not match the binding's actual type. |
Fluent API Example
container
.bind<UserService>({ key: 'services.UserService' })
.toClass(UserService)
.setScope(BindingScopes.SINGLETON)
.setTags('core');Provider Bindings
Providers allow complex creation logic. Two forms are supported:
Function provider:
container.bind({ key: 'config.db' }).toProvider((container) => {
const env = container.get<EnvConfig>({ key: 'config.env' });
return { host: env.DB_HOST, port: env.DB_PORT };
});Class provider (must implement IProvider<T>):
class DbConfigProvider implements IProvider<DbConfig> {
value(container: Container): DbConfig {
const env = container.get<EnvConfig>({ key: 'config.env' });
return { host: env.DB_HOST, port: env.DB_PORT };
}
}
container.bind({ key: 'config.db' }).toProvider(DbConfigProvider);Binding Scopes
| Scope | Value | Description |
|---|---|---|
BindingScopes.TRANSIENT | 'transient' | (Default) A new instance is created every time the dependency is requested. |
BindingScopes.SINGLETON | 'singleton' | A single instance is created on first request and reused for all subsequent requests. The cache is per-Binding, not per-Container. |
Binding Value Types
| Type | Value | Description |
|---|---|---|
BindingValueTypes.CLASS | 'class' | Bound via toClass(). Container instantiates with DI. |
BindingValueTypes.VALUE | 'value' | Bound via toValue(). Direct value return. |
BindingValueTypes.PROVIDER | 'provider' | Bound via toProvider(). Factory function or IProvider class. |
BindingKeys Utility
Builds namespaced binding keys from structured objects.
File: packages/inversion/src/common/types.ts
BindingKeys.build({ namespace: 'services', key: 'UserService' });
// → 'services.UserService'
BindingKeys.build({ namespace: '', key: 'AppConfig' });
// → 'AppConfig'
BindingKeys.build({ namespace: 'services', key: '' });
// → Throws error: key is requiredThis is also used internally by container.get() and container.getBinding() when you pass a { namespace, key } object as the key.
@inject Decorator
The @inject decorator marks where dependencies should be injected - either on constructor parameters or class properties.
File: packages/inversion/src/modules/metadata/injectors.ts (base) & packages/core/src/base/metadata/injectors.ts (core wrapper)
Signature
@inject({ key: string | symbol; isOptional?: boolean })| Parameter | Type | Default | Description |
|---|---|---|---|
key | string | symbol | - | The binding key to resolve from the container. |
isOptional | boolean | false | If true, returns undefined instead of throwing when the binding is not found. |
Constructor Parameter Injection
class UserController {
constructor(
@inject({ key: 'services.UserService' })
private userService: UserService,
@inject({ key: 'services.CacheService', isOptional: true })
private cacheService?: CacheService, // Won't throw if not registered
) {}
}Property Injection
class UserController {
@inject({ key: 'services.UserService' })
private userService!: UserService;
@inject({ key: 'services.CacheService', isOptional: true })
private cacheService?: CacheService;
}How It Works
- When
@injectis applied to a constructor parameter, it storesIInjectMetadata(key, index, isOptional) on the class via theMetadataRegistry. - When
@injectis applied to a property, it storesIPropertyMetadata(bindingKey, isOptional) on the class prototype via theMetadataRegistry. - When
container.instantiate(MyClass)is called, it reads both metadata sets, resolves each dependency from the container, and injects them.
Base vs Core Decorators
The @venizia/ignis-inversion package exports base decorators that use the module-level metadataRegistry singleton. The @venizia/ignis (core) package re-exports wrappers that use the core MetadataRegistry.getInstance() singleton instead, which includes model/repository/datasource metadata support.
Always import from @venizia/ignis in application code:
import { inject } from '@venizia/ignis';Registering a Class
No class decorator is needed to make a class injectable. A class becomes resolvable once a binding exists for it - created either by boot auto-discovery, by a framework helper (app.controller(), app.service(), @repository), or explicitly with container.bind(). Scope is configured on the binding, never on the class:
class UserService extends BaseService {
constructor(
@inject({ key: 'repositories.UserRepository' })
private userRepository: UserRepository,
) {
super({ scope: UserService.name });
}
}
app.bind({ key: 'services.UserService' })
.toClass(UserService)
.setScope(BindingScopes.SINGLETON); // default is TRANSIENTMetadataRegistry
The MetadataRegistry stores and retrieves all metadata attached by decorators (@inject, @controller, @model, etc.).
Base MetadataRegistry
File: packages/inversion/src/modules/registry/index.ts
A singleton exported as metadataRegistry. Extends BaseHelper.
| Method | Description |
|---|---|
define({ target, key, value }) | Stores arbitrary metadata on a target using Reflect.defineMetadata. |
get({ target, key }) | Retrieves metadata by key from a target. |
has({ target, key }) | Checks if metadata exists. |
delete({ target, key }) | Removes metadata. Returns true if it existed. |
getKeys({ target }) | Returns all metadata keys on a target. |
getMethodNames({ target }) | Returns all method names on a class prototype (excluding constructor). |
clearMetadata({ target }) | Removes all metadata from a target. |
setInjectMetadata({ target, index, metadata }) | Stores constructor parameter injection metadata (IInjectMetadata). |
getInjectMetadata({ target }) | Returns all constructor injection metadata for a class. |
setPropertyMetadata({ target, propertyName, metadata }) | Stores property injection metadata (IPropertyMetadata). |
getPropertiesMetadata({ target }) | Returns a Map<string | symbol, IPropertyMetadata> for all injected properties. |
getPropertyMetadata({ target, propertyName }) | Returns property metadata for a specific property. |
Core MetadataRegistry
File: packages/core/src/helpers/inversion/registry.ts
Extends the base with controller, repository, model, and datasource metadata support via mixins. Accessed as a singleton via MetadataRegistry.getInstance().
Additional capabilities include:
- Controller metadata (route configs, path mappings)
- REST and gRPC controller metadata
- Repository binding metadata
- Model registry (schema, settings)
- DataSource metadata and schema auto-discovery
Metadata Keys
Defined in packages/inversion/src/modules/metadata/common/constants.ts:
MetadataKeys.PROPERTIES = Symbol.for('ignis:properties')
MetadataKeys.INJECT = Symbol.for('ignis:inject')Key Types
interface IInjectMetadata {
key: string | symbol;
index: number;
isOptional?: boolean;
}
interface IPropertyMetadata {
bindingKey: string | symbol;
isOptional?: boolean;
[key: string]: any;
}Boot System Integration
The boot system (@venizia/ignis-boot) extends the DI container to support automatic artifact discovery and registration.
Key Bindings
When boot system is enabled, the following bindings are created:
| Binding Key | Type | Description |
|---|---|---|
@app/instance | Value | The application container instance |
@app/project_root | Value | Absolute path to project root |
@app/boot-options | Value | Boot configuration options |
bootstrapper | Class (Singleton) | Main boot orchestrator |
booter.DatasourceBooter | Class (Tagged: 'booter') | Datasource discovery booter |
booter.RepositoryBooter | Class (Tagged: 'booter') | Repository discovery booter |
booter.ServiceBooter | Class (Tagged: 'booter') | Service discovery booter |
booter.ControllerBooter | Class (Tagged: 'booter') | Controller discovery booter |
Tag-based Discovery
The boot system uses container tags for automatic discovery:
// Register a booter with tag
this.bind({ key: 'booter.CustomBooter' })
.toClass(CustomBooter)
.setTags('booter');
// Find all booters
const booterBindings = this.findByTag<IBooter>({ tag: 'booter' });This pattern allows the Bootstrapper to automatically discover and execute all registered booters without explicit registration.
Artifact Bindings
Once artifacts are discovered and loaded, they're bound using consistent namespace patterns:
// Controllers - auto-tagged with 'controllers'
this.bind({ key: 'controllers.UserController' }).toClass(UserController);
// Services - auto-tagged with 'services'
this.bind({ key: 'services.UserService' }).toClass(UserService);
// Repositories - auto-tagged with 'repositories'
this.bind({ key: 'repositories.UserRepository' }).toClass(UserRepository);
// Datasources - auto-tagged with 'datasources'
this.bind({ key: 'datasources.PostgresDataSource' }).toClass(PostgresDataSource);Boot Lifecycle & DI
The boot system integrates into the application lifecycle:
- Application Constructor - Binds boot infrastructure if
bootOptionsconfigured - initialize() - Calls
boot()which:- Discovers booters from container (via
findByTag) - Instantiates booters (via
container.get()orbinding.getValue()) - Executes boot phases (configure → discover → load)
- Each booter binds discovered artifacts to container
- Discovers booters from container (via
- Post-Boot - All artifacts available for dependency injection
Example Flow:
// 1. Boot discovers UserController.js file
// 2. Boot loads UserController class
// 3. Boot binds to container:
app.bind({ key: 'controllers.UserController' }).toClass(UserController);
// 4. Later, when UserController is instantiated:
class UserController {
constructor(
@inject({ key: 'services.UserService' })
private _userService: UserService // Auto-injected!
) {}
}Benefits
- Zero-configuration DI: Artifacts auto-discovered and registered
- Convention-based: Follow naming patterns, get DI for free
- Extensible: Custom booters integrate seamlessly via tags
- Type-safe: Full TypeScript support throughout boot process
Learn More: See Bootstrapping Concepts
Request Context Access
Access the current Hono request context from anywhere using useRequestContext(). This uses Hono's context storage middleware and requires asyncContext.enable: true in application config.
import { useRequestContext } from '@venizia/ignis';
class MyService extends BaseService {
async doSomething() {
const ctx = useRequestContext();
if (ctx) {
const userId = ctx.get('currentUser')?.id;
// Use context data without passing it through parameters
}
}
}WARNING
useRequestContext() returns undefined outside of request handling. Always check for undefined before accessing context properties.
Setup: Enable async context in your application:
class MyApp extends BaseApplication {
configs = {
asyncContext: { enable: true },
// ...
};
}See Also
Related Concepts:
- Dependency Injection Guide - DI fundamentals tutorial
- Application - Application extends Container
- Controllers - Use DI for injecting services
- Services - Use DI for injecting repositories
- Providers - Factory pattern for dynamic injection
References:
- Inversion Helper - DI container utilities
- Bootstrapping API - Auto-discovery and DI
- Glossary - DI concepts explained
Tutorials:
- Testing - Unit testing with mocked dependencies
- Building a CRUD API - DI in practice
Best Practices:
- Architectural Patterns - DI patterns and anti-patterns