「代码能跑」和「代码能改」是两回事——架构的职责是让核心业务不被技术实现绑架。函数式架构把这件事做到极致:核心是纯函数(输入输出可推演),副作用全部推到外壳(数据库、HTTP、消息都是可替换的适配器)。这不是「用函数式语法写分层架构」,而是「让依赖方向向内、让核心可解释、让替换成本趋零」。本文从动机讲起:六边形/洋葱架构的边界与依赖方向、函数式领域建模、依赖注入的函数式实现、端口与适配器、可测试性与错误隔离,最后给出从传统分层迁移的路线。
前置:/scala-domain-modeling/(领域建模与 ADT)、/scala-functional-programming/(纯函数与不可变)、/scala-testing-practice/(属性测试)、/scala-tagless-final/(代数与多解释器)。
目录
- 1. 架构动机:可测试性与可推理性
- 2. 洋葱与六边形架构:核心、边界与适配器
- 3. 纯核心与副作用外壳:依赖方向
- 4. 函数式领域建模:ADT、领域服务与规则
- 5. 依赖注入的函数式实现:参数传递与 Reader
- 6. 端口与适配器:数据库、HTTP 与消息
- 7. 可测试性:解释器替换与属性测试
- 8. 错误建模:类型化错误与故障隔离
- 9. 迁移路线:从分层到函数式架构
- 10. 速查表与一句话记忆
- 延伸阅读
1. 架构动机:可测试性与可推理性
先回答「为什么值得把架构做得函数式」——两大收益:
收益一:可测试性
□ 纯核心:给定输入必得输出,测试零 mock
□ 副作用隔离:数据库/HTTP 全部可替换成内存/假实现
□ 属性测试:随机输入跑不变量(可测试性直接翻倍)
收益二:可推理性
□ 纯函数可读性强:没有隐藏状态、没有时序依赖
□ 函数即文档:签名就是契约
□ 修改安全:纯核心改错方向编译器/测试立刻暴露
成本认知:
□ 架构成本:边界设计、适配器样板
反例(分层架构的典型痛点):
□ service 层直接 new Repository()、查 JDBC
□ 测试要起数据库、mock 一堆类
□ 改业务规则要动三层代码
函数式架构让「业务规则」成为可单独运行的纯函数。
工程要点:函数式架构的动机是**「把复杂业务变成可推演、可测试的纯函数」**——核心越复杂、规则越多,收益越大;简单 CRUD 不需要。测试从「mock 对象、起数据库」变成「构造输入、断言输出」,修改安全性与可推理性同步提升。
2. 洋葱与六边形架构:核心、边界与适配器
六边形(端口与适配器)与洋葱架构同源,Scala 函数式实现是它的极致化:
分层地图:
□ 最内层:Domain(纯领域:类型 + 规则 + 领域服务)
□ 次内层:Application(用例编排,依赖 Domain 与端口)
□ 边界层:Ports(接口:DB/HTTP/Message 的抽象)
□ 最外层:Adapters(实现:Postgres/HTTP 客户端/Kafka 生产者)
依赖方向(铁律):
内层不依赖外层,外层依赖内层
□ Domain 不知道 DB/HTTP 的存在
□ Application 只依赖 Ports(接口),不依赖 Adapters
□ Adapters 实现 Ports 并「注入」给 Application
Scala 的表达:
□ Domain:纯 Scala + ADT(零框架依赖)
□ Ports:trait(可能泛型化,见 Tagless Final)
□ Adapters:given/构造器注入实现
□ Application:多态函数或服务 trait
// 端口(边界层):核心只依赖这个抽象
trait OrderRepo:
def load(id: OrderId): F[Either[RepoError, Order]]
def save(o: Order): F[Either[RepoError, Unit]]
// 领域核心:纯函数,不知道 OrderRepo 的存在
def totalPrice(o: Order): Money =
o.items.foldLeft(Money(0))(_ + _.price * _.qty)
工程要点:架构的骨架是**「核心向内、依赖向内、适配器向外」**——领域与用例不依赖任何技术实现,只依赖端口接口;数据库/HTTP/消息是外圈的可替换适配器。判断标准:把 Postgres 换成内存,核心代码零改动。这就是「端口与适配器」的全部意义。
3. 纯核心与副作用外壳:依赖方向
「纯核心 + 副作用外壳」的核心是把副作用集中在最外层执行:
副作用外壳原则:
□ 纯核心:计算、规则、状态转移(可推导、可测)
□ 副作用外壳:IO、DB 读写、网络、时间、随机
□ 边界:核心不碰 IO;外壳不写业务规则
□ 依赖方向:外壳知道核心,核心不知道外壳
用效果系统表达(Cats Effect/ZIO):
□ 核心函数返回纯值或 Either
□ 用例层把核心计算与端口效果组合成 F[...]
□ 最外层把 F 运行起来(unsafeRun / 入口)
状态转移的纯化:
□ 命令式:读状态 → 改状态 → 写回(三处副作用)
□ 函数式:read + (pure: state => (newState, events)) + write
→ 中间那段纯函数可单独测试、可回放
import cats.effect.IO
import cats.syntax.all.*
// 纯核心:订单状态机的一步(无副作用)
def transition(state: OrderState, cmd: Command): (OrderState, List[Event]) =
(state.copy(status = Status.Approved), List(Event.Approved(state.id)))
// 外壳:读取 + 纯计算 + 写回 + 发事件
def approve(oid: OrderId)(repo: OrderRepo, events: EventBus): IO[Unit] =
for
st <- repo.load(oid) // 副作用:读
(s2, evs) = transition(st, Approve) // 纯核心
_ <- repo.save(s2) // 副作用:写
_ <- evs.traverse_(events.publish) // 副作用:发事件
yield ()
工程要点:纯核心 + 副作用外壳的准则是**「计算与副作用分离在函数边界」**——核心只做输入到输出的变换(含状态转移的纯版本),读取与写回都在外壳。这样「状态机的一步」可以脱离数据库单独测试,甚至事件可回放重演。副作用外壳再薄一层业务逻辑都不要,核心再厚一层 IO 也不要。
4. 函数式领域建模:ADT、领域服务与规则
领域层是架构的心,函数式建模让规则「可验证」:
函数式领域建模三板斧:
□ ADT(代数数据类型):领域概念就是类型
- 求和类型(sealed trait):Order = Draft | Paid | Shipped
- 乘积类型(case class):Order(id, items, total)
□ 纯领域服务:规则 = 函数(入参领域类型,出参领域类型)
- def canShip(o: Order): Boolean
- 不接收 repo、不返回 IO——只描述规则
□ 不变量:类型保证 + 属性测试验证
建模规则:
□ 用类型表达非法状态不可表示(如 Negative qty 用子类型)
□ 规则集中在领域服务,不散落在用例层
□ 命令与事件:Command(意图)/ Event(事实)也是 ADT
import cats.data.NonEmptyList
sealed trait OrderStatus
case object Draft extends OrderStatus
case object Paid extends OrderStatus
case object Shipped extends OrderStatus
final case class OrderItem(sku: String, qty: Int, price: Money)
final case class Order(id: OrderId, items: NonEmptyList[OrderItem],
status: OrderStatus)
// 纯领域规则:只有已支付订单可发货
def canShip(o: Order): Boolean =
o.status match
case Paid => o.items.toList.forall(_.qty > 0)
case _ => false
工程要点:函数式领域建模的准则是**「概念即类型、规则即函数、不变量即属性」**——用 ADT 精确描述领域概念,让非法状态「不可表达」;业务规则写成纯函数(只依赖领域类型),集中在一个领域层。命令/事件也用 ADT 建模,为审计与事件溯源留好数据形态。领域层是零框架依赖的纯 Scala。
5. 依赖注入的函数式实现:参数传递与 Reader
函数式架构里依赖注入有三种姿态,从简到繁:
三种注入方式:
□ 参数传递(最简):把依赖作为函数参数
def flow(repo: OrderRepo)(id: OrderId): IO[Unit]
→ 显式、无魔法;缺点是参数会传很多层
□ Reader/R(ZIO):依赖进「环境类型」
ZIO[Env, E, A] / ReaderT[F, Env, A]
→ 依赖自动传递;ZLayer 组装
□ 类型类/代数(Tagless Final):依赖进「约束」
[F[_]: Monad](repo: OrderRepo[F])
→ 组合能力 + 依赖同时编码
选型:
□ 小系统:参数传递最清楚
□ 中型:Reader/环境(ZIO 的 R)
□ 需要多解释器:Tagless Final(约束注入)
□ 三者可混用:核心用参数,边界用环境
// 最简:参数传递(显式,测试零魔法)
def approveFlow(repo: OrderRepo)(oid: OrderId): IO[Unit] =
repo.load(oid).flatMap(st => repo.save(st.copy(status = Paid)))
// ZIO:依赖进环境类型,ZLayer 注入
val approveZIO: ZIO[OrderRepo, RepoError, Unit] =
for
repo <- ZIO.service[OrderRepo]
st <- repo.load(oid)
_ <- repo.save(st.copy(status = Paid))
yield ()
工程要点:函数式 DI 的准则是**「优先显式、环境次之、约束兜底」**——参数传递最朴素也最可测;需要自动传递时用 ZIO 的 R/ZLayer(依赖进类型,组装在入口);需要多解释器用 Tagless 约束。核心价值:依赖是「类型的一部分」,注入与替换都在类型系统内完成,不需要运行时容器。
6. 端口与适配器:数据库、HTTP 与消息
端口与适配器是「副作用外壳」的具体形态,落地细节决定架构是否成立:
端口设计:
□ 端口命名领域化:OrderRepo 而非 JdbcOrderRepository
□ 端口方法返回领域类型 + 领域错误,不泄漏 SQL/HTTP 类型
□ 端口粒度按「用例需要」设计,不为表 CRUD 设计
适配器实现:
□ DB 适配器:内部可以 JDBC/Doobie/Slick
→ 把技术异常映射成领域错误(RepoError)
□ HTTP 适配器:内部可以 http4s/Sttp/原生 HTTP
→ 把协议错误映射成领域错误
□ 消息适配器:Kafka/SQS 客户端包一层
边界纪律:
□ 适配器不写业务规则(决策归核心)
□ 适配器不依赖其它适配器(依赖方向向内)
□ 换实现 = 换一个适配器(测试替身同理)
import cats.effect.IO
// 领域错误:数据库层不泄漏
sealed trait RepoError
case class NotFound(id: OrderId) extends RepoError
case class Conflict(msg: String) extends RepoError
// 端口
trait OrderRepo:
def load(id: OrderId): IO[Either[RepoError, Order]]
def save(o: Order): IO[Either[RepoError, Unit]]
// 适配器:Postgres 实现(内部随便用,对外只有领域错误)
final class PostgresOrderRepo(ds: DataSource) extends OrderRepo:
def load(id: OrderId): IO[Either[RepoError, Order]] =
DBIO.query(id).attempt.map(_.left.map(e => Conflict(e.getMessage)))
工程要点:端口与适配器的纪律是**「端口领域化、适配器技术化、边界零泄漏」**——端口方法用领域术语和领域类型,适配器内部可以很技术(JDBC/HTTP),但对外只暴露领域错误。测试替身就是「内存适配器」,生产换库就是「换适配器」。任何「技术概念穿透到核心」都是架构腐化的信号。
7. 可测试性:解释器替换与属性测试
函数式架构把测试从「编排 mock」变成「验证纯函数 + 替换适配器」:
三层测试策略:
□ 领域层:纯函数直接测(输入输出对、属性测试)
- 不变量:订单总额 = 各项之和、发货后状态变更正确
- 生成随机领域对象(Gen)跑属性
□ 用例层:端口替身(内存 repo)注入
- 测试「编排逻辑」:load → 纯计算 → save 的调用序列
- 用日志/内存适配器断言行为
□ 适配器层:集成测试(真实 DB/HTTP,少量)
测试替身类型:
□ 内存适配器:Map 实现端口,行为等价
□ 日志适配器:记录调用序列供断言
□ 故障适配器:注入失败/超时,验证错误路径
import cats.effect.IO
import scala.collection.mutable
// 内存适配器(测试替身)
final class InMemoryOrderRepo extends OrderRepo:
val store = mutable.Map.empty[OrderId, Order]
def load(id: OrderId) = IO(store.get(id).toRight(NotFound(id)))
def save(o: Order) = IO { store(o.id) = o; Right(()) }
val repo: OrderRepo = new InMemoryOrderRepo
// 用例测试:不 mock、不装库,纯 Scala 断言
工程要点:可测试性的准则是**「核心测性质、用例测编排、适配器测集成」**——领域规则用属性测试覆盖不变量,用例层注入内存/日志适配器验证流程,真实基础设施只留少量集成测试。因为端口是领域化的接口,「换实现」就是测试的全部魔法,不再需要 mock 框架。
8. 错误建模:类型化错误与故障隔离
错误是架构边界的一部分——错误类型必须随边界建模:
错误分层:
□ 领域错误:业务规则失败(库存不足、状态非法)
→ ADT 枚举,核心可预知、可处理
□ 端口错误:技术失败(DB 超时、HTTP 500、消息丢失)
→ 适配器映射为领域错误,核心决定重试/降级
□ 缺陷(bug):程序错误 → 崩溃或全局监控,不进业务通道
故障隔离:
□ 边界处翻译错误:把技术异常转成领域错误
□ 用例层决定「重试、降级、熔断」——决策在核心
□ 事件总线:写命令与读查询错误处理不同(CQRS 视角)
□ 不可恢复错误尽快失败(fail fast),别静默吞
类型化错误的好处:
□ 编译器强制处理分支(sealed trait 穷尽 match)
□ 错误即文档:API 会怎么失败写在类型里
□ 测试可枚举:每个失败分支都有对应测试
import cats.effect.IO
// 错误跨边界翻译
def loadWithMapping(repo: OrderRepo)(id: OrderId): IO[Either[DomainError, Order]] =
repo.load(id).map {
case Left(RepoError.NotFound(i)) => Left(DomainError.OrderMissing(i))
case Left(RepoError.Conflict(m)) => Left(DomainError.Inconsistent(m))
case Right(o) => Right(o)
}
sealed trait DomainError
object DomainError:
case class OrderMissing(id: OrderId) extends DomainError
case class Inconsistent(msg: String) extends DomainError
工程要点:错误建模的准则是**「错误随边界翻译、类型化枚举、决策留核心」**——适配器把技术异常翻译成领域错误,核心用 ADT 穷尽处理,bug 走崩溃路径不污染业务通道。类型化错误让「失败模式」成为可穷举、可测试、可文档化的一等公民;重试/降级/熔断的决策必须由核心做,适配器只负责翻译不负责决策。
9. 迁移路线:从分层到函数式架构
已有分层架构(Controller/Service/Repository)怎么渐进改造:
渐进路线(五步):
① 提取纯领域:把 Service 里的业务规则抽成纯函数 + ADT
→ 不改变调用方式,先让规则可测
② 端口化:把 Repository/外部依赖抽象成端口 trait
→ Controller 依赖接口而非具体类
③ 反依赖:把 Service 从「调 Repository」改成「接收端口」
→ 依赖方向向内,测试替身可注入
④ 纯化用例:用例层用效果系统组合「读 → 纯算 → 写」
→ 副作用集中在入口
⑤ 替换测试:移除 mock,改用内存/日志适配器
→ 测试从「编排」变「验证」
迁移判断:
□ 先迁移「核心规则多、变更频繁」的模块(收益大)
□ 每步保持可编译、可测试(小步重构)
迁移陷阱:
□ 一把梭:全项目同时重构(风险爆炸)
□ 为纯而纯:把简单 CRUD 也抽象三层(过度设计)
□ 边界漏风:某个端口仍暴露技术类型
□ 测试没跟上:抽象了接口但测试还是 mock(无红利)
工程要点:迁移的准则是**「小步、收益优先、每步可编译」**——先从规则密集的模块提取纯领域,再端口化、反依赖、纯化用例、最后替换测试。简单 CRUD 不动,核心规则模块优先改造。每步都保持「能编译、能测试」,让重构在绿色测试保护下推进,而不是「停业式重建」。
10. 速查表与一句话记忆
| 问题 | 一句话答案 |
|---|---|
| 为什么函数式架构 | 可测试性 + 可推理性,核心复杂时收益最大 |
| 分层怎么排 | Domain → Application → Ports → Adapters |
| 依赖方向 | 内层不依赖外层,适配器依赖核心 |
| 纯核心是什么 | 计算与状态转移,零副作用 |
| 副作用在哪 | 外壳:DB/HTTP/消息,入口执行 |
| 领域怎么建模 | ADT + 纯领域函数 + 属性不变量 |
| DI 怎么做 | 参数传递 / Reader(ZIO) / Tagless 约束 |
| 端口纪律 | 领域化命名,零技术类型泄漏 |
| 测试怎么做 | 核心属性测试 + 内存适配器替身 |
| 怎么迁移 | 提纯领域 → 端口化 → 反依赖 → 纯化用例 |
一句话记忆:函数式架构 = 六边形分层(核心向内)+ 纯核心/副作用外壳(计算与 IO 分离)+ ADT 领域建模(非法状态不可表达)+ 函数式 DI(参数/环境/约束,类型即注入)+ 端口与适配器(技术可替换、零泄漏)+ 类型化错误(边界翻译、决策留核心)+ 渐进迁移(提纯→端口化→反依赖→纯化用例)——核心心法:让核心是纯函数、让外壳是可替换的、让依赖方向永远向内。
延伸阅读
- /scala-domain-modeling/ — ADT 与领域建模深入
- /scala-tagless-final/ — 多解释器与代数设计
- /scala-functional-effects/ — 效果系统实现外壳
- /scala-microservices-practice/ — 服务边界与分布式
- /scala-functional-error-handling/ — 类型化错误处理
- /scala-web-http-apps/ — HTTP 适配器实践
- 架构专题 — 架构模式与演进
- 分布式系统专题 — 边界与故障隔离
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。