Docs/Dependency injectionv1.0.0-rc.1

Dependency Injection — Overview

@tagna/udiot v1.0.0-rc.1


What Is Dependency Injection?

Dependency injection (DI) is a design pattern where a class declares what it needs (its dependencies) rather than constructing those things itself. A central container reads those declarations and supplies the right objects at runtime.

UDIOT's DI system is built around three goals:

  1. Decoupling — classes state what they depend on; they never call new on other services directly.
  2. Testability — swapping a real database for a test double is a single container registration call, not a refactor.
  3. Lifecycle control — the container owns when instances are created and how long they live, including thread-safe singleton sharing.

Token-Based Resolution

Every dependency is identified by a token. A token can be any of:

Token kind Example Typical use
Class constructor UserService Most services
String literal 'DATABASE_URL' Config values, string constants
Symbol Symbol('Logger') Avoiding name collisions
import { globalContainer } from '@tagna/udiot';

// Register using a class as its own token
globalContainer.registerClass(UserService, UserService);

// Register using a string token
globalContainer.registerValue('DATABASE_URL', 'postgres://localhost/mydb');

// Register using a Symbol token
const LOGGER = Symbol('Logger');
globalContainer.registerValue(LOGGER, console);

// Resolve by token
const url = globalContainer.resolve<string>('DATABASE_URL');

The InjectionToken type alias covers all three forms:

type InjectionToken = string | symbol | Function;

Provider Kinds

A provider is a descriptor that tells the container how to produce a value for a given token.

ClassProvider

Instantiates a class. The container resolves the constructor's parameters from its own registry before calling new.

{
  provide: UserService,
  useClass: UserService,
  scope?: ServiceScope
}

ValueProvider

Returns a pre-existing value verbatim. Useful for configuration objects, environment constants, or any value you have already created.

{
  provide: 'DB_CONFIG',
  useValue: { host: 'localhost', port: 5432 }
}

FactoryProvider

Calls a factory function. The factory receives already-resolved dependencies listed in inject.

{
  provide: ConnectionPool,
  useFactory: (config: DbConfig) => new ConnectionPool(config),
  inject: ['DB_CONFIG'],
  scope?: ServiceScope
}

ExistingProvider

Creates an alias so one token resolves to whatever another token resolves to.

{
  provide: 'IUserService',
  useExisting: UserService
}

Service Scopes

Scope controls how many instances the container creates and how long each lives.

Scope Enum value Behavior
Singleton ServiceScope.SINGLETON One instance shared for the lifetime of the container. Default for registerClass.
Transient ServiceScope.TRANSIENT New instance on every resolve() call.
Scoped ServiceScope.SCOPED One instance per child container created via createChild().
import { ServiceScope } from '@tagna/udiot';

// Singleton — same UserService instance everywhere
container.registerClass(UserService, UserService, ServiceScope.SINGLETON);

// Transient — each resolve gets a fresh LogEntry
container.registerClass(LogEntry, LogEntry, ServiceScope.TRANSIENT);

// Scoped — one RequestContext per child container (e.g., per HTTP request)
container.registerClass(RequestContext, RequestContext, ServiceScope.SCOPED);

Hierarchical Containers

Containers form a parent–child hierarchy. When a child container cannot find a provider for a token it walks up to its parent and tries there. A child may also shadow a parent's registration by providing its own provider for the same token.

┌─────────────────────────────────┐
│         Root Container          │
│  (globalContainer)              │
│  DatabaseService  (SINGLETON)   │
│  Logger           (SINGLETON)   │
│  ConfigService    (SINGLETON)   │
└────────────┬────────────────────┘
             │  createChild()
             ▼
┌─────────────────────────────────┐
│       Module Container          │
│  UserService      (SINGLETON)   │  ← adds UserService
│  UserRepository   (SINGLETON)   │
└────────────┬────────────────────┘
             │  createChild()
             ▼
┌─────────────────────────────────┐
│      Request Container          │
│  RequestContext   (SCOPED)      │  ← one instance per request
│  (DatabaseService inherited)    │  ← resolved from Module/Root
└─────────────────────────────────┘

Resolution walks up the chain. Registration shadows it locally.

const root   = new Container();
const module = root.createChild();
const request = module.createChild();

root.registerValue('DB_URL', 'prod-db');

// Child inherits — resolves 'prod-db'
module.resolve<string>('DB_URL');

// Child overrides for its scope only
module.registerValue('DB_URL', 'test-db');
module.resolve<string>('DB_URL'); // 'test-db'

// Root is unaffected
root.resolve<string>('DB_URL');   // 'prod-db'

How Decorators Interact with DI

UDIOT decorators store metadata on classes using the reflect-metadata API. The container reads this metadata at resolution time instead of requiring explicit .registerClass() calls.

@Injectable

Marks a class as eligible for injection and records its constructor parameter types (via TypeScript's emitDecoratorMetadata). An @Injectable class with providedIn: 'root' is automatically available from globalContainer.

@Injectable({ scope: ServiceScope.SINGLETON })
class UserService {
  constructor(private db: DatabaseService) {}
}

@Inject

Overrides the automatically detected token for a single constructor parameter. Required when injecting string or Symbol tokens because TypeScript cannot emit those as design:paramtypes.

@Injectable()
class ApiClient {
  constructor(
    @Inject('API_URL') private url: string,
    private logger: LoggerService   // type-inferred, no @Inject needed
  ) {}
}

The container auto-registers any class decorated with @Injectable on first resolve(), so you rarely need to call registerClass() manually for your own services.


Container Hierarchy — ASCII Diagram

globalContainer  (root)
│
├── @Injectable({ providedIn: 'root' }) services auto-registered here
│
└── Module Container  (createChild() of root)
    │
    │   Provides: services listed in @Module({ providers: [...] })
    │   Inherits: everything from root
    │
    └── Request/Scope Container  (createChild() of module)
            │
            │   SCOPED providers get a fresh instance here
            │   Inherits: everything from module + root
            │
            └── (disposed at end of request)

Typical flow for a web request:

  1. App starts → globalContainer is populated with infrastructure services.
  2. Each module calls root.createChild() and registers its own providers.
  3. Each incoming HTTP request calls moduleContainer.createChild(). All SCOPED providers (e.g., RequestContext) get fresh instances that are discarded when the request ends.
From the canonical UDIOT public documentation.Try it in the playground ↗