Scala 领域建模实战:ADT、类型驱动设计与模块化架构

用 Scala 做领域建模的方法论:ADT(sealed trait + case class)建模领域对象、opaque type 值对象防混淆、类型驱动设计、错误建模(Either)、依赖注入(given/ZLayer)、六边形架构与状态机。

引言

领域建模决定代码的「形状」——模型烂,后续所有功能都别扭。Scala 的建模语言是类型:sealed trait 表达「互斥的分支」,case class 表达「不可变的值」,opaque type 隔离「语义不同的同值类型」,Either 表达「可能出错的计算」。本文按一条主线展开:从 ADT 建模领域对象,到值对象与类型驱动设计,再到错误建模、依赖注入、六边形架构与状态机,最后给出演进与重构成熟度模型。

前置:/scala-functional-programming/(Option/Either/不可变)、/scala-type-system/(泛型/类型类)、/scala-typelevel-programming/(类型级约束进阶)。


目录


1. 领域建模的 Scala 哲学:类型即契约

建模的核心问题:如何让「合法状态」被类型表达,让非法状态「写不出来」。

三个原则:

① 不可变优先:case class 默认不可变 → 共享安全
② 穷尽匹配:sealed trait 让 match 必须覆盖所有分支 → 加分支编译器提醒
③ 类型驱动:先定义类型,再写函数 → 类型是文档也是约束

建模 vs 状态编程的对比:

维度状态编程Scala 类型驱动
状态可变字段不可变值 + 转换
分支if/else 运行时sealed trait match 编译期穷尽
错误异常(隐式)Either/IO(显式)
非法状态运行时才炸编译期写不出

「写不出」的力量:接口拒绝非法输入,而不是「调用方保证」。

记忆:Scala 建模三原则——不可变优先、sealed trait 穷尽匹配、先类型后函数;目标是让非法状态编译期写不出来,而不是运行时兜底。


2. ADT 建模:sealed trait 与 case class 表达域

ADT(代数数据类型)= 和类型(sealed trait)+ 积类型(case class):

// 积类型:多个字段组合(「和」)
final case class User(id: UserId, name: String, email: Email)

// 和类型:互斥的分支(「或」)
sealed trait PaymentStatus
object PaymentStatus {
  case object Pending      extends PaymentStatus
  case class  Paid(amount: BigDecimal) extends PaymentStatus
  case class  Failed(reason: String)   extends PaymentStatus
}

建模原则:

① 领域名词 → case class;领域动词/变化 → sealed trait 分支
② 别用布尔代替状态(isPaid + isFailed 会互相矛盾)
③ match 要穷尽(sealed 保证),新增分支编译器提醒

反模式:一堆可空字段的「上帝对象」→ 用 ADT 拆成分支。

// 坏:可空字段表达状态
case class Order(id: Long, paidAt: Option[Long], cancelledAt: Option[Long])

// 好:状态分支表达状态
sealed trait OrderStatus
case object Pending   extends OrderStatus
case class  Paid(t: Long) extends OrderStatus
case class  Cancelled(t: Long) extends OrderStatus

记忆:ADT = case class 积 + sealed trait 和;名词做 case class、动词/变化做分支;别用布尔/可空字段表达状态,穷尽匹配是 ADT 的免费安全网。


3. 值对象与 opaque type:防混淆的类型隔离

值对象 = 语义唯一但值相同(金额、ID、邮箱)——用类型隔离,防混淆与非法值。

opaque type:

object domain {
  opaque type UserId = Long
  object UserId { def apply(id: Long): UserId = id }
}
import domain.UserId

def getUser(id: UserId): Option[User] = ...   // 只有 UserId 能进来

// val bad: Long = userId   // 编译错误:UserId 不能当 Long 用

opaque type 的收益:

- 编译期防混淆(OrderId ≠ ProductId)
- 可附加合法值校验(构造时检查)
- 运行时零开销(就是底层类型)

校验封装:

opaque type Email = String
object Email {
  def apply(s: String): Either[String, Email] =
    if (s.contains("@")) Right(s) else Left("非法邮箱")
}

何时用:跨模块边界、易混淆的同值类型、有合法性约束的领域值。

记忆:opaque type 把 Long/String 包装成语义类型——编译期防混淆、可做校验、运行时零开销;ID、邮箱、金额这类领域值优先用。


