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()构造期解析出的 deps 是 module 的 fixed 默认值;返回的 module 方法离开原 ALS 执行链后仍能读取这些默认值。- 显式
di.with(...)是调用期最高优先级覆盖,能够局部替换已构造 module 的 fixed 默认值。 - graph edge 是 module 可见依赖的权威声明;普通调用现场存在同名依赖,不会静默改变 module,也不会让未声明依赖碰巧可见。
- 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 调用链显式覆盖 deps,优先于 fixed 默认值tag、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 未注册该依赖,抛出清晰错误。
依赖优先级与覆盖契约
module 构造完成后,方法内动态读取 srv.xxx 时按以下顺序解析,越靠前优先级越高:
- 当前 async 调用链中最内层
di.with(...)对该 key 的显式覆盖。 - module 构造时从 graph edge 解析并捕获的 fixed 默认值。
hono等 graph 明确声明为 implicit 的基础 seed。
普通 caller scope 不属于覆盖层。一个 module 即使在另一个请求或任务的 scope 中被调用,也不会仅因调用现场存在同名 key 就改用调用方实例。嵌套 with 会继承外层覆盖,由最内层同名值优先;内层结束后恢复外层值,最外层结束后恢复 fixed 默认值。每个 override scope 都有独立 context,跨 await、callback 和并发任务传播,但不会污染相邻调用链。
构造期覆盖仍然有效:如果 di.make('billing') 前的 di.with(...) 已提供 graph 声明的依赖,di.make 会复用该实例,并将它捕获为 billing 的 fixed 默认值。构造完成后可以再次显式覆盖:
await di.with({ logger: testLogger }, () =>
srv.billing.createCheckout(input),
)调用期覆盖只作用于执行期间动态发生的 srv.xxx 读取。factory 已经读取并保存在闭包、字段或第三方对象内部的值不会被事后替换;需要支持测试覆盖的依赖,应在 module 方法及其 async 下游中通过 srv.xxx 动态读取。
module 的可见依赖集合由它的 graph edge 加 implicit seed 决定。运行 factory 和绑定方法时,DI 会先收窄到这个集合,再叠加其中 key 对应的显式覆盖。因此,请求 scope 中已经安装但未通过 edge 声明的 service 不可见;srv.xxx 是消费入口,graph 才是声明入口。
声明依赖,隐式消费
这是本设计最重要的部分。
服务构建时,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;如果调用链处在显式 di.with(...) 中,则返回对应 override。
这里的职责分工是:
node('billing', makeBillingService)声明 service 名称和构建函数,并从返回值推导srv.billing类型。edge(['logger', 'repo', 'resend'], 'billing')显式声明billing的构建依赖。- 内部 service builder 把 graph 解析出的 deps 捕获为 fixed 默认值。
makeBillingService()和它的下游函数通过srv.xxx隐式消费这批 deps。- 返回的 module façade 方法通过 method binding 在 fixed 默认值之上应用显式调用期 override。
di.make('billing')在 Hono 请求中按依赖树安装 service:当前srv已有的实例直接复用,没有的实例才从对应的 node 构建。
实现上,内部 builder 只保存 graph 允许的 fixed deps,并通过一个窄的 runner 构造和调用 module:
async function buildService<T>(deps: Partial<SrvMap>, make: () => T | Promise<T>) {
const fixedDeps = resolveDeclaredDeps(deps)
const run = <R>(fn: () => R) =>
runWithModuleDeps(fixedDeps, currentExplicitOverrides(), fn)
return run(async () => {
const service = await make()
return bindModuleFacadeIfNeeded(service, run)
})
}如果 service 返回值上存在 dispose、close、destroy、Symbol.dispose 或 Symbol.asyncDispose,构建完成后会把对应 disposer 注册到当前 request lifecycle。释放动作由 di.hono() 统一执行。
不能直接依赖 ts-fp-di 的默认 context 合并来承载这些语义。上游合并会重建标准 Map,但只保留当前 context 的额外字段;当带 lifecycle 的 Hono context 作为新 context 叠加到已有 scope 时,lifecycle 可能丢失。DI 内部因此集中使用自己的 context runner:复制两侧的自定义元数据,独立重建 deps/once/state/derived Map,并把 override 标记与 request lifecycle 一起跨嵌套 scope 传播。context 对象不跨请求复用。
Module façade 绑定边界
仅在构造期运行 fixed scope 不够,因为 ALS 绑定执行链路而不绑定对象。module 离开构造 scope 后,方法入口仍需重新建立“fixed 默认值 + 显式 override”的有效 scope。
Proxy 只用于本项目采用的 module façade 形态:
- function module 会绑定自身调用入口。
- 以
Object.prototype或null为 prototype 的普通对象会绑定自己的方法,并递归处理自己的普通对象 façade 字段。 - class instance、Map、Set、Date、Promise、ORM、Kysely、Winston、Resend 等带自定义 prototype 或原生 internal slot 的对象保持原实例和原身份,不递归代理。
- accessor 使用原 target 作为 receiver;不可配置且不可写的数据属性原样返回,遵守 Proxy invariant。
- 绑定后的 method function 使用稳定缓存,同一属性反复读取保持 identity;method 自身的
.path、.options等属性也保留。 - façade 与 method 都使用 WeakMap 缓存,因此循环引用不会无限递归。
这条边界支持当前实际需要的顶层方法、function module,以及 auth.api.getSession() 一类嵌套普通 façade 方法,同时避免把 srv.d1.kysely、repository、SDK client 或 Map 变成递归 Proxy。class instance 如果自身需要 ambient dependency,应由项目提供一个普通对象 façade,而不是依赖 DI 猜测第三方对象结构。
绑定可以保证 method 内同步、await 和 callback 下游的动态 srv.xxx 使用同一有效 scope。它不改变已经保存在闭包或字段中的值,也不让 request-scoped resource 获得跨请求生命周期。
资源生命周期边界
方法绑定只负责固定上下文,不负责维护额外的存活状态,也不在核心包里做 service 逃逸检查。
资源型依赖的释放由 di.hono() 维护的 request lifecycle 负责。对于 Hono 场景,di.make(...) 在进入路由前按需构建 service,并把构建出来的资源 disposer 注册到当前 request lifecycle;di.hono() 在路由和所有 di.wait(...) 后台任务结束后,按构建顺序的反序释放这些资源。
这意味着核心包的语义保持轻量:
di.hono()为每个 request 创建独立 DI scope 和 lifecycle;如果入口本身处于di.with(...),只继承这层显式 override,不继承普通 caller deps。di.with(deps, fn)只在当前调用链里显式覆盖 deps,不单独创建资源生命周期;context 合并必须保留已有 lifecycle。- 内部 service builder 捕获 fixed deps;方法入口替换有效 deps Map 时仍保留当前 request lifecycle 和 override 元数据。
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。
这个 request scope 与相邻请求完全隔离。为了支持测试或外层任务显式注入,di.hono() 会继承入口处的 di.with(...) override;普通外层 scope 中偶然存在的依赖不会进入请求。request lifecycle 直接写入新 context,因此即使 Hono 调用发生在已有 ALS scope 内,后续 di.make、嵌套 with、di.wait 和 disposer 仍共享正确的 lifecycle。
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 并递归安装。
- 只收集 graph edge 声明的 deps 与 implicit seed,把它们捕获为 fixed 默认值并调用 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 里的 D1 module 初始化可以直接写成 node('d1', makeD1),其中 makeD1() 返回 Promise;srv.d1 的类型仍然是解析后的 module interface。
资源型依赖
所有 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 才暴露问题。构造和方法调用都会被收窄到 edge 声明的集合;即使同一 request 已安装 env,未声明 edge 的 module 也不能读取。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.with(...)与 module fixed runner 在后台链路中保留同一个 lifecycle;任务内新建的嵌套di.wait(...)会登记到同一集合。di.hono()在await next()结束后检查是否存在 wait task。- 如果没有 wait task,立即按反序释放本次 request 构建出来的资源。
- 如果存在 wait task,调用
srv.hono.executionCtx.waitUntil(drainWaitTasks().then(disposeAll));drain 循环等待期间新登记的嵌套任务,全部 settle 后再释放资源。
示例:
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,
)已构造 module 的测试替换同样通过公开 interface 完成:
await di.with({ logger: testLogger }, () =>
srv.billing.createCheckout(input),
)这只替换 createCheckout() 及其 async 下游动态读取的 srv.logger;factory 已保存到闭包的 logger 仍是构造时实例。契约测试应同时覆盖 fixed fallback、显式 override、嵌套恢复和并发隔离。
也可以做更接近真实链路的 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
│ ├── di-overrides.test.ts
│ ├── module-facades.test.ts
│ └── hono-lifecycle.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 提供 fixed 默认值与显式 override,但不会阻止 service 逃逸。持有 request-scoped resource 的 service 不应保存到 request 之外。
- 只有 function 和普通对象 façade 会被绑定;class/SDK/native 实例保持原样。需要 ambient dependency 的 class 应增加项目 façade。
di.wait会延长 request scope 的资源存活时间,后台任务不应执行超过 CloudflarewaitUntil限制的长任务。ts-fp-dicontext 结构属于第三方包 API。核心包集中封装 context Map 合并、override 元数据和 ALS runner,应用层不要直接依赖这些细节。- Cloudflare Workers 环境需要确保
AsyncLocalStorage可用。当前项目已经使用ts-fp-di,迁移时仍需保留对应运行时兼容配置。
结论
这套设计把六件事分清楚:
- graph 负责“依赖关系”。
ts-fp-di负责“当前 async scope”。- fixed deps 负责“module 默认依赖”。
di.with(...)负责“显式局部覆盖”。di.hono()负责“request 生命周期和资源释放”。srv负责“业务代码的简单读取体验”。
最终业务代码保持 srv.xxx + async/await,Hono 保持 middleware 风格;而依赖图、测试替换、资源释放和后台任务生命周期都有明确位置。