Skip to content

Deep Dive: Dependency Injection

Technical reference for the DI system in Ignis - managing resource lifecycles and dependency resolution.

Files:

  • packages/inversion/src/container.ts — Base Container and Binding classes
  • packages/inversion/src/registry.ts — Base MetadataRegistry
  • packages/inversion/src/metadata/injectors.ts — Base @inject and @injectable decorators
  • packages/inversion/src/common/types.tsBindingScopes, BindingValueTypes, BindingKeys, IProvider
  • packages/core/src/helpers/inversion/container.ts — Extended Container with ApplicationLogger
  • packages/core/src/helpers/inversion/registry.ts — Extended MetadataRegistry (singleton, with model/repository/datasource mixins)
  • packages/core/src/base/metadata/injectors.ts — Core @inject and @injectable (wired to extended registry)

Quick Reference

ComponentPurposeKey Methods
ContainerDI registry managing resource lifecyclesbind(), get(), gets(), instantiate(), resolve(), findByTag(), isBound(), unbind(), clear(), reset()
BindingSingle registered dependency configurationtoClass(), toValue(), toProvider(), setScope(), setTags(), getValue(), clearCache()
@injectDecorator marking injection pointsApplied to constructor parameters and class properties
@injectableDecorator marking a class as injectableStores scope and tag metadata
MetadataRegistryStores decorator metadataSingleton — base via metadataRegistry export, core via MetadataRegistry.getInstance()
BindingKeysUtility for building namespaced keysBindingKeys.build({ namespace, key })
Boot SystemAutomatic artifact discovery and bindingIntegrates with Container via tags and bindings

Prerequisites

Before reading this document, you should understand:

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

typescript
const container = new Container({ scope: 'MyApp' }); // scope is optional, defaults to "Container"

Key Methods

MethodSignatureDescription
bindbind<T>({ key: string | symbol }): Binding<T>Creates and registers a new Binding for the given key. Returns the Binding for fluent configuration.
getget<T>({ key, isOptional? }): TRetrieves 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.
getsgets<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).
getBindinggetBinding<T>({ key }): Binding<T> | undefinedReturns the raw Binding object without resolving it. key accepts string, symbol, or { namespace, key }.
setset<T>({ binding: Binding<T> }): voidDirectly sets a pre-built Binding into the container.
isBoundisBound({ key: string | symbol }): booleanChecks if a binding exists for the given key.
unbindunbind({ key: string | symbol }): booleanRemoves a binding. Returns true if it existed.
resolveresolve<T>(cls: TClass<T>): TAlias for instantiate(). Creates a new instance of the class with DI.
instantiateinstantiate<T>(cls: TClass<T>): TCreates a new instance of a class, injecting constructor parameters and property dependencies from the container.
findByTagfindByTag<T>({ tag, exclude? }): Binding<T>[]Finds all bindings tagged with tag. Optionally exclude specific binding keys via exclude (accepts Array<string> or Set<string>).
clearclear(): voidClears cached singleton values on all bindings (does not remove bindings).
resetreset(): voidRemoves all bindings entirely.
getMetadataRegistrygetMetadataRegistry(): MetadataRegistryReturns the metadata registry. The core Container overrides this to return MetadataRegistry.getInstance().

Instantiation Algorithm (Two-Phase)

When container.instantiate(MyClass) is called:

  1. Constructor injection — Reads @inject metadata from the class, sorts by parameter index, resolves each dependency from the container, and passes them as constructor arguments.
  2. Property injection — After the instance is created, reads property metadata, resolves each dependency, and assigns them directly to the instance properties.
typescript
// 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

typescript
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

