Deep Dive: Dependency Injection
Technical reference for the DI system in Ignis - managing resource lifecycles and dependency resolution.
Files:
packages/inversion/src/container.ts— BaseContainerandBindingclassespackages/inversion/src/registry.ts— BaseMetadataRegistrypackages/inversion/src/metadata/injectors.ts— Base@injectand@injectabledecoratorspackages/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@injectand@injectable(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 |
| @injectable | Decorator marking a class as injectable | Stores scope and tag metadata |
| 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/container.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>({ bindings }): T[] | Resolves multiple dependencies at once. Each entry in bindings accepts { key, isOptional? }. All lookups are treated as optional (returns undefined for missing). |
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, sorts by parameter index, resolves each dependency from the container, and passes them as constructor arguments. - 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
) {}
}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/container.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/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, injectable } from '@venizia/ignis';@injectable Decorator
Marks a class as injectable and attaches optional metadata.
File: packages/inversion/src/metadata/injectors.ts (base) & packages/core/src/base/metadata/injectors.ts (core wrapper)
Signature
@injectable({ scope?: TBindingScope; tags?: Record<string, any> })| Parameter | Type | Default | Description |
|---|---|---|---|
scope | 'singleton' | 'transient' | — | Optional scope hint for the binding. |
tags | Record<string, any> | — | Optional metadata tags. |
Example
@injectable({ scope: BindingScopes.SINGLETON })
class UserService extends BaseService {
constructor(
@inject({ key: 'repositories.UserRepository' })
private userRepo: UserRepository,
) {
super({ scope: UserService.name });
}
}MetadataRegistry
The MetadataRegistry stores and retrieves all metadata attached by decorators (@inject, @injectable, @controller, @model, etc.).
Base MetadataRegistry
File: packages/inversion/src/registry.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. |
setInjectableMetadata({ target, metadata }) | Stores @injectable metadata on a class. |
getInjectableMetadata({ target }) | Returns @injectable metadata for a class. |
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/common/keys.ts:
MetadataKeys.PROPERTIES = Symbol.for('ignis:properties')
MetadataKeys.INJECT = Symbol.for('ignis:inject')
MetadataKeys.INJECTABLE = Symbol.for('ignis:injectable')Key Types
interface IInjectMetadata {
key: string | symbol;
index: number;
isOptional?: boolean;
}
interface IPropertyMetadata {
bindingKey: string | symbol;
isOptional?: boolean;
[key: string]: any;
}
interface IInjectableMetadata {
scope?: TBindingScope;
tags?: Record<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:
@injectable()
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