AgentRuntime
-> TaskEngine
-> ContextBuilder
-> ModelProvider
TaskEngine
-> AgentRegistry
-> ToolRunner
-> EventBus
-> SessionStore
ToolRunner
-> SkillRegistry
-> PermissionChecker
-> EventBus
ContextBuilder
-> SkillRegistry
-> ToolRunner
-> HistoryStore
到这一步,入口文件的装配代码已经涨到了一两百行。每一行都是 const x = new X(y, z),没有业务含义,纯粹在串依赖。更烦的是,每改一个类的构造函数,就要回 composition root 里改一大串代码。如果某个模块需要隔出一个独立的 request scope 或 test scope,还得手动复制整套装配逻辑。
我需要一个更省事的方案。第一个想到的是 NestJS 那套装饰器注入——@Injectable() 一贴,框架自动扫描、自动装配。但翻了下依赖,需要开 experimentalDecorators、装 reflect-metadata、引入一整套模块系统,对这个小项目来说太重了。
于是顺着这个方向,我把 TypeScript 生态里常见的依赖组织方式系统摸了摸底。目标是搞清楚每种方案到底在解决什么问题、代价是什么、什么情况下该升级,而不是找什么"最佳实践"。
IoC、DI、IoC Container 经常被混用,先说清它们的区别。
IoC(控制反转) 是原则:业务代码不自己创建依赖,由外部传入。
class UserService {
private repo = new PostgresUserRepo ( )
}
class UserService {
constructor ( private readonly repo: UserRepo) { }
}
DI(依赖注入) 是实现 IoC 的一种具体方式。构造函数注入是最常见、也最推荐的形式。
IoC Container 是自动化 DI 的工具——一个运行时的注册表,负责记录 provider、创建实例、缓存单例、管理生命周期。
container. register ( UserRepoToken, ( ) => new PostgresUserRepo ( db) )
container. register ( UserServiceToken, ( ) => new UserService ( container. resolve ( UserRepoToken) ) )
依赖组织
├── 对象创建与注入(DI 实现方式)
│ ├── 全局单例
│ ├── 手写 DI / Composition Root
│ ├── 显式 IoC Container
│ └── Decorator-based DI
│
├── 容器的使用方式
│ ├── DI 风格:装配层 resolve → 注入业务对象
│ └── Service Locator 风格:业务对象自己调 inject()
│
└── 模块组织方式
├── Plugin / Module Registry
└── Functional Core + Imperative Shell
这四种方案解决同一个问题:对象由谁创建、依赖怎么传。
模块加载时就创建好实例,export 出去,用方直接 import。
const db = createDb ( )
const userRepo = new PostgresUserRepo ( db)
export const userService = new UserService ( userRepo)
import { userService } from "./user-service"
await userService. getUser ( "u_123" )
适合 :小脚本、原型、稳定基础设施(logger / config / metrics)。
不适合 :需要测试替换的业务 service、需要 request scope 的服务、长期维护的中大型后端。
我的原则:基础设施可以少量用,核心业务逻辑不要默认走这条路。
业务类只声明自己需要什么,由入口层(composition root)统一创建并装配。
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 = config
适合大多数中小型后端项目 。如果只能选一个默认方案,我选手写 DI。
当依赖图大到 composition root 已经失控时,引入一个轻量容器来管理注册和解析。
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) )
)
TypeScript 项目不一定需要 Inversify 这种重型库。一个 30 行的轻量容器就能解决大部分问题:
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
搭配 AsyncLocalStorage 就能做 scoped container:
适合 :模块多、依赖图复杂、需要 request scope / kernel scope、测试隔离但不想手写大量装配代码。
NestJS、Angular、Inversify 这类框架的标准做法。
@ Injectable ( )
class UserService {
constructor (
@ Inject ( UserRepoToken) private readonly userRepo: UserRepo,
) { }
}
@ Module ( { providers: [ UserService, PostgresUserRepo] } )
class UserModule { }
框架通过装饰器元数据扫描类、提取构造函数参数类型、自动创建和注入实例。
适合 :NestJS 风格项目、团队熟悉框架 DI。不适合 :轻量 Bun 服务、不想引入 reflect-metadata 的项目。
有了 IoC Container,怎么用它还有两种截然不同的风格。区别不在于有没有容器,而在于谁主动获取依赖 。
DI 风格 ——装配层从容器拿依赖,通过构造函数传入业务对象:
container. register ( OrderServiceToken, ( ) =>
new OrderService (
container. resolve ( UserServiceToken) ,
container. resolve ( EventBusToken) ,
)
)
class OrderService {
constructor (
private readonly userService: UserService,
private readonly eventBus: EventBus,
) { }
}
Service Locator 风格 ——业务对象自己向容器索取:
class OrderService {
private readonly userService = inject ( UserServiceToken)
private readonly eventBus = inject ( EventBusToken)
}
它最大的问题是依赖变隐式。看 class OrderService { constructor() {} },你会以为它没有依赖——但内部可能悄无声息地调了三个 inject()。这让类无法脱离容器单独测试,也破坏了阅读体验。
这样既能靠容器减少样板代码,又不会让业务对象和容器强绑定。
前面四种方案解决的是"对象怎么创建和注入"。下面的方案解决的是更上层的问题——模块怎么组织、怎么扩展 。
当系统除了模块多,还需要动态扩展 时(比如 agent runtime、编辑器、bot platform),Plugin Registry 会更合适。
核心思路:每个模块自己声明注册逻辑,启动时统一加载。
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 UserRepoToken
适合 :插件系统、agent runtime、多 provider 集成、需要按配置启用能力的系统。
Functional Core + Imperative Shell 解决的是另一个维度的问题——把纯计算和副作用剥离开。
function calculatePrice ( input: PriceInput, rules: PricingRules) : PriceResult {
}
const rules = await repo. loadRules ( )
const result = calculatePrice ( input, rules)
await repo. savePrice ( result)
项目初期 → 手写 DI,基础设施(logger/config)可以用全局单例
模块增多、装配代码失控 → 引入显式 IoC Container
需要 request/session/kernel 隔离 → AsyncLocalStorage + scoped container
系统天然插件式 → Plugin Registry + lifecycle hooks
团队已用 NestJS → Decorator DI,接受框架约束
开始手写 DI 很舒服,但模块涨起来以后 composition root 变成了几百行纯粹的装配代码。最终落地的是一套轻量 IoC Container + AsyncLocalStorage 的方案,核心代码就两个文件。
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 >
export const bootstrap = ( options: KernelOptions) : IAgentKernel => {
const container = new IocContainer ( ) ;
container. set ( KernelOptionsToken, options) ;
let logger: ILogger;
let router: RequestRouter;
runWithContainer ( container, ( ) => {
registerProviders ( ) ;
container. resolveAll ( ) ;
resolveAll(): provider 全部注册完之后,一次性触发所有单例的构建。我有意在启动阶段就把整棵依赖树构造完毕,不等到第一个请求进来时才懒解析。好处是启动阶段就能发现依赖缺失、构造失败等问题,不会在运行时冷不丁爆炸。
inject.lazy(): 初衷是解决循环依赖。假设 A 依赖 B、B 依赖 A,用 inject.lazy() 可以拿到一个代理对象,只在真正访问属性时才去容器里 resolve,从而打破循环。但实际上 resolveAll() 在启动时就把所有实例都创建了,lazy 在这套流程里几乎没起作用。保留它只是作为一个逃生舱,万一未来有模块需要延迟解析。
AsyncLocalStorage: 纯粹是隔离感的考虑。如果 bootstrap() 被调多次(比如测试里开多个 kernel,或者一个进程里起多个隔离的 agent 实例),共享一个全局容器意味着要手动处理 provider 覆盖、实例替换、状态清理这些破事。用 ALS 的话,每个 bootstrap() 的 IocContainer 完全独立,inject() 和 register() 天然只操作当前 async context 里的那一个。因为请求链路不需要走 runWithContainer,所有模块在 resolveAll() 时就构建完毕了,handleRequest 直接用闭包里捕获的 logger、router 等引用,根本不碰 ALS,所以性能损耗基本上也可以忽略不计。
整套方案的核心思路是:在 provider 注册和实例创建之间画一条清晰的线。
registerProviders() → 声明"有哪些模块、怎么创建"
resolveAll() → 一次性把整棵树建好
handleRequest() → 只使用,不再创建
大幅减少组装样板代码
只需声明 provider 和 token,resolveAll() 一次性完成所有实例创建,装配逻辑从“每个依赖 new 一次”收敛为注册循环。
启动时全量验证依赖完整性
resolveAll() 在启动阶段即触发所有 provider 的构造,任何缺失依赖、循环依赖或构造错误都会立即暴露。
天然的内核隔离能力
AsyncLocalStorage 让每个 bootstrap() 调用拥有独立容器实例,多个 kernel 可并行运行于同一进程,互不干扰。测试时也可轻松创建隔离的新容器,无需手动重置全局状态。
业务代码零装饰器,保持显式
不依赖 reflect-metadata、装饰器或类属性自动扫描。业务类仍通过构造函数接收依赖,可读性和可测试性与手写 DI 完全一致,只是装配层借用了容器来减少重复。
极低的运行时开销
所有实例在启动时构造完毕,请求链路只使用闭包中的已建对象,不再经过容器查找。AsyncLocalStorage 仅用于启动阶段的上下文传递,请求处理不触发任何异步钩子,性能几乎无损耗。
灵活的生命周期管理
默认所有 provider 以单例工作,恰好满足 Agent 运行时中 TaskEngine、EventBus、Logger 等全局组件的需求。若需要请求级隔离,只需在 runWithContainer 中传入新容器,即可实现 scoped 语义。
极简的实现与维护成本
核心代码仅两个文件、不到 150 行,无第三方依赖。且可以轻松修改或扩展(如增加 dispose 钩子、瞬态支持等),相较于引入 Inversify 或 NestJS 大幅降低了学习与运维负担。
我的默认起点永远是手写 DI + 按业务模块组织目录 。等样板代码真的多到烦人了,再升级到显式 IoC Container。不要让方案跑在需求前面。
new
StripeClient
(
)
const eventBus = new InMemoryEventBus ( )
const orderService = new OrderService ( orderRepo, paymentClient, eventBus)
return { db, orderRepo, paymentClient, eventBus, orderService }
}
const userService = container. resolve ( UserServiceToken)
)
}
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
}
}
.
resolve
(
)
)
)
} ,
}
for ( const mod of modules) {
mod. register ( container)
}
;
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. ` ,
) ;
}
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 ;
}
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 ;
} ;
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 } ;
} ;
TypeScript 项目的依赖管理:从手写 DI 到 IoC 容器