4. 错误建模:Either、错误类型与失败域

错误的两种哲学:

异常:隐式、调用方不知会发生什么
Either:显式、错误是返回值的一部分

函数式错误建模:

// 显式失败域:错误写进类型
sealed trait PaymentError
case object InsufficientFunds extends PaymentError
case class  NetworkError(msg: String) extends PaymentError

def pay(amount: BigDecimal): Either[PaymentError, Receipt] = ...

用 Either 的收益:

- 调用方被迫处理(编译器提示)
- 错误类型可穷尽匹配
- 组合:flatMap 短路、validation 聚合

多个错误聚合(应用式):Cats Validated/NonEmptyChain 收集所有错误而非第一个:

import cats.data.Validated
import cats.implicits._

case class Signup(name: String, email: String, pwd: String)
def validate(s: Signup): Validated[NonEmptyChain[String], Signup] =
  (validateName(s.name), validateEmail(s.email), validatePwd(s.pwd)).mapN(Signup.apply)

错误边界策略:核心域用 Either 显式失败;系统边界(IO)用效果系统把异常转失败域。

记忆:错误建模用 Either 显式失败域——错误类型穷尽匹配、调用方被迫处理;多错误聚合用 Validated/NonEmptyChain;域内 Either、IO 边界把异常转失败域。


5. 依赖注入:given、ZLayer 与纯函数边界

依赖注入在函数式 Scala 里=「参数化 + 编译期解析」。

纯函数依赖:直接参数传依赖:

def createUser(repo: UserRepo, now: Clock): Either[Error, User] = ...
// 测试:传 mock 即可,无需框架

given/using 隐式注入:

trait UserRepo { def find(id: UserId): Option[User] }
object UserRepo {
  given live: UserRepo = new JdbcUserRepo(...)
}

def getUser(id: UserId)(using repo: UserRepo): Option[User] =
  repo.find(id)
// 调用:自动找 given;测试可覆盖 given

ZLayer(ZIO 生态):可组合、可替换的依赖层:

val app: ZIO[UserRepo & EmailService, Error, Unit] = ...
val layers = UserRepo.live >>> EmailService.live   // 组合依赖

注入选型:

- 简单场景:构造函数/参数传依赖
- 中型项目:given/using
- ZIO 生态:ZLayer(可组合、可测试替换)

铁律:依赖从边界注入,不要在函数内部 new 全局服务。

记忆:依赖注入三选——简单参数传、given/using 编译期解析、ZIO 用 ZLayer 可组合;依赖从边界注入、测试换 mock,别在函数内 new 全局服务。


6. 六边形架构:ports 与 adapters

六边形架构(Ports & Adapters):核心域(纯逻辑)与外部世界(DB/HTTP/IO)解耦。

          ┌─────────────────────┐
外部输入 → │   Port(接口/ADT)   │
HTTP/RPC  → │   核心域(纯函数)    │ → Port → DB/MQ
          └─────────────────────┘
          依赖方向:外 → 内(domain 不依赖外部库)

落地结构:

domain/     ← 纯领域(ADT、逻辑、ports 接口)
application/← 用例/服务(编排)
infrastructure/ ← adapters(JdbcRepo、HttpClient)

关键规则:

① 核心域零外部依赖(只 Scala 标准库)
② 外部访问走 Port 接口
③ adapters 负责转换(领域类型 ↔ 传输类型)

示例 Port:

// domain/ports
trait UserPort {
  def find(id: UserId): F[Option[User]]   // F 是效果抽象
}

// infrastructure/adapters
class JdbcUserPort(using db: Db) extends UserPort {
  def find(id: UserId) = ...
}

记忆:六边形架构 = 核心纯域 + Port 接口 + Adapter 实现;依赖方向外→内,domain 零外部依赖,外部走 Port,适配器负责类型转换。


7. 状态建模:状态机、不变式与阶段类型

领域常有「只能按顺序经历的状态」——用状态机建模,让非法转移编译期拦截。

类型化状态机(阶段类型):

sealed trait OrderPhase
final class Placed   extends OrderPhase
final class Shipped  extends OrderPhase
final class Delivered extends OrderPhase

final class Order[S <: OrderPhase] private () {
  def ship(using ev: S =:= Placed): Order[Shipped] = new Order
  def deliver(using ev: S =:= Shipped): Order[Delivered] = new Order
}

