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:
- Decoupling — classes state what they depend on; they never call
newon other services directly. - Testability — swapping a real database for a test double is a single container registration call, not a refactor.
- 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:
- App starts →
globalContaineris populated with infrastructure services. - Each module calls
root.createChild()and registers its own providers. - Each incoming HTTP request calls
moduleContainer.createChild(). AllSCOPEDproviders (e.g.,RequestContext) get fresh instances that are discarded when the request ends.