MethodSignatureDescription
toClasstoClass(value: TClass<T>): thisBinds to a class. The container will instantiate it (resolving constructor and property dependencies) when requested.
toValuetoValue(value: T): thisBinds to a constant value (e.g., a config object, string, number).
toProvidertoProvider(value: ((container) => T) | TClass<IProvider<T>>): thisBinds to a factory function or a class implementing IProvider<T>.
setScopesetScope(scope: TBindingScope): thisSets the lifecycle scope ('singleton' or 'transient').
setTagssetTags(...tags: string[]): thisAdds one or more tags to the binding. Tags are additive — calling this multiple times adds more tags.
getValuegetValue(container?: Container): TResolves the binding's value. For CLASS and PROVIDER types, a container argument is required. Respects singleton caching.
clearCacheclearCache(): voidClears the cached singleton instance (if any). Next getValue() call will re-create it.
hasTaghasTag(tag: string): booleanChecks if the binding has a specific tag.
getTagsgetTags(): string[]Returns all tags as an array.
getScopegetScope(): TBindingScopeReturns the current scope setting.
getBindingMetagetBindingMeta({ 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

typescript
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:

typescript
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>):

typescript
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

ScopeValueDescription
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

TypeValueDescription
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

typescript
BindingKeys.build({ namespace: 'services', key: 'UserService' });
// → 'services.UserService'

BindingKeys.build({ namespace: '', key: 'AppConfig' });
// → 'AppConfig'

BindingKeys.build({ namespace: 'services', key: '' });
// → Throws error: key is required

This 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

typescript
@inject({ key: string | symbol; isOptional?: boolean })
ParameterTypeDefaultDescription
keystring | symbolThe binding key to resolve from the container.
isOptionalbooleanfalseIf true, returns undefined instead of throwing when the binding is not found.

Constructor Parameter Injection

typescript
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

typescript
class UserController {
  @inject({ key: 'services.UserService' })
  private userService!: UserService;

  @inject({ key: 'services.CacheService', isOptional: true })
  private cacheService?: CacheService;
}

How It Works

  1. When @inject is applied to a constructor parameter, it stores IInjectMetadata (key, index, isOptional) on the class via the MetadataRegistry.
  2. When @inject is applied to a property, it stores IPropertyMetadata (bindingKey, isOptional) on the class prototype via the MetadataRegistry.
  3. 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:

typescript
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

typescript
@injectable({ scope?: TBindingScope; tags?: Record<string, any> })
ParameterTypeDefaultDescription
scope'singleton' | 'transient'Optional scope hint for the binding.
tagsRecord<string, any>Optional metadata tags.

Example

typescript
@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.

MethodDescription
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:

typescript
MetadataKeys.PROPERTIES  = Symbol.for('ignis:properties')
MetadataKeys.INJECT      = Symbol.for('ignis:inject')
MetadataKeys.INJECTABLE  = Symbol.for('ignis:injectable')

Key Types

typescript
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 KeyTypeDescription
@app/instanceValueThe application container instance
@app/project_rootValueAbsolute path to project root
@app/boot-optionsValueBoot configuration options
bootstrapperClass (Singleton)Main boot orchestrator
booter.DatasourceBooterClass (Tagged: 'booter')Datasource discovery booter
booter.RepositoryBooterClass (Tagged: 'booter')Repository discovery booter
booter.ServiceBooterClass (Tagged: 'booter')Service discovery booter
booter.ControllerBooterClass (Tagged: 'booter')Controller discovery booter

Tag-based Discovery

The boot system uses container tags for automatic discovery:

typescript
// 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:

typescript
// 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:

  1. Application Constructor - Binds boot infrastructure if bootOptions configured
  2. initialize() - Calls boot() which:
    • Discovers booters from container (via findByTag)
    • Instantiates booters (via container.get() or binding.getValue())
    • Executes boot phases (configure → discover → load)
    • Each booter binds discovered artifacts to container
  3. Post-Boot - All artifacts available for dependency injection

Example Flow:

typescript
// 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.

typescript
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:

typescript
class MyApp extends BaseApplication {
  configs = {
    asyncContext: { enable: true },
    // ...
  };
}

See Also