Skip to content

Dependency Injection

Dependency Injection (DI) enables loosely coupled, testable code by automatically providing dependencies to classes.

Deep Dive: See DI Reference for technical details on Container, Binding, and @inject.

Standalone Package: The core DI container is available as the standalone @venizia/ignis-inversion package for use outside the Ignis framework. See Inversion Package Reference for details.

Core Concepts

ConceptDescription
ContainerThe central registry for all your application's services and dependencies. The Application class itself extends Container.
BindingThe process of registering a class or value with the container under a specific key (e.g., 'services.UserService').
InjectionThe process of requesting a dependency from the container using the @inject decorator.

How It Works: The DI Flow

Instantiation Algorithm (Two-Phase)

When the container instantiates a class, it follows a two-phase process:

  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 construction, reads property metadata and assigns each dependency to the decorated properties.

Binding Dependencies

Before a dependency can be injected, it must be bound to the container. This is typically done in the preConfigure method of your Application class.

Standard Resource Binding

The Application class provides helper methods for common resource types. These automatically create a binding with a conventional key.

MethodDefault KeyDefault Scope
app.service(UserService)services.UserServiceTransient
app.repository(UserRepository)repositories.UserRepositoryTransient
app.dataSource(PostgresDataSource)datasources.PostgresDataSourceSingleton
app.controller(UserController)controllers.UserControllerTransient
app.component(MyComponent)components.MyComponentSingleton

All these methods accept an optional second parameter to customize the binding key:

typescript
// Default binding (key: 'controllers.UserController')
app.controller(UserController);

// Custom binding key
app.controller(UserController, {
  binding: { namespace: 'controllers', key: 'CustomUserController' }
});

Custom Bindings

For other values or more complex setups, use the bind method directly.

typescript
// In your application class's preConfigure()
this.bind<MyCustomClass>({ key: 'MyCustomClass' }).toClass(MyCustomClass);

this.bind<string>({ key: 'API_KEY' }).toValue('my-secret-api-key');

Binding Scopes

You can control the lifecycle of your dependencies with scopes.

  • TRANSIENT (default): A new instance is created every time the dependency is resolved.
  • SINGLETON: A single instance is created once and cached. All subsequent resolutions return the same instance.
typescript
import { BindingScopes } from '@venizia/ignis-inversion';

this.bind({ key: 'services.MySingletonService' })
  .toClass(MySingletonService)
  .setScope(BindingScopes.SINGLETON); // Use SINGLETON for this service

Binding Tags

Bindings are automatically tagged with their namespace prefix. For example, a binding with key 'services.UserService' is auto-tagged with 'services'. You can also add custom tags:

typescript
this.bind({ key: 'services.UserService' })
  .toClass(UserService)
  .setTags('critical', 'user-domain');

Tags are used by the container's findByTag() method to discover bindings by category.

Injecting Dependencies

Ignis provides the @inject decorator to request dependencies from the container.

This makes dependencies explicit and ensures they are available right away.

typescript
import { BaseRestController, controller, inject } from '@venizia/ignis';
import { UserService } from '../services/user.service';

@controller({ path: '/users' })
export class UserController extends BaseRestController {
  constructor(
    @inject({ key: 'services.UserService' })
    private userService: UserService
  ) {
    super({ scope: UserController.name });
  }

  // ... you can now use this.userService
}

Property Injection

You can also inject dependencies directly as class properties.

typescript
import { inject } from '@venizia/ignis';
import { UserService } from '../services/user.service';

export class UserComponent {
  @inject({ key: 'services.UserService' })
  private userService: UserService;

  // ...
}

Optional Dependencies

Mark a dependency as optional to avoid errors when it's not bound:

typescript
constructor(
  @inject({ key: 'services.CacheService', isOptional: true })
  private cache?: CacheService
) {
  super({ scope: MyService.name });
  // cache will be undefined if not bound
}

Providers

Providers are used for dependencies that require complex setup logic. A provider can be either a factory function or a class that implements the IProvider<T> interface with a value() method.

Factory Function Provider

typescript
// In your application class
this.bind<ThirdPartyApiClient>({ key: 'services.ApiClient' })
  .toProvider((container) => {
    const config = container.get<IConfig>({ key: 'configs.api' });
    return new ThirdPartyApiClient({
      apiKey: config.apiKey,
      baseUrl: config.baseUrl,
    });
  });

Class-Based Provider

typescript
import { IProvider, Container } from '@venizia/ignis-inversion';

export class ApiClientProvider implements IProvider<ThirdPartyApiClient> {
  value(container: Container): ThirdPartyApiClient {
    const config = container.get<IConfig>({ key: 'configs.api' });
    const client = new ThirdPartyApiClient({
      apiKey: config.apiKey,
      baseUrl: config.baseUrl,
    });
    client.connect();
    return client;
  }
}

You would then bind this provider in your application:

typescript
// In your application class
this.bind<ThirdPartyApiClient>({ key: 'services.ApiClient' })
  .toProvider(ApiClientProvider);

Standalone Containers

You can create independent DI containers using the Container class directly. These containers are completely separate from the application's context and do not share any bindings.

Creating an Independent Container

typescript
import { Container, BindingScopes } from '@venizia/ignis-inversion';

// Create a standalone container
const container = new Container({ scope: 'MyCustomContainer' });

// Bind dependencies
container.bind({ key: 'config.apiKey' }).toValue('my-secret-key');
container.bind({ key: 'services.Logger' }).toClass(LoggerService);
container.bind({ key: 'services.Cache' })
  .toClass(CacheService)
  .setScope(BindingScopes.SINGLETON);

// Resolve dependencies
const logger = container.get<LoggerService>({ key: 'services.Logger' });
const apiKey = container.get<string>({ key: 'config.apiKey' });

// Resolve multiple at once
const [svcA, svcB] = container.gets<[ServiceA, ServiceB]>({
  bindings: [
    { key: 'services.A' },
    { key: 'services.B', isOptional: true },
  ],
});

Use Cases

Use CaseDescription
Unit TestingCreate isolated containers with mock dependencies for each test
Isolated ModulesBuild self-contained modules with their own dependency graph
Multi-TenancySeparate containers per tenant with tenant-specific configurations
Worker ThreadsIndependent containers for background workers
Plugin SystemsEach plugin gets its own container to prevent conflicts

Example: Testing with Isolated Container

typescript
import { Container } from '@venizia/ignis-inversion';
import { describe, it, expect, beforeEach } from 'bun:test';

describe('UserService', () => {
  let container: Container;

  beforeEach(() => {
    // Fresh container for each test
    container = new Container({ scope: 'TestContainer' });

    // Bind mock dependencies
    container.bind({ key: 'repositories.UserRepository' }).toValue({
      findById: async () => ({ id: '1', name: 'Test User' }),
    });

    container.bind({ key: 'services.UserService' }).toClass(UserService);
  });

  it('should find user by id', async () => {
    const userService = container.get<UserService>({ key: 'services.UserService' });
    const user = await userService.findById({ id: '1' });

    expect(user.name).toBe('Test User');
  });
});

Container vs Application

AspectApplication (extends Container)Standalone Container
PurposeFull HTTP server with routing, middlewarePure dependency injection
BindingsShared across entire applicationIsolated, no sharing
LifecycleManaged by frameworkYou control it
Use CaseMain applicationTesting, isolated modules, workers

TIP

The Application class extends Container, so all container methods (bind, get, gets, resolve, findByTag, isBound, unbind) are available on your application instance. Standalone containers are useful when you need isolation from the main application context.

Container API Summary

MethodDescription
bind<T>({ key })Create a new binding
get<T>({ key, isOptional? })Resolve a single dependency
gets<T>({ bindings })Resolve multiple dependencies at once
resolve<T>(cls)Instantiate a class with DI (alias for instantiate)
isBound({ key })Check if a key is bound
unbind({ key })Remove a binding
findByTag({ tag, exclude? })Find bindings by tag
clear()Clear all cached singleton instances
reset()Remove all bindings entirely

See Also