arche

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,并自动生成 typed srv 和 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。包内部的类型边界集中封装在 srv Proxy 和 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 srv

defineGraph() 默认带有一个 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() 内部会自动:

  1. 从所有 node 推导 AppSrvMap。
  2. 根据所有 edge(from, to) 生成每个 node 的依赖 key 列表。
  3. 为每个 node 注册对应的 service factory。
  4. 返回只暴露 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-di scope 中读取 key 为 xxx 的实例。
  • 如果当前没有 DI scope,抛出清晰错误。
  • 如果当前 scope 未注册该依赖,抛出清晰错误。

依赖优先级与覆盖契约

module 构造完成后,方法内动态读取 srv.xxx 时按以下顺序解析,越靠前优先级越高:

  1. 当前 async 调用链中最内层 di.with(...) 对该 key 的显式覆盖。
  2. module 构造时从 graph edge 解析并捕获的 fixed 默认值。
  3. 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) 负责:

  1. 从 graph 注册表中读取目标 service key 对应的 service factory 和 deps。
  2. 对每个 dep 先检查当前 srv scope,如果已经存在就直接复用。
  3. 如果 dep 不存在,就从注册表找到对应 node 并递归安装。
  4. 只收集 graph edge 声明的 deps 与 implicit seed,把它们捕获为 fixed 默认值并调用 service factory。
  5. 把构建产物发布回当前 ts-fp-di scope。
  6. 如果构建产物有关闭方法,把 disposer 注册到当前 request lifecycle。
  7. 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)。

实现策略:

  1. di.hono() 初始化 request scope 和 request lifecycle。
  2. di.make(...) 构建 service,并把资源型 service 的 disposer 注册到 request lifecycle。
  3. di.wait(fn) 在当前 scope 中启动后台任务,保存 Promise,并调用 srv.hono.executionCtx.waitUntil(promise)。
  4. di.with(...) 与 module fixed runner 在后台链路中保留同一个 lifecycle;任务内新建的嵌套 di.wait(...) 会登记到同一集合。
  5. di.hono() 在 await next() 结束后检查是否存在 wait task。
  6. 如果没有 wait task,立即按反序释放本次 request 构建出来的资源。
  7. 如果存在 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"
  }
}

迁移路径

  1. 新增 pkg/di,先实现核心 defineGraph、srv、trySrv、wait、hono、make、with。
  2. 在后端用 defineGraph().node(...).edge(...).done() 声明服务 DAG。
  3. 替换当前 pkg/be/src/kits/srv.ts,让它从 graph build 后的 di.srv 导出 srv。
  4. 增加 Hono di.hono(),并把 requestId/env/executionCtx 等 request 相关值改成从 srv.hono 派生的 graph node。
  5. 直接在 Hono 路由上使用 di.make('logger') / di.make('repo') / di.make('resend') 等 graph middleware,不再单独导出 withXXX() 包装。
  6. 把 makeXXXService() 改成零参数,通过 srv.xxx 消费依赖;用 edge(from, to) 固定构建依赖。
  7. 增加 di.wait helper,让后台任务复用当前 request scope,并延长资源释放时间。
  8. 最后移除旧的 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 的资源存活时间,后台任务不应执行超过 Cloudflare waitUntil 限制的长任务。
  • ts-fp-di context 结构属于第三方包 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 风格;而依赖图、测试替换、资源释放和后台任务生命周期都有明确位置。

On this page