Dependency Management in TypeScript: From Manual DI to an IoC Container
I've recently been building an agent project in TypeScript, centered on an orchestratable agent runtime. The project isn't large, but its modules have a dense web of dependencies.
At first there were only five or six modules. Any framework felt unnecessary: create the objects with new at the entry point and pass dependencies through constructors. Each class's needs were obvious from its constructor, and tests were as simple as new Service(fakeRepo).
Problems began when the module count grew into double digits. As features became more granular, the list looked like this:
AgentRuntime
-> TaskEngine
-> ContextBuilder
-> ModelProvider
TaskEngine
-> AgentRegistry
-> ToolRunner
-> EventBus
-> SessionStore
ToolRunner
-> SkillRegistry
-> PermissionChecker
-> EventBus
ContextBuilder
-> SkillRegistry
-> ToolRunner
-> HistoryStoreBy then, the entry point had grown to one or two hundred lines of assembly code. Every line was const x = new X(y, z), expressing no business behavior, just wiring dependencies. Worse, every constructor change required a series of changes in the composition root. Creating an independent request scope or test scope meant manually copying the entire setup.
I needed something easier. My first thought was NestJS-style decorator injection: add @Injectable() and let the framework scan and wire everything. But that required experimentalDecorators, reflect-metadata, and a whole module system. It was too heavy for this project.
So I systematically explored common ways to organize dependencies in TypeScript. I wanted to understand what each approach solves, what it costs, and when to move up—not find a universal best practice.
First, Clarify the Concepts
IoC, DI, and IoC Container are often used interchangeably. Let's distinguish them.
IoC (Inversion of Control) is a principle: business code receives dependencies from outside instead of creating them itself.
// 没有 IoC:自己管理依赖
class UserService {
private repo = new PostgresUserRepo()
}
// 有 IoC:依赖从外部来
class UserService {
constructor(private readonly repo: UserRepo) {}
}DI (Dependency Injection) is one way to implement IoC. Constructor injection is the most common and most recommended form.
An IoC Container automates DI: it is a runtime registry that records providers, creates instances, caches singletons, and manages lifecycles.
container.register(UserRepoToken, () => new PostgresUserRepo(db))
container.register(UserServiceToken, () => new UserService(container.resolve(UserRepoToken)))These concepts form a hierarchy:
Concept | Level | Description |
|---|---|---|
IoC | Design principle | Move control from inside to outside |
DI | Implementation approach | Pass dependencies through constructors or parameters |
IoC Container | Tool / Runtime | Automate registration, resolution, and lifecycle management |
An Overview of Dependency Organization
These approaches aren't all at the same level. First, group them by the problems they solve:
依赖组织
├── 对象创建与注入(DI 实现方式)
│ ├── 全局单例
│ ├── 手写 DI / Composition Root
│ ├── 显式 IoC Container
│ └── Decorator-based DI
│
├── 容器的使用方式
│ ├── DI 风格:装配层 resolve → 注入业务对象
│ └── Service Locator 风格:业务对象自己调 inject()
│
└── 模块组织方式
├── Plugin / Module Registry
└── Functional Core + Imperative ShellI'll follow that structure below.
Object Creation and Injection: DI Approaches
These four approaches answer the same questions: who creates objects, and how are dependencies passed?
Global Singletons
Create an instance when the module loads, export it, and let consumers import it directly.
const db = createDb()
const userRepo = new PostgresUserRepo(db)
export const userService = new UserService(userRepo)
// 使用
import { userService } from "./user-service"
await userService.getUser("u_123")Advantages | Disadvantages |
|---|---|
No learning curve or extra concepts | Dependencies are hidden in imports rather than explicit |
Fits naturally global objects such as loggers and configuration | Poor test isolation; replacing dependencies requires module mocks |
No runtime overhead | Uncontrolled initialization timing: top-level code runs immediately |
Circular dependencies arise easily |
Suitable for: small scripts, prototypes, and stable infrastructure such as logging, configuration, and metrics.
Not suitable for: business services whose dependencies need replacing in tests, request-scoped services, or medium-to-large backends maintained over time.
My rule: use a few for infrastructure if needed, but don't make them the default for core business logic.
Manual DI / Composition Root
Business classes declare what they need, and the entry layer—the composition root—creates and wires everything centrally.
// 业务层——只声明依赖
class OrderService {
constructor(
private readonly orderRepo: OrderRepo,
private readonly paymentClient: PaymentClient,
private readonly eventBus: EventBus,
) {}
}
// 入口层——装配依赖
export function createAppDeps() {
const db = createDb()
const orderRepo = new PostgresOrderRepo(db)
const paymentClient = new StripeClient(config)
const eventBus = new InMemoryEventBus()
const orderService = new OrderService(orderRepo, paymentClient, eventBus)
return { db, orderRepo, paymentClient, eventBus, orderService }
}Advantages | Disadvantages |
|---|---|
Fully explicit dependencies, visible in constructors | Assembly code grows quickly with the dependency graph |
Natural testing: | Every constructor change requires updating the composition root |
No runtime magic; the compiled result is ordinary JavaScript | Manual assembly provides no automatic lifecycle management |
Suitable for most small-to-medium backend projects. If I could choose only one default, I'd choose manual DI.
An Explicit IoC Container
When the dependency graph makes the composition root unmanageable, introduce a lightweight container for registration and resolution.
const UserRepoToken = token<UserRepo>("app.userRepo")
const UserServiceToken = token<UserService>("app.userService")
container.register(UserRepoToken, () => new PostgresUserRepo(db))
container.register(UserServiceToken, () =>
new UserService(container.resolve(UserRepoToken))
)
// 入口层
const userService = container.resolve(UserServiceToken)Containers usually support three lifecycles:
Lifecycle | Behavior | Typical Uses |
|---|---|---|
singleton | One instance in the entire container | Database connection pools, configuration |
transient | A new instance for every resolution | Lightweight utility objects |
scoped | One instance per request, session, or tenant | Request context, multitenancy |
TypeScript projects don't necessarily need a heavyweight library such as Inversify. A lightweight 30-line container can solve most problems:
class Container {
private providers = new Map<Token<unknown>, Provider<unknown>>()
private instances = new Map<Token<unknown>, unknown>()
register<T>(token: Token<T>, provider: Provider<T>) {
this.providers.set(token, provider)
}
resolve<T>(token: Token<T>): T {
if (this.instances.has(token)) return this.instances.get(token) as T
const provider = this.providers.get(token)
if (!provider) throw new Error(`Provider not registered: ${String(token)}`)
const instance = provider()
this.instances.set(token, instance)
return instance as T
}
}Combine it with AsyncLocalStorage for a scoped container:
@startuml
title AsyncLocalStorage Context Isolation
participant "HTTP Request" as req
participant "ALS Store" as als
participant "Container" as c
participant "UserService" as svc
req -> als: run(container, handler)
activate als
als -> req: handler()
req -> c: resolve(UserServiceToken)
c -> svc: new UserService(repo)
svc --> c: instance
c --> req: userService
deactivate als
note right of als
Each request runs in its own async context
Different requests receive different container instances
end note
@endumlDiagram unavailable. Use Show code to inspect the source.
Advantages | Disadvantages |
|---|---|
Central registration reduces boilerplate | Adds a runtime resolution layer |
Supports singleton, scoped, and transient lifecycles | The dependency graph is less obvious than with manual DI |
Isolated containers for requests, kernels, or tests | Errors move from compile time to runtime |
Fits plugins and modular registration | The container itself needs lifecycle design |
Suitable for: many modules, complex dependency graphs, request scope / kernel scope, and test isolation without extensive manual assembly.
Decorator-based DI
The standard approach in frameworks such as NestJS, Angular, and Inversify.
@Injectable()
class UserService {
constructor(
@Inject(UserRepoToken) private readonly userRepo: UserRepo,
) {}
}
@Module({ providers: [UserService, PostgresUserRepo] })
class UserModule {}The framework scans decorator metadata, extracts constructor parameter types, and automatically creates and injects instances.
Advantages | Disadvantages |
|---|---|
No handwritten provider factories for large numbers of classes | Depends on experimentalDecorators and reflect-metadata |
Automatic scanning, registration, and injection | TypeScript interfaces are erased at runtime, so explicit tokens are still needed |
Fits teams already using | Dependency-resolution failures produce less intuitive stack traces |
Suitable for: NestJS-style projects and teams familiar with framework DI. Not suitable for: lightweight Bun services or projects avoiding reflect-metadata.
Using a Container: DI vs Service Locator
Once you have an IoC Container, there are two very different ways to use it. The distinction isn't whether a container exists, but who actively retrieves dependencies.
@startuml
package "DI Style" {
[Composition Root] as cr
[Container] as c1
[OrderService] as os1
cr -> c1: resolve dependencies
cr -> os1: new OrderService(deps)
note right of os1: Unaware of the container
}
package "Service Locator Style" {
[Container] as c2
[OrderService] as os2
os2 -> c2: inject(EventBusToken)
note right of os2: Explicitly requests dependencies from the container
}
@endumlDiagram unavailable. Use Show code to inspect the source.
DI style: the assembly layer resolves dependencies and passes them to business objects through constructors:
container.register(OrderServiceToken, () =>
new OrderService(
container.resolve(UserServiceToken),
container.resolve(EventBusToken),
)
)
class OrderService {
constructor(
private readonly userService: UserService,
private readonly eventBus: EventBus,
) {}
}Service Locator style: business objects ask the container for dependencies themselves:
class OrderService {
private readonly userService = inject(UserServiceToken)
private readonly eventBus = inject(EventBusToken)
}Why Service Locator Requires Restraint
Its biggest problem is hidden dependencies. Looking at class OrderService { constructor() {} }, you'd assume it has none, yet it might silently call inject() three times inside. This prevents testing the class independently of the container and makes it harder to read.
My boundary is:
Layer | May Use inject/resolve? |
|---|---|
Assembly and plugin-loading layers | Yes |
Core business classes | No; use constructor injection |
This reduces boilerplate through a container without tightly coupling business objects to it.
Organizing Modules
The four approaches above address object creation and injection. The following approaches address a higher-level issue: how to organize and extend modules.
Plugin / Module Registry
When a system needs dynamic extension as well as many modules—for example an agent runtime, editor, or bot platform—a Plugin Registry is more suitable.
The central idea: each module declares its own registration logic, and startup loads them centrally.
interface AppModule {
name: string
register(container: Container): void
}
export const userModule: AppModule = {
name: "user",
register(container) {
container.register(UserRepoToken, () => new PostgresUserRepo())
container.register(UserServiceToken, () =>
new UserService(container.resolve(UserRepoToken))
)
},
}
// 启动
for (const mod of modules) {
mod.register(container)
}Advantages | Disadvantages |
|---|---|
Clear, self-describing module boundaries | Module dependency order must be designed explicitly |
Enable or disable modules through configuration | Requires lifecycle definitions such as init/destroy |
A natural fit for plugin systems | Not suitable for simple CRUD projects |
Suitable for: plugin systems, agent runtime projects, multiple provider integrations, and systems enabling capabilities through configuration.
Functional Core + Imperative Shell
Functional Core + Imperative Shell addresses a different dimension: separating pure computation from side effects.
@startuml
title Functional Core + Imperative Shell
package "Imperative Shell" {
[HTTP Handler] as http
[DB Query] as db
[External API] as api
[File IO] as io
}
package "Functional Core" {
[Price Calculator] as calc
[Permission Checker] as perm
[Context Builder] as ctx
[State Machine] as sm
}
http --> calc
http --> perm
http --> ctx
http --> sm
note right of calc
Pure functions: same input = same output
No database reads, network requests, or file writes
end note
@endumlDiagram unavailable. Use Show code to inspect the source.
// 核心:纯函数,不碰 IO
function calculatePrice(input: PriceInput, rules: PricingRules): PriceResult {
// pure logic
}
// 外壳:处理 IO
const rules = await repo.loadRules()
const result = calculatePrice(input, rules)
await repo.savePrice(result)Advantages | Disadvantages |
|---|---|
Very low testing cost for core logic | Doesn't manage long-lived objects |
No dependency on containers, databases, or networks | Connection pools, event streams, and caches still need DI |
Fits rule evaluation, data transformations, and permission checks |
It complements DI:
Layer | Responsibility |
|---|---|
DI / IoC Container | Manage external dependencies and object lifecycles |
Functional Core | Hold testable core computation |
Comparing the Four DI Approaches
Dimension | Global Singletons | Manual DI | Explicit IoC Container | Decorator DI |
|---|---|---|---|---|
Learning cost | None | Low | Medium | Medium–High |
Dependency visibility | Low | High | Medium–High | Medium |
Testability | Low–Medium | High | High | Medium–High |
Lifecycle management | None | Manual | Built in | Built into the framework |
Compile-time safety | High | High | Medium | Medium |
Boilerplate | Very little | High; grows with module count | Low | Low |
Suitable scale | Small | Small–Medium | Medium–Large | Large |
A Path for Choosing
Evolve gradually with project size:
@startuml
title Choosing a Dependency Strategy by Project Size
start
:Start the project;
if (Fewer than 5 modules?) then (Yes)
:Manual DI;
note right: Explicit dependencies, no magic
else (No)
if (Is wiring code growing too large?) then (Yes)
if (Need dynamic extensions?) then (Yes)
:Plugin Registry + IoC Container;
else (No)
:Explicit IoC Container;
endif
else (No)
if (Using NestJS?) then (Yes)
:Decorator-based DI;
else (No)
:Continue with manual DI;
endif
endif
endif
stop
@endumlDiagram unavailable. Use Show code to inspect the source.
In words:
Early project → manual DI, with global singletons for infrastructure such as logger and config.
More modules and unmanageable assembly code → introduce an explicit IoC Container.
Need request/session/kernel isolation → AsyncLocalStorage + a scoped container.
Naturally plugin-based system → Plugin Registry + lifecycle hooks.
Team already uses NestJS → Decorator DI, accepting the framework's constraints.
My Final Solution
Dependencies:
@startuml
top to bottom direction
node "AgentRuntime" as rt {
node "TaskEngine" as te
node "ContextBuilder" as cb
}
node "Infrastructure" as infra {
node "AgentRegistry" as ar
node "SkillRegistry" as sr
node "ToolRunner" as tr
node "EventBus" as eb
node "SessionStore" as ss
}
te --> ar
te --> tr
te --> eb
te --> ss
cb --> sr
cb --> tr
tr --> sr
tr --> eb
note right of rt
All modules need logger and config
Different kernels need separate instances
end note
@endumlDiagram unavailable. Use Show code to inspect the source.
Manual DI felt great initially, but as modules multiplied, the composition root grew to hundreds of lines of pure assembly. I settled on a lightweight IoC Container plus AsyncLocalStorage, with the core code in just two files.
IoC Container Implementation
import { AsyncLocalStorage } from "node:async_hooks";
export type ClassToken<T> = abstract new (...args: never[]) => T;
export type SymbolToken<T> = symbol & { readonly __type?: T };
export type Token<T> = ClassToken<T> | SymbolToken<T>;
type Provider<T> = () => T;
const als = new AsyncLocalStorage<IocContainer>();
export class IocContainer {
private instances = new Map<Token<unknown>, unknown>();
private providers = new Map<Token<unknown>, Provider<unknown>>();
set<T>(token: Token<T>, instance: T): void {
this.instances.set(token, instance);
}
register<T>(token: Token<T>, factory?: Provider<T>): void {
if (factory === undefined) {
if (typeof token === "symbol") {
throw new Error(
`Cannot auto-register symbol token "${String(token)}" without a factory.`,
);
}
// 抽象类作为 token 时,自动推断构造函数
this.providers.set(token, () => new (token as new () => T)());
return;
}
this.providers.set(token, factory);
}
resolve<T>(token: Token<T>): T {
if (this.instances.has(token)) return this.instances.get(token) as T;
const factory = this.providers.get(token);
if (!factory)
throw new Error(`Provider for token ${String(token)} not registered`);
const instance = factory();
this.set(token, instance);
return instance as T;
}
/** 一次性解析所有已注册的 provider,确保所有单例在启动时即构建完毕 */
resolveAll(): void {
for (const token of this.providers.keys()) this.resolve(token);
}
}
export function token<T>(name: string): SymbolToken<T> {
return Symbol.for(name) as SymbolToken<T>;
}
export function runWithContainer<T>(
container: IocContainer,
callback: () => T,
): T {
return als.run(container, callback);
}
function getContainer(): IocContainer {
const container = als.getStore();
if (!container)
throw new Error("No active IoC container in current async context");
return container;
}
export const register: IRegistrar = <T>(
token: Token<T>,
factory?: Provider<T>,
): void => {
getContainer().register(token, factory);
};
export const inject: IInject = (<T>(token: Token<T>): T => {
return getContainer().resolve(token);
}) as IInject;
inject.lazy = function lazy<T extends object>(token: Token<T>): T {
let instance: T | undefined;
let resolved = false;
const container = als.getStore();
if (!container) throw new Error("inject.lazy() requires an active container");
return new Proxy({} as T, {
get(_, prop) {
if (!resolved) {
instance = container.resolve(token);
resolved = true;
}
const value = Reflect.get(instance!, prop);
return typeof value === "function" ? value.bind(instance) : value;
},
}) as T;
};Bootstrapping the Entry Layer
export const bootstrap = (options: KernelOptions): IAgentKernel => {
const container = new IocContainer();
container.set(KernelOptionsToken, options);
let logger: ILogger;
let router: RequestRouter;
runWithContainer(container, () => {
registerProviders(); // 注册所有模块的 provider
container.resolveAll(); // 一次性构建所有单例
logger = inject(Logger).child("Bootstrap");
logger.info("Bootstrapping Agent Kernel");
router = inject(RequestRouter);
registerRoutes();
});
const handleRequest = async (request: RpcRequest): Promise<RpcResponse> => {
return runWithContainer(container, async () => {
const { id, method, params } = request;
logger.debug(`Received request: ${method} with id: ${id}`, params);
try {
return router.handle(request);
} catch (error) {
let msg = "unknown error";
if (error instanceof Error) msg = error.message;
return ErrorRspSchema.parse({ id, method, msg });
}
});
};
return { handleRequest };
};A Few Design Decisions
In the current design, every provider is a singleton by default.
resolveAll(): after all providers are registered, construct every singleton at once. I deliberately build the entire dependency tree at startup rather than lazily resolving it on the first request. Missing dependencies and construction failures are discovered immediately instead of unexpectedly exploding at runtime.
inject.lazy(): originally intended for circular dependencies. If A depends on B and B on A, inject.lazy() returns a proxy that resolves from the container only when a property is accessed, breaking the cycle. In practice, resolveAll() creates every instance at startup, so lazy resolution plays almost no role in this workflow. I keep it only as an escape hatch in case a future module needs delayed resolution.
AsyncLocalStorage: this is purely about isolation. If bootstrap() runs several times—for multiple kernels in tests, or isolated agent instances in one process—a global container requires manually dealing with provider overrides, instance replacement, and state cleanup. With ALS, each bootstrap() has a completely independent IocContainer; inject() and register() naturally operate only on the current async context's container. Requests don't need runWithContainer: all modules are built during resolveAll(), and handleRequest directly uses logger, router, and other references captured in its closure. It never touches ALS, so the performance cost is negligible.
The Result
The core idea is to draw a clear line between registering providers and creating instances.
registerProviders() → 声明"有哪些模块、怎么创建"
resolveAll() → 一次性把整棵树建好
handleRequest() → 只使用,不再创建Benefits:
Far less assembly boilerplate
Declare providers and tokens, then let resolveAll() create every instance. Wiring changes from constructing each dependency individually to a registration loop.
Full dependency validation at startup
resolveAll() constructs every provider during startup, immediately exposing missing dependencies, circular dependencies, and construction errors.
Built-in kernel isolation
AsyncLocalStorage gives each bootstrap() its own container. Multiple kernels can run concurrently in one process without interfering. Tests can easily create fresh isolated containers without resetting global state manually.
Explicit business code with no decorators
No reflect-metadata, decorators, or automatic class-property scanning. Business classes still receive dependencies through constructors, retaining the readability and testability of manual DI. Only the assembly layer uses the container to reduce repetition.
Very little runtime overhead
Every instance is constructed at startup. Request handling uses already-created objects from closures, without container lookups. AsyncLocalStorage carries context only during startup; request handling triggers no async hooks, leaving performance almost unaffected.
Flexible lifecycle management
All providers default to singletons, fitting global runtime components such as TaskEngine, EventBus, and Logger. For request-level isolation, pass a fresh container to runWithContainer to obtain scoped semantics.
Minimal implementation and maintenance costs
The core is two files and fewer than 150 lines, without third-party dependencies. It is easy to change or extend, for example with dispose hooks or transient support, and has far less learning and operational overhead than introducing Inversify or NestJS.
Summary
Problem | Recommended Approach |
|---|---|
Few modules; keep things simple |
|
Too much assembly code | A lightweight |
Need |
|
The system is naturally extensible |
|
Pure computation |
|
The team uses |
|
My default starting point is always manual DI with directories organized by business module. Move to an explicit IoC Container only when boilerplate becomes genuinely troublesome. Don't let the solution run ahead of the need.