函数式架构:六边形设计、纯核心与副作用外壳

系统讲解 Scala 函数式架构:架构动机(可测试性与可推理性)、洋葱/六边形架构与依赖方向、纯核心与副作用外壳、函数式领域建模(ADT/领域服务/规则引擎)、依赖注入的函数式实现(参数传递/Reader/ZLayer)、端口与适配器(数据库/HTTP/消息)、可测试性(解释器替换/属性测试)、类型化错误与故障隔离、以及从传统分层到函数式架构的迁移路线。

「代码能跑」和「代码能改」是两回事——架构的职责是让核心业务不被技术实现绑架。函数式架构把这件事做到极致:核心是纯函数(输入输出可推演),副作用全部推到外壳(数据库、HTTP、消息都是可替换的适配器)。这不是「用函数式语法写分层架构」,而是「让依赖方向向内、让核心可解释、让替换成本趋零」。本文从动机讲起:六边形/洋葱架构的边界与依赖方向、函数式领域建模、依赖注入的函数式实现、端口与适配器、可测试性与错误隔离,最后给出从传统分层迁移的路线。

前置:/scala-domain-modeling/(领域建模与 ADT)、/scala-functional-programming/(纯函数与不可变)、/scala-testing-practice/(属性测试)、/scala-tagless-final/(代数与多解释器)。

目录

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 适配器实践
  • 架构专题 — 架构模式与演进
  • 分布式系统专题 — 边界与故障隔离

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「scala」更多文章

  1. Scala Native 与 GraalVM:AOT 编译、互操作与部署
  2. Akka Streams 与响应式流:图 DSL、背压与流式实战
  3. Tagless Final 与代数式设计:类型类、DSL 与多解释器