Skip to main content

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:

MapKeyValuePurpose
instancesServiceClass or SymbolService instanceLookup during injection
serviceModulesServiceClass or SymbolModule nameDependency 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:

  1. Modules start in dependency order (topological sort)
  2. A module can only inject from modules that started before it
  3. The dependencies array controls both load order and injection permissions

Internal API

The DI system exposes internal functions for the module system:

FunctionPurpose
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)
warning

These internal functions should only be used by the module system, not in application code.

Best Practices

  1. Declare all dependencies - Be explicit in module config
  2. Use field initializers - Cleaner than constructor injection
  3. Prefer required injection - Use injectOptional sparingly
  4. Avoid circular deps - Restructure if needed
  5. Keep injection shallow - Don't create deep dependency chains
  6. Test with mocks - Leverage DI for unit testing
  7. Use interface tokens - For swappable implementations