Dependency Injection
True Life includes an Angular-style dependency injection system that allows services to depend on other services in a clean, testable way.
Basic Usage
Use the inject() function in class field initializers:
import { inject } from "@core/di";
import { CharacterService } from "@modules/characters/character.service";
export class BankingService {
// Inject during construction
private characterService = inject(CharacterService);
@Server
async getBalance(ctx: EventContext<[]>): Promise<number> {
const character = this.characterService.getActiveCharacter(ctx.source);
return character?.data.balance ?? 0;
}
}
Architecture Overview
How It Works
The DI system consists of three main components:
1. DI Container
A global container (src/lib/di/index.ts) that stores:
| Map | Key | Value | Purpose |
|---|---|---|---|
instances | ServiceClass or Symbol | Service instance | Lookup during injection |
serviceModules | ServiceClass or Symbol | Module name | Dependency validation |
2. Injection Context
A context object set during service construction:
interface InjectionContext {
moduleName: string; // "banking"
serviceName: string; // "BankingService"
validSources: Set<string>; // ["banking", "characters", ...]
}
The validSources set contains:
- The current module's name (can inject own services)
- All declared dependencies (can inject from these modules)
3. Module Integration
When a module starts, the module system orchestrates DI:
// Inside registerModule().start()
for (const ServiceClass of config.services) {
// 1. Instantiate within injection context
const instance = runInInjectionContext(
config.name, // "banking"
ServiceClass.name, // "BankingService"
validSources, // Set(["banking", "characters"])
() => new ServiceClass() // Construction happens here
);
// 2. Register in DI container for other modules
registerService(ServiceClass, instance, config.name);
}
Injection Flow
Declaring Dependencies
For injection to work, you must declare the source module as a dependency:
// src/modules/banking/module.ts
import { registerModule } from "@core/module";
import { BankingService } from "@modules/banking/banking.service";
export default registerModule({
name: "banking",
dependencies: ["characters"], // Required to inject CharacterService
services: [BankingService],
});
Optional Dependencies
Use injectOptional() for services that may not be available:
import { injectOptional } from "@core/di";
import { DebugService } from "@modules/debug/debug.service";
export class MyService {
// Returns undefined if DebugService is not loaded
private debugService = injectOptional(DebugService);
log(message: string) {
this.debugService?.log(message);
}
}
Injection Rules
✅ Valid Injection Points
export class MyService {
// Field initializers - RECOMMENDED
private dep1 = inject(Dep1Service);
private dep2 = inject(Dep2Service);
// Constructor body - also valid
constructor() {
this.dep3 = inject(Dep3Service);
}
}
❌ Invalid Injection Points
export class MyService {
private dep = inject(DepService); // ✅ OK
someMethod() {
// ❌ WRONG: inject() called outside construction
const dep = inject(OtherService);
}
}
// ❌ WRONG: inject() called outside service class
const globalDep = inject(SomeService);
Circular Dependencies
Circular dependencies are not supported and will throw an error:
// ModuleA depends on ModuleB
// ModuleB depends on ModuleA
// ❌ This will fail at startup with topological sort error
If you need circular communication, use:
- Event-based decoupling
- A shared mediator service
- Lazy injection patterns
Error Messages
The DI system provides detailed error messages to help debug issues:
Called Outside Injection Context
inject() called outside of injection context.
inject(CharacterService) must be called during service construction
(in field initializers or constructor).
Make sure the service is registered in a module's services array.
Cause: inject() was called in a method body, global scope, or async callback.
Service Not Found
Service "CharacterService" not found in DI container.
Make sure the service is registered in a module that is a dependency of "banking".
Current service: BankingService
Cause: The service hasn't been registered yet (module not started) or doesn't exist.
Invalid Dependency
Cannot inject "CharacterService" from module "characters" into "BankingService".
Module "banking" does not declare "characters" as a dependency.
Add "characters" to the dependencies array in your module registration.
Cause: You're trying to inject from a module not listed in your dependencies.
How inject() Works Internally
export function inject<T>(token: InjectionToken<T>): T {
// 1. Check we're in an injection context
if (!currentContext) {
throw new Error(`inject() called outside of injection context...`);
}
// 2. Look up the service instance
const instance = instances.get(token);
if (instance === undefined) {
throw new Error(`Service "${tokenName}" not found...`);
}
// 3. Validate the source module is a valid dependency
const sourceModule = serviceModules.get(token);
if (sourceModule && !currentContext.validSources.has(sourceModule)) {
throw new Error(`Cannot inject "${tokenName}" from module "${sourceModule}"...`);
}
return instance as T;
}
The key insight is that validation happens at injection time, not at startup. This means:
- You'll get an error immediately when the invalid injection runs
- The error message tells you exactly which service and module are involved
- You can fix it by adding the dependency to your module config
Multiple Injections
You can inject multiple services:
export class ComplexService {
private characters = inject(CharacterService);
private banking = inject(BankingService);
private inventory = inject(InventoryService);
private notifications = inject(NotificationService);
@Server
async complexOperation(ctx: EventContext<[]>): Promise<void> {
const character = this.characters.getActiveCharacter(ctx.source);
const balance = await this.banking.getBalance(ctx);
const items = await this.inventory.getItems(ctx);
await this.notifications.notify({
source: ctx.source,
args: [`Balance: ${balance}, Items: ${items.length}`],
});
}
}
Testing with DI
The DI system makes testing easier by allowing mock injection:
// In tests
import { setTestContainer } from "@core/di";
const mockCharacterService = {
getActiveCharacter: vi.fn().mockReturnValue({ id: "test" }),
};
setTestContainer({
[CharacterService.name]: mockCharacterService,
});
// Now MyService will receive the mock
Interface Injection Tokens
For injecting interfaces instead of concrete classes, use createInjectionToken():
import { createInjectionToken, inject, registerService } from "@core/di";
// Define the interface
interface ILogger {
log(message: string): void;
}
// Create a token for the interface
export const ILogger = createInjectionToken<ILogger>("ILogger");
// Register an implementation
registerService(ILogger, new ConsoleLogger(), "logging");
// Inject by interface
class MyService {
private logger = inject(ILogger);
}
This is useful when:
- You want to swap implementations (e.g., mock vs real)
- Multiple classes implement the same interface
- You want to decouple from concrete implementations
Lifecycle and Module Order
Understanding the lifecycle is crucial for DI:
Key points:
- Modules start in dependency order (topological sort)
- A module can only inject from modules that started before it
- The
dependenciesarray controls both load order and injection permissions
Internal API
The DI system exposes internal functions for the module system:
| Function | Purpose |
|---|---|
registerService(token, instance, moduleName) | Add service to container |
unregisterService(token) | Remove service from container |
setInjectionContext(...) | Set context before construction |
clearInjectionContext() | Clear context after construction |
runInInjectionContext(...) | Wrap construction with context |
getService(token) | Direct lookup (bypasses validation) |
hasService(token) | Check if service is registered |
clearContainer() | Remove all services (shutdown) |
These internal functions should only be used by the module system, not in application code.
Best Practices
- Declare all dependencies - Be explicit in module config
- Use field initializers - Cleaner than constructor injection
- Prefer required injection - Use
injectOptionalsparingly - Avoid circular deps - Restructure if needed
- Keep injection shallow - Don't create deep dependency chains
- Test with mocks - Leverage DI for unit testing
- Use interface tokens - For swappable implementations