引言
领域建模决定代码的「形状」——模型烂,后续所有功能都别扭。Scala 的建模语言是类型:sealed trait 表达「互斥的分支」,case class 表达「不可变的值」,opaque type 隔离「语义不同的同值类型」,Either 表达「可能出错的计算」。本文按一条主线展开:从 ADT 建模领域对象,到值对象与类型驱动设计,再到错误建模、依赖注入、六边形架构与状态机,最后给出演进与重构成熟度模型。
前置:/scala-functional-programming/(Option/Either/不可变)、/scala-type-system/(泛型/类型类)、/scala-typelevel-programming/(类型级约束进阶)。
目录
- 1. 领域建模的 Scala 哲学:类型即契约
- 2. ADT 建模:sealed trait 与 case class 表达域
- 3. 值对象与 opaque type:防混淆的类型隔离
- 4. 错误建模:Either、错误类型与失败域
- 5. 依赖注入:given、ZLayer 与纯函数边界
- 6. 六边形架构:ports 与 adapters
- 7. 状态建模:状态机、不变式与阶段类型
- 8. 与外部边界:repository、事件与 ADT 落地
- 9. 演进与重构:模型的成熟度模型
- 10. 速查表与一句话记忆
- 延伸阅读
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]] — 类型驱动设计的跨语言对照
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。