不变式维护:

- 在构造时校验(opaque type 或 require)
- 转换函数返回 Either 而非静默修改
- 不可变 + 显式转换 = 不变式天然成立

布尔状态的反模式:

isPlaced + isShipped + isDelivered 三个布尔可同时 true → 用阶段类型

「状态只能正确演进」:类型化状态机把非法操作从运行时报错变成编译期拒。

记忆:状态建模用阶段类型表达顺序——placed/shipped/delivered 各是一类,非法转移编译期拦截;不变式在构造校验、转换返回 Either、不可变保证成立。


8. 与外部边界:repository、事件与 ADT 落地

领域对象要跨过 IO 边界(DB/HTTP)——这是建模最容易崩的地方。

Repository 模式:

trait OrderRepo {
  def save(o: Order): F[Unit]
  def find(id: OrderId): F[Option[Order]]
}

ADT 持久化:

- 领域 ADT 别直接进表:用映射层转存储模型
- sealed trait 用 discriminator 列(type/kind)
- case object 用枚举列、case class 用字段列

领域事件:变更发事件(事件溯源/CDC):

sealed trait OrderEvent
case class OrderPlaced(orderId: OrderId, at: Long) extends OrderEvent

JSON/HTTP 边界:circe 的 Encoder/Decoder 用 ADT 自动派生(见 /scala-web-http-apps/)。

边界转换规则:领域类型与传输/存储类型分开,adapter 负责映射。

记忆:外部边界走 Repository/Port;ADT 持久化用 discriminator 列、映射层隔离;变更发领域事件;circe 自动派生 ADT 编解码——边界转换都交给 adapter。


9. 演进与重构:模型的成熟度模型

领域模型是演进的,不是一次成型的。成熟度递进:

L0 上帝对象:一堆可空字段、布尔状态
L1 ADT 基础:sealed trait + case class 拆分支
L2 类型隔离:opaque type 值对象、校验封装
L3 显式错误:Either/Validated 失败域
L4 架构约束:六边形 + 依赖注入 + 状态机

演进信号(什么时候升级):

- 布尔状态互相矛盾 → 上 ADT 分支
- 同类值频繁传错 → 上 opaque type
- match 到处空口 if 判类型 → 上 sealed 穷尽
- 依赖散乱 new → 上注入/六边形

重构成熟度的「代价」:类型越多编译期越严,但编码成本上升——按领域复杂度取平衡,别过度建模。

记忆:模型成熟度 L0→L4 递进——上帝对象→ADT→类型隔离→显式错误→架构约束;信号驱动升级(布尔矛盾/传错值/if 判类型),按复杂度取平衡别过度建模。


10. 速查表与一句话记忆

建模目标手段
领域对象case class(积)
互斥分支sealed trait(和)
值对象/防混淆opaque type
显式失败Either / Validated
依赖注入参数 / given / ZLayer
架构六边形 ports & adapters
状态顺序阶段类型 + =:=
外部边界Repository / 事件

一句话记忆:Scala 领域建模 = 类型即契约——case class 建模领域对象、sealed trait 表达互斥分支(穷尽匹配免费安全网)、opaque type 隔离值对象防混淆、Either/Validated 显式失败域、依赖从边界注入(参数/given/ZLayer)、六边形架构把核心纯域与外部世界解耦、阶段类型让状态只能正确演进;成熟度 L0→L4 信号驱动升级,按复杂度取平衡——把领域知识写进类型,让非法状态编译期就写不出来。


延伸阅读

  • /scala-functional-programming/ — Option/Either 与不可变基础
  • /scala-type-system/ — 泛型/类型类支撑建模
  • /scala-typelevel-programming/ — 类型级约束的状态机进阶
  • /scala-functional-effects/ — 效果系统与依赖层
  • /scala-database-access/ — Repository 与持久化映射
  • [[distributed-systems]] — 领域事件的分布式落地
  • [[typescript]] — 类型驱动设计的跨语言对照

继续阅读

探索更多技术文章

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

全部文章 返回首页

「scala」更多文章

  1. 纯函数式效果系统实战:Cats Effect IO 与 ZIO
  2. Scala.js 与 Scala Native:跨平台编译、互操作与工程实践
  3. Scala 类型级编程实战:Shapeless、Match Types 与编译期计算