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() 构造期,以及它返回的 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,核心的 srvtrySrvdefineGraphdi.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)

核心类型

应用侧不再手写完整的 AppSrvMapSrvTagLivedefineGraph() 是一个会累积类型状态的 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()

上面的写法允许 nodeedge 交替出现。类型层面:

  • .edge(['requestId'], 'logger') 成立,因为 requestIdlogger 都已经声明。
  • .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. 返回只暴露 srvtrySrvwait(...)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

tagsetrunWithDepsletslive 都是内部实现细节,不出现在 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 未注册该依赖,抛出清晰错误。

声明依赖,隐式消费

这是本设计最重要的部分。

服务构建时,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-didiContext() 创建一个固定上下文,把 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 返回值上存在 disposeclosedestroySymbol.disposeSymbol.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-didiInit(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) 负责:

  1. 从 graph 注册表中读取目标 service key 对应的 service factory 和 deps。
  2. 对每个 dep 先检查当前 srv scope,如果已经存在就直接复用。
  3. 如果 dep 不存在,就从注册表找到对应 node 并递归安装。
  4. 把当前 scope 中已准备好的 deps 写入 fixed DI context,并调用 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 依赖 loggerreporesend,路由只需要挂 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.asyncDisposeSymbol.disposedisposeclosedestroy。如果存在这些关闭方法,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)

实现策略:

  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.hono()await next() 结束后检查是否存在 wait task。
  5. 如果没有 wait task,立即按反序释放本次 request 构建出来的资源。
  6. 如果存在 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.envsrv.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"
  }
}

迁移路径

  1. 新增 pkg/di,先实现核心 defineGraphsrvtrySrvwaithonomakewith
  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 会固定 deps,但不会阻止 service 逃逸。持有 request-scoped resource 的 service 不应保存到 request 之外。
  • di.wait 会延长 request scope 的资源存活时间,后台任务不应执行超过 Cloudflare waitUntil 限制的长任务。
  • ts-fp-di context 结构属于第三方包 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 风格;而依赖图、测试替换、资源释放和后台任务生命周期都有明确位置。

On this page