Framework Design
Dependency graph, request scope, and lifecycle design for @niamori/arche.di
@niamori/arche.di 设计文档
背景
当前后端通过 srv.xxx 这个 Proxy 读取请求级依赖,背后结合了 ts-fp-di 和 Hono context。这个写法在业务层很顺手,但存在几个问题:
- 依赖关系不够显式,
makeXXXService()内部调用链需要哪些 service 不容易从构建处看出来。 - 生命周期主要靠 middleware 顺序和约定维护,资源型依赖的 acquire / release 边界不够集中。
- 测试时可以覆盖依赖,但依赖层级、固定依赖实例、后台任务 scope 等语义还没有形成统一抽象。
目标是把这套能力抽成一个通用包,包名为 @niamori/arche.di。
设计目标
- 业务代码统一通过
srv.xxx读取依赖。 srv背后使用ts-fp-di提供的 ALS scope,不直接手写AsyncLocalStorage。- 依赖声明和依赖层级使用
defineGraph().node(...).edge(...)表达,不引入 Effect Tag / Layer。 makeXXXService()默认不接收 deps,service 内部统一通过srv.xxx消费依赖。- 用
defineGraph().node(...).edge(...).done()显式声明 DAG,并自动生成 typedsrv和 service 注册表。 - 在
makeXXXService()构造期,以及它返回的 service 方法调用期,内部继续使用srv.xxx时,都固定读取构建时解析出的同一批 deps 实例。 - Hono 仍然使用自然 middleware 形式:
di.hono()、di.make('billing'),不引入新的 controller DSL,也不额外保留withXXX()包装。 - 每个接口只安装自己需要的依赖,适配 Cloudflare Workers / FaaS 的 request-scope 生命周期。
ctx.executionCtx.waitUntil()通过di.wait(fn)延长当前 request scope 的资源生命周期,不额外创建后台 scope。
非目标
- 不把业务函数改造成
Effect.gen或返回Effect.Effect,也不把 Effect 作为 DI runtime。 - 不提供全局常驻容器,也不把所有依赖打包成一个常驻应用层。
- 不把业务 service 绑定到 Hono。Hono 只是 request scope adapter,核心的
srv、trySrv、defineGraph、di.with仍然可用于测试、队列任务、cron、CLI 等场景。 - 不在业务层写
as any。包内部的类型边界集中封装在srvProxy 和 service 注册表里,并且不使用as any。
总体架构
defineGraph
负责声明 service DAG 和注册 service factory
ts-fp-di
负责当前 async 调用链上的 DI scope
srv Proxy
负责提供 typed service locator:srv.xxx -> 当前 ts-fp-di scope
Hono adapter
负责初始化 request scope、收集 wait task、统一释放资源,并提供 di.make(...) middleware业务层看到的仍然是普通 TypeScript:
srv.logger.info('grant access')
const user = await srv.repo.AuthUser.findOne({ id: userId })
await srv.billing.grantAccess(userId)核心类型
应用侧不再手写完整的 AppSrvMap、SrvTag 和 Live。defineGraph() 是一个会累积类型状态的 builder:
const makeRequestId = () =>
srv.hono.req.header('x-request-id') ?? 'missing-request-id'
export const di = defineGraph()
.node('requestId', makeRequestId)
.node('env', () => srv.hono.env)
.node('logger', makeLogger)
.edge(['requestId'], 'logger')
.node('orm', makeOrm)
.edge(['env', 'logger'], 'orm')
.node('repo', makeRepos)
.edge(['orm', 'logger'], 'repo')
.node('billing', makeBillingService)
.edge(['logger', 'repo'], 'billing')
.done()
export const srv = di.srv
export type AppSrvMap = typeof srvdefineGraph() 默认带有一个 hono seed node。di.hono() 会把当前 Hono ctx 注入为 srv.hono,所以 makeRequestId()、makeEnv() 等 request 相关节点可以直接读取 srv.hono,不需要在 graph 中手写 edge(['hono'], 'requestId')。
node(name, make) 会把 make 的返回值类型加入当前 GraphBuilder<TServices>;如果 make 返回 Promise<T>,则加入的是解析后的 T。所以下一行 edge(...) 的 from/to 参数只能补全并接受已经声明过的节点名。
defineGraph()
.node('requestId', () => '123')
.node('logger', makeLogger)
.edge(['requestId'], 'logger')
.node('billing', makeBillingService)
.edge(['logger'], 'billing')
.done()上面的写法允许 node 和 edge 交替出现。类型层面:
.edge(['requestId'], 'logger')成立,因为requestId和logger都已经声明。.edge(['repo'], 'billing')如果写在repo节点声明之前,会直接类型报错。.node('requestId', ...)如果重复声明,也会类型报错。.node('hono', ...)会类型报错,因为hono是默认 seed node。di.make('hono')会类型报错,因为hono只可读取,不需要构建。
done() 内部会自动:
- 从所有
node推导AppSrvMap。 - 根据所有
edge(from, to)生成每个 node 的依赖 key 列表。 - 为每个 node 注册对应的 service factory。
- 返回只暴露
srv、trySrv、wait(...)、hono()、make(...)、with(...)的di对象。
公开 API 保持很窄:
di.srv // 强读取:缺 scope 或缺依赖时抛错
di.trySrv // 弱读取:缺 scope 或缺依赖时返回 undefined
di.wait(fn) // 调用 srv.hono.executionCtx.waitUntil(fnPromise),并延长当前 request scope 生命周期
di.hono() // 初始化 Hono request scope,并注入 srv.hono
di.make('billing') // 按 graph 构建 billing 及其依赖
di.with(deps, fn) // 在当前 async 调用链注入或覆盖 depstag、set、runWithDeps、lets、live 都是内部实现细节,不出现在 di 的公开类型上。
srv 读取语义
srv.xxx 是唯一运行时读取入口:
export const srv = new Proxy({} as SrvMap, {
get(_, prop) {
if (typeof prop !== 'string') return undefined
return diDep(prop)
},
})真实实现中会限制 prop 必须属于 keyof SrvMap。业务层不需要知道 diDep。
srv.xxx 的语义是:
- 在当前
ts-fp-discope 中读取 key 为xxx的实例。 - 如果当前没有 DI scope,抛出清晰错误。
- 如果当前 scope 未注册该依赖,抛出清晰错误。
声明依赖,隐式消费
这是本设计最重要的部分。
服务构建时,graph 负责解析依赖;makeXXXService() 本身不接收 deps。依赖声明放在 graph 里:
const di = defineGraph()
.node('logger', makeLogger)
.node('repo', makeRepos)
.edge(['logger'], 'repo')
.node('resend', makeResend)
.node('billing', makeBillingService)
.edge(['logger', 'repo', 'resend'], 'billing')
.done()makeBillingService() 内部统一使用 srv.xxx:
export function makeBillingService(): BillingService {
srv.logger.info('create billing service')
initializeBillingRules()
return {
async grantAccess(userId) {
srv.logger.info('grant access', { userId })
return grantAccess(userId)
},
}
}其中 initializeBillingRules() 和 grantAccess() 可以继续使用 srv.logger / srv.repo,它们应该读取到构建时解析出的那批 deps 实例。
这里的职责分工是:
node('billing', makeBillingService)声明 service 名称和构建函数,并从返回值推导srv.billing类型。edge(['logger', 'repo', 'resend'], 'billing')显式声明billing的构建依赖。- 内部 service builder 把 graph 解析出的 deps 写入固定 DI scope。
makeBillingService()和它的下游函数通过srv.xxx隐式消费这批 deps。- 返回的 service 方法通过 method binding 固定到同一个 DI scope。
di.make('billing')在 Hono 请求中按依赖树安装 service:当前srv已有的实例直接复用,没有的实例才从对应的 node 构建。
实现上,内部 builder 使用 ts-fp-di 的 diContext() 创建一个固定上下文,把 deps 写入该上下文,然后在这个上下文中构造 service:
async function buildService<T>(deps: Partial<SrvMap>, make: () => T | Promise<T>) {
const fixedCtx = contextFromDeps(deps)
return diInit(async () => {
const service = await make()
return bindServiceMethodsIfNeeded(service, fixedCtx)
}, fixedCtx)
}如果 service 返回值上存在 dispose、close、destroy、Symbol.dispose 或 Symbol.asyncDispose,构建完成后会把对应 disposer 注册到当前 request lifecycle。释放动作由 di.hono() 统一执行。
contextFromDeps 不手写 ALS,只填充 ts-fp-di 暴露的 context 对象:
function contextFromDeps(deps: Partial<SrvMap>) {
const ctx = diContext()
for (const [key, value] of Object.entries(deps)) {
ctx.deps.set(key, value)
}
return ctx
}ts-fp-di 的 diInit(cb, ctx) 在已有 DI scope 中调用时,会把当前 scope 与传入 ctx 合并,并且 ctx 中的值覆盖当前值。因此固定 deps 可以覆盖调用现场的同名依赖。
需要注意的是,ctx 本身是一个带有 deps/once/state/derived Map 的可变对象,不能在整个 createDi() 中复用同一个实例。否则第一次请求写入的 deps 会残留在这个共享 context 里,后续请求即使没有声明同名 deps,也可能读到旧值。
因此 di.with(deps, fn) 和内部 service builder 都需要为本次 deps 创建独立 context。diInit 负责“当前 scope + 传入 context”的合并,但不负责清空或复制传入 context 自己已经持有的状态。
方法绑定语义
仅在构造期运行 diInit(..., fixedCtx) 还不够。因为 ALS 绑定的是执行链路,不是 service 对象本身。
如果这样写:
const service = diInit(() => makeBillingService(), fixedCtx)
await service.grantAccess(userId)grantAccess() 内部的 srv.xxx 默认读取的是调用它时的当前 scope,而不是创建 service 时的 fixed deps。
因此内部 service builder 默认对返回 service 的方法做绑定。绑定不是只处理顶层方法,而是会递归处理嵌套对象方法;如果 service 本身就是一个函数,也会绑定它的调用入口:
function bindServiceMethods<T extends object>(
service: T,
fixedCtx: DiContext,
cache = new WeakMap<object, object>(),
): T {
const cached = cache.get(service)
if (cached) return cached as T
const proxy = new Proxy(service, {
apply(target, thisArg, args) {
return diInit(
() => Reflect.apply(target as (...args: unknown[]) => unknown, thisArg, args),
fixedCtx,
)
},
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver)
if (typeof value !== 'function') {
if (isObjectLike(value)) {
return bindServiceMethods(value, fixedCtx, cache)
}
return value
}
return (...args: unknown[]) => {
return diInit(() => value.apply(target, args), fixedCtx)
}
},
})
cache.set(service, proxy)
return proxy
}这样可以保证:
makeXXXService()构造阶段调用srv.xxx,读取 fixed deps。service.someMethod()方法阶段调用srv.xxx,也读取 fixed deps。service.repo.User.findOne()这种嵌套对象方法阶段调用srv.xxx,仍然读取 fixed deps。service本身是函数时,直接调用service()也读取 fixed deps。- 方法中继续调用的任意下游函数,只要仍在该 async 调用链里,也读取 fixed deps。
资源生命周期边界
方法绑定只负责固定上下文,不负责维护额外的存活状态,也不在核心包里做 service 逃逸检查。
资源型依赖的释放由 di.hono() 维护的 request lifecycle 负责。对于 Hono 场景,di.make(...) 在进入路由前按需构建 service,并把构建出来的资源 disposer 注册到当前 request lifecycle;di.hono() 在路由和所有 di.wait(...) 后台任务结束后,按构建顺序的反序释放这些资源。
这意味着核心包的语义保持轻量:
di.hono()创建一次当前 request 的 DI scope 和 lifecycle。di.with(deps, fn)只在当前调用链里注入或覆盖 deps,不单独创建资源生命周期。- 内部 service builder 只创建 fixed deps context,并把返回 service 的方法绑定到这个 context。
di.wait(fn)会把后台任务登记到当前 request lifecycle,并延长资源释放时间。
如果某个 service 持有 ORM、事务、repo 等 request-scoped resource,应由应用层约束不要把它保存到 request 之外。di.wait(...) 可以延长本次 request scope 的存活时间,但不能把资源变成跨请求常驻资源。
Hono middleware 接入
di.hono() 是 Hono middleware,用来在每个 request 入口初始化一个 ts-fp-di scope,并把当前 Hono ctx 写入默认 seed:srv.hono。
requestId、env、executionCtx 等 Hono 相关依赖可以直接声明成普通 graph node,从 srv.hono 派生。requestId 仍然由 Hono 的 requestId middleware 生成,DI 只负责把它从 Hono 范畴转成 srv.requestId:
const di = defineGraph()
.node('requestId', () => srv.hono.var.requestId)
.node('env', () => srv.hono.env)
.node('logger', makeLogger)
.edge(['requestId'], 'logger')
.done()
app.use('*', di.hono())
app.use('*', requestId())路由按需安装最终需要的 service:
app.get('/api/billing/grant', di.make('billing'), async (ctx) => {
await srv.billing.grantAccess(ctx.req.query('userId')!)
return ctx.json({ ok: true })
})Hono 仍然只负责 gateway、HTTP、requestId、response 和错误转换。业务 service 不依赖 Hono。
di.make 的职责
di.make(key) 负责:
- 从 graph 注册表中读取目标 service key 对应的 service factory 和 deps。
- 对每个 dep 先检查当前
srvscope,如果已经存在就直接复用。 - 如果 dep 不存在,就从注册表找到对应 node 并递归安装。
- 把当前 scope 中已准备好的 deps 写入 fixed DI context,并调用 service factory。
- 把构建产物发布回当前
ts-fp-discope。 - 如果构建产物有关闭方法,把 disposer 注册到当前 request lifecycle。
await next(),释放动作留给最外层的di.hono()。
di.make(...) 接收 graph 里声明过的 service key,例如 di.make('billing')。它不尝试解析任意手写依赖结构。
伪代码:
async function makeMiddleware(key: string, next: () => Promise<void>) {
await installService(key)
await next()
}
async function installService(key: string) {
const metadata = registry.get(key)
if (srvHas(key)) return
detectCycle(key)
for (const dep of metadata.deps) {
if (srvHas(dep.key)) continue
await installService(dep.key)
}
const deps = depsFromCurrentDiScope(metadata.deps)
const service = await buildService(deps, metadata.make)
publishServiceToDiScope(key, service)
registerDisposerIfNeeded(service)
}如果 graph 里出现循环依赖,会抛出明确的 DiGraphCycleError;如果绕过类型系统去声明 node('hono', ...) 或 edge(['hono'], ...),运行时也会阻止,因为 hono 是默认 seed,不属于可构建业务节点。
如果 billing 依赖 logger、repo、resend,路由只需要挂 di.make('billing')。logger 如果已经由 di.with(...) 写入当前 scope,就会被直接复用;repo / resend 如果没有现成实例,就会按它们各自注册的 node 构建。某个依赖既没有现成实例,也没有注册 node 时,di.make 会在请求进入业务逻辑前失败。
这里不推荐把所有 node 预先构建后挂到每个路由上。Cloudflare Workers / FaaS 场景下,每个请求只应该构建当前接口真正需要的依赖。
依赖的依赖
使用 node / edge 表达依赖层级,业务侧避免手写 token / tag 和递归构建样板。
Logger 依赖 requestId:
defineGraph()
.node('requestId', () => 'fallback-request-id')
.node('logger', makeLogger)
.edge(['requestId'], 'logger')Repo 依赖 ORM 和 logger:
defineGraph()
.node('logger', makeLogger)
.node('orm', makeOrm)
.edge(['logger'], 'orm')
.node('repo', makeRepos)
.edge(['orm', 'logger'], 'repo')Billing 依赖 logger、repo、resend:
defineGraph()
.node('logger', makeLogger)
.node('repo', makeRepos)
.edge(['logger'], 'repo')
.node('resend', makeResend)
.node('billing', makeBillingService)
.edge(['logger', 'repo', 'resend'], 'billing')这里不使用 generator。依赖关系由 node(name, make) 和 edge(from, to) 声明,资源生命周期由 di.hono() 统一管理。
make 可以是同步 factory,也可以是异步 factory。比如 Cloudflare Workers 里的 MikroORM 初始化可以直接写成 node('orm', makeOrm),其中 makeOrm() 返回 Promise<MikroOrm>;srv.orm 的类型仍然是解析后的 MikroOrm。
资源型依赖
所有 service 都通过 node(...) 进入 graph。done() 内部会记录每个 node 的 factory 和 deps,di.make(...) 按需构建 service。
内部 release 逻辑会在 service 返回值上按顺序探测 Symbol.asyncDispose、Symbol.dispose、dispose、close、destroy。如果存在这些关闭方法,release 阶段会调用对应方法;如果不存在,release 阶段就是 noop。入口统一,不再区分普通 service 和 resource service。
const di = defineGraph()
.node('env', () => fallbackEnv)
.node('logger', makeLogger)
.node('connection', makeConnection)
.edge(['env', 'logger'], 'connection')
.done()普通 service 和资源型 service 都用 node(name, makeXxx),区别只在返回对象是否带关闭方法。
推荐把“依赖声明”放在 edge(...) 里,而不是靠 factory 内部第一次读取 srv.env 才暴露问题。srv.env 是消费入口,不是依赖声明入口。
waitUntil 后台任务
ctx.executionCtx.waitUntil() 可以让任务在 response 返回后继续执行。这里不再创建独立后台 scope,而是让 di.wait(fn) 延长当前 request scope 的资源生命周期。
因此后台入口不接收一个已经开始执行的 Promise,而是接收一个 async function:
di.wait(async () => {
await srv.billing.syncSubscription(subscriptionId)
})di.wait(fn) 会在当前 ALS scope 中启动 fn,把得到的 Promise 保存到 request lifecycle,并调用 srv.hono.executionCtx.waitUntil(promise)。
实现策略:
di.hono()初始化 request scope 和 request lifecycle。di.make(...)构建 service,并把资源型 service 的 disposer 注册到 request lifecycle。di.wait(fn)在当前 scope 中启动后台任务,保存 Promise,并调用srv.hono.executionCtx.waitUntil(promise)。di.hono()在await next()结束后检查是否存在 wait task。- 如果没有 wait task,立即按反序释放本次 request 构建出来的资源。
- 如果存在 wait task,调用
srv.hono.executionCtx.waitUntil(drainWaitTasks().then(disposeAll)),等所有后台任务和嵌套后台任务完成后再释放资源。
示例:
app.post('/grant/:userId', di.make('billing'), async (ctx) => {
const subscriptionId = ctx.req.param('userId')
di.wait(async () => {
await srv.billing.syncSubscription(subscriptionId)
})
return ctx.text('accepted', 202)
})不要在 di.wait(...) 里修改 response、headers、cookies 等响应相关状态;后台任务应只使用已经构建好的 service、srv.hono.env、srv.requestId 等稳定值。长耗时任务仍应进入 Queue,因为 Cloudflare waitUntil 有时间限制。
测试方式
注入 scope 的单元测试不需要 Hono。
const result = di.with(
{
requestId: 'test-request',
logger: fakeLogger,
repo: fakeRepo,
resend: fakeResend,
},
() => srv.requestId,
)也可以做更接近真实链路的 Hono + DI 集成测试:
const app = new Hono()
app.use('*', di.hono())
app.use('*', async (ctx, next) => {
await di.with(
{
requestId: ctx.req.header('x-request-id')!,
env: testEnv,
},
next,
)
})
app.post('/grant/:userId', di.make('billing'), async (ctx) => {
await srv.billing.grantAccess(ctx.req.param('userId'))
return ctx.json({ ok: true })
})类型测试使用 Vitest 的 *.test-d.ts:
import { assertType, expectTypeOf, test } from 'vitest'
test('defineGraph accumulates node types', () => {
const graph = defineGraph().node('requestId', () => '123')
assertType(graph.edge(['requestId'], 'requestId'))
// @ts-expect-error hono is implicit and does not need an edge.
graph.edge(['hono'], 'requestId')
// @ts-expect-error edge deps must already be declared.
graph.edge(['logger'], 'requestId')
// @ts-expect-error hono is a default seed, not a buildable target.
graph.edge(['requestId'], 'hono')
const di = graph
.node('logger', () => ({ info(message: string) { return message.length } }))
.edge(['requestId'], 'logger')
.done()
expectTypeOf(di.srv.hono.req.header).toBeFunction()
expectTypeOf(di.srv.requestId).toEqualTypeOf<string>()
expectTypeOf(di.trySrv.requestId).toEqualTypeOf<string | undefined>()
expectTypeOf(di.srv.logger.info).toEqualTypeOf<(message: string) => number>()
assertType(di.make('logger'))
// @ts-expect-error low-level API is hidden.
di.live
})package.json 中使用 vitest run --typecheck,这样运行时测试和类型测试会一起执行。
测试时应该每个 case 创建独立 scope,避免共享 ts-fp-di state。
包结构建议
pkg/di/
├── package.json
├── src/
│ └── index.ts # defineGraph, srv/trySrv, wait, hono, make, with
├── test/
│ ├── graph.test-d.ts
│ ├── index.test.ts
│ └── hono-di.test.ts
└── tsconfig.json使用方式:
import { defineGraph, type ServiceDeps, type ServiceKey } from '@niamori/arche.di'package.json 子路径:
{
"exports": {
".": "./dist/esm/src/index.js"
}
}迁移路径
- 新增
pkg/di,先实现核心defineGraph、srv、trySrv、wait、hono、make、with。 - 在后端用
defineGraph().node(...).edge(...).done()声明服务 DAG。 - 替换当前
pkg/be/src/kits/srv.ts,让它从 graph build 后的di.srv导出srv。 - 增加 Hono
di.hono(),并把requestId/env/executionCtx等 request 相关值改成从srv.hono派生的 graph node。 - 直接在 Hono 路由上使用
di.make('logger')/di.make('repo')/di.make('resend')等 graph middleware,不再单独导出withXXX()包装。 - 把
makeXXXService()改成零参数,通过srv.xxx消费依赖;用edge(from, to)固定构建依赖。 - 增加
di.waithelper,让后台任务复用当前 request scope,并延长资源释放时间。 - 最后移除旧的 Hono context fallback,只保留
srv -> ts-fp-di一条读取路径。
主要风险
srv是便利入口,但也会隐藏依赖消费位置。必须要求每个 service 用edge(...)显式声明构建依赖。- method binding 会固定 deps,但不会阻止 service 逃逸。持有 request-scoped resource 的 service 不应保存到 request 之外。
di.wait会延长 request scope 的资源存活时间,后台任务不应执行超过 CloudflarewaitUntil限制的长任务。ts-fp-dicontext 结构属于第三方包 API。核心包会集中封装diContext/diInit/diSet/diDep的使用,应用层不要直接依赖这些细节。- Cloudflare Workers 环境需要确保
AsyncLocalStorage可用。当前项目已经使用ts-fp-di,迁移时仍需保留对应运行时兼容配置。
结论
这套设计把三件事分清楚:
- graph 负责“依赖关系”。
ts-fp-di负责“当前 async scope”。di.hono()负责“request 生命周期和资源释放”。srv负责“业务代码的简单读取体验”。
最终业务代码保持 srv.xxx + async/await,Hono 保持 middleware 风格;而依赖图、测试替换、资源释放和后台任务生命周期都有明确位置。