Skip to content

Inversion (DI) ​

@venizia/ignis-inversion is the standalone IoC container - decorator-based injection, a fluent binding API, and singleton/transient scoping - that every other IGNIS package builds on.

In one example ​

The smallest real use: bind a class with constructor injection, then resolve it through the container.

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

class UserService {
  constructor(@inject({ key: 'config.appName' }) private appName: string) {}
}

const container = new Container({ scope: 'MyApp' });

container.bind<string>({ key: 'config.appName' }).toValue('MyApp');
container.bind<UserService>({ key: 'services.UserService' })
  .toClass(UserService)
  .setScope(BindingScopes.SINGLETON);

const userService = container.get<UserService>({ key: 'services.UserService' });

A class needs no decorator to be bindable. The binding is always created explicitly with container.bind(), and its scope is set via setScope().

The framework layer (@venizia/ignis) creates these bindings for you - for controllers, services, and repositories - via app.controller() / app.service() / @repository.

How it works ​

  • A binding resolves one of three ways. All Binding setters return this, so calls chain.

    ResolverBehavior
    toClassContainer instantiates the class with full DI
    toValueReturns the value as-is
    toProviderCalls a factory function, or an IProvider class
  • Instantiation is two-phase. container.resolve(cls) and container.instantiate(cls) are the same method - resolve is an alias.

    PhaseWhat happens
    1. Constructor injectionReads @inject metadata by parameter index, passes resolved values as constructor args
    2. Property injectionAssigns each @inject-decorated property on the built instance
  • Every constructor parameter must carry @inject. The metadata array is index-keyed - an undecorated parameter leaves a hole the container has no way to fill. instantiate() refuses the class by name and parameter index rather than passing undefined.

  • Namespaces auto-tag bindings. A key like services.UserService tags the binding services automatically. setTags() adds more. findByTag() queries by tag, with an exclude list.

  • Keys can be a string or a symbol. Two symbols are two keys even when they share a description; Symbol.for(name) is one key wherever it is created. The lookup methods - get, gets, getBinding - also accept { namespace, key }, which BindingKeys.build joins into a dotted string. bind, isBound, and unbind take the plain key only.

  • @inject names a dependency by key or by class. @inject({ key: 'repositories.UserRepository' }) and @inject({ target: UserRepository }) both work. They are the two arms of a union, so you pass one or the other, never both.

Scopes

ScopeConstantBehavior
TransientBindingScopes.TRANSIENTNew instance on every resolution (default)
SingletonBindingScopes.SINGLETONCached on the Binding after first resolution - any value, null and undefined included. A rejected promise stays cached until clear()

IMPORTANT

Singleton caching lives on the Binding object, not the container. Rebinding a key creates a fresh Binding with its own cache. If you hold onto an old Binding reference, its cache stays independent of container.clear()/reset() calls made against the new Binding registered under the same key.

Property-injected classes only get their @inject properties populated when built through the container - container.resolve() or instantiate(). A plain new MyClass() leaves them undefined.

The Full reference covers MetadataRegistry, gets(), key formats, IProvider, and every error message in detail.

Common tasks ​

Bind a class with constructor injection ​

typescript
class OrderService {
  constructor(
    @inject({ key: 'repositories.OrderRepository' }) private orderRepository: OrderRepository,
    @inject({ key: 'services.Logger', isOptional: true }) private logger?: Logger,
  ) {}
}

Bind a class by naming the class ​

Pass target instead of key. The container reads the key off the class at resolve time, from the key its registration recorded. A key rename then never reaches this file.

typescript
class OrderService {
  constructor(
    @inject({ target: OrderRepository }) private orderRepository: OrderRepository,
  ) {}
}

The class has to be a registered artifact. If it is not, the container throws and names it. A circular import that leaves the class reference undefined is caught earlier still, at decoration time.

Bind a value or a provider ​

typescript
container.bind<string>({ key: 'APP_NAME' }).toValue('MyApp');

container.bind<DatabaseConnection>({ key: 'db.connection' })
  .toProvider((container) => {
    const config = container.get<Config>({ key: 'config.database' });
    return new DatabaseConnection(config);
  });

Set the scope ​

typescript
container.bind({ key: 'services.CacheService' })
  .toClass(CacheService)
  .setScope(BindingScopes.SINGLETON); // default is TRANSIENT if omitted

Inject into a property instead of the constructor ​

typescript
class UserService {
  @inject({ key: 'repositories.UserRepository' })
  private userRepository: UserRepository;
}

Resolve an optional dependency ​

isOptional: true returns undefined instead of throwing when the key is unbound - on the constructor, on a property, and via container.get(). With the { target } form, an optional class that was never registered also resolves to undefined.

A dependency cycle throws with its path - Circular dependency | services.A -> services.B -> services.A - instead of overflowing the stack. A cycle that passes through an await is not detected. gets() resolves several keys at once, always treating each as optional.

typescript
const maybeService = container.get<MyService>({ key: 'services.Optional', isOptional: true });

Instantiate a class without registering it ​

typescript
const instance = container.resolve<MyClass>(MyClass); // full DI, not bound to a key

See also ​

Files: