Effect 的服务接口该怎么设计?

一个服务接口设计得不好,会把 R 里的类型参数变成噪音:每个调用方都得同时处理一堆自己不关心的依赖,或者被迫绑死某个存储实现。

好的服务接口是小而专一的能力:按能力而不是按技术划分,方法的 R 是 never,错误只暴露调用方能处理的,并且只给真正有「换一个实现」场景的东西做服务。

在 Effect 里,服务用 Context.Service 定义,业务代码取用它时,它会被记进 Effect<A, E, R> 的 R 参数;语法部分见 Effect 里怎么用 Context.Service 把依赖写进类型。语法简单,设计才是难处。下面几个问题,基本就是设计时要依次判断的事。

接口该按能力还是按技术划分?

按能力。Mailer.send、Clock.currentTimeMillis、PaymentGateway.charge 是能力;HttpClient、PostgresClient 是技术。

业务代码最好依赖前者,后者留给实现去依赖。这样把数据库从 Postgres 换成别的,业务代码一行不改。

方法的 R 为什么应该是 never?

假设 Mailer.send 返回的是 Effect<void, EmailError, HttpClient>,那么每个使用 Mailer 的地方都得同时处理 HttpClient,实现细节泄漏到了接口。

方法的 R 应当是 never。实现需要的东西,在构造这个服务的时候就取好,做法见 Layer:构建依赖图、生命周期与组合。

接口里该暴露哪些错误?

只暴露调用方能处理的。把底层的 SqlError 原样写进接口,等于把存储实现焊死在业务层。

错误要在边界上翻译成领域语言,服务接口就是那条边界,这个原则见 错误设计。

什么东西值得做成服务?

判断标准:这个东西有没有「换一个实现」的合理场景,比如测试替身、不同环境、不同供应商。

  • 有,做成服务。时间、随机数这类环境量也一样:Effect 自带的 Clock、Random 就是服务,你不直接调 Date.now(),而是通过服务取时间,测试时才能换成可控的时钟,用法见 Effect 怎么写测试。
  • 没有,就是普通函数。formatPrice 这样的纯函数不需要服务。

反过来,也别把接口做得太大:一个 AppContext 塞进二十个方法,等于又退回了全局对象。拆成小而专一的服务,R 里的联合类型才有信息量。

服务实例要不要往下传?

不要。取一次,在闭包里用,不要把实例当参数一路往下传,否则又回到了手动传参。

有状态的服务也一样:状态应当在构造时创建、封装在实现里,而不是在模块顶层放一个 let。

key 字符串怎么起?

key 是服务的运行时身份,两个服务用了同一个 key,会占同一个槽位。惯例是写成 包名/服务名,比如 "app/Mailer",避免撞车。

这篇的设计建议来自教程章节的整理,没有额外运行代码验证,也没有记录对应的 effect 版本号。Effect v4 的 API 与 v3 差异较大,使用前请以你安装的版本为准。