软件设计到底在设计什么:模型、边界与演化规则
「用 Spring Boot,分 Controller、Service、DAO 三层,关键地方用几个设计模式」,这是很多项目的「设计文档」。它描述的其实是技术选型和代码组织模板。换一个业务,这份文档几乎不用改,因为它没有回答真正的设计问题:这个系统里有哪些稳定的概念、它们之间的边界在哪里,以及需求变化时哪些地方允许变、哪些地方不该动。
设计的目标是控制变化的影响范围:一次需求变化最终要改几个地方、会不会波及无关的功能,是衡量设计好坏最直接的尺子。为此,设计要产出三样东西:业务模型(有哪些概念、它们能做什么)、边界与契约(模块之间知道什么、传递什么)、演化规则(依赖方向、扩展点放在哪里)。框架和分层模板是在这之后才做的决定,同一个模型可以用不同的框架实现,而一个错误的模型换什么框架都救不回来。
这是设计原则与模式专题的第一篇,后面的关注点分离、原则和模式都建立在这个坐标系上。
一、设计的对象:模型、边界与演化规则
可以把设计粗分为模型和规范:模型回答「系统里有什么」,规范回答「这些东西之间必须遵守什么约束」。本文把规范再拆成两部分,边界与契约、演化规则,因为在服务端工程里,这两类约束出问题的方式不同。
1.1 模型:业务概念和它们的行为
模型不是数据库表的字段集合。以订单为例,数据视角的订单是一行记录,有状态、金额、创建时间;模型视角的订单还包括它的行为和规则:
- 只有「待支付」的订单可以取消;
- 已发货的订单只能走退货流程;
- 金额由商品价格、优惠和运费计算得出,不能被随意设置。
模型清楚的标志是:业务人员描述规则时用的词,能在代码里找到对应的类或方法。代码里只有 OrderDO、OrderDTO、OrderVO 和一堆 setStatus,说明模型还停留在数据层面,规则散落在各个 Service 里。
1.2 边界与契约:谁知道什么
边界决定一个模块对外暴露什么、对内隐藏什么。契约则是跨越边界时双方的约定:传什么数据、什么情况下失败、失败时返回什么。
契约不只是 Java 的 interface。HTTP 接口、消息格式、数据库表结构、配置文件、命令行参数,都是契约。它们有一个共同点:一旦被别人依赖,修改就要付出协调成本。所以边界要画在「两侧会独立变化」的地方,并且尽量让跨边界传递的东西少而稳定。
1.3 演化规则:变化应该落在哪里
演化规则回答「以后怎么改」:
- 依赖方向:业务规则不依赖数据库、HTTP、消息中间件,而是由技术实现去适配业务定义的接口。依赖倒置原则讲的就是这件事,见 设计原则。
- 扩展点:新增一种支付渠道时,应该是「新增一个实现」,而不是「修改三个
switch」。 - 不该动的地方:核心模型的变化应该是少见的,一旦频繁修改,说明模型没抓住稳定的概念。
二、一个反例:一个对象四处共用
很多项目里,一个 Order 类同时承担四个角色:
@Entity // ORM 实体
@Table(name = "orders")
@JsonInclude(JsonInclude.Include.NON_NULL) // HTTP 响应
public class Order { // 同时还作为 Kafka 消息体、业务对象
@Id private Long id;
private String status;
private BigDecimal amount;
@JsonIgnore private String internalRemark;
// getter、setter
}写起来很省事,一个类走天下。问题出在变化的时候:
| 变化 | 影响范围 |
|---|---|
| 接口要多返回一个展示字段 | 实体多了一个非持久化字段,要加 @Transient;消息体也多了这个字段,下游要确认兼容 |
| 表结构拆分,金额挪到另一张表 | 接口返回的 JSON 结构跟着变,调用方被迫升级 |
| 消息消费方要求字段改名 | 数据库列名或接口字段被迫跟着改,或者堆上各种注解做映射 |
| 业务要限制「只有待支付订单可以改金额」 | 任何拿到对象的地方都能调 setAmount,规则无处安放 |
每一端的变化都会扩散到另外三端,这就是边界缺失。改进的方向是让各端使用自己的对象,在边界上转换:接口用请求和响应对象,持久化用行对象,消息用事件对象,业务规则写在领域模型里。
这不是说每个项目都要四套对象。只有当这几端确实会独立变化时,拆开的收益才大于转换代码的成本。一个只有内部使用、没有外部消费方的小服务,共用一个对象完全可以接受。
三、设计与技术选型、分层模板的区别
| 回答的问题 | 例子 | 换一个业务还适用吗 | |
|---|---|---|---|
| 技术选型 | 用什么工具 | Spring Boot、MySQL、Kafka | 基本适用 |
| 分层模板 | 代码放在哪 | Controller、Service、Repository | 基本适用 |
| 软件设计 | 概念、边界和变化规则是什么 | 订单状态流转规则、计价与支付的边界、渠道扩展方式 | 不适用,必须重新做 |
一个快速的检验方法:把设计文档里的业务名词全部删掉,看它还剩多少内容。如果剩下的部分几乎完整,说明这份文档讲的主要是技术选型和分层模板。
分层本身有价值,它把「和框架打交道的代码」与「业务代码」分开。但三层结构并不保证关注点已经分离:一个 3,000 行的 OrderService 同样可以把风控、计价、通知、持久化全部混在一起。怎样拆开这些关注点,是下一篇 关注点分离与可测试性 的内容。
四、什么时候不必认真设计
设计有成本:思考时间、抽象带来的间接层、团队的沟通。下面这些情况,直接写出能工作的代码通常更划算:
- 一次性脚本和数据修复:运行一次就丢弃;
- 生命周期很短的原型:目的是验证想法,验证完会重写;
- 需求规模很小且稳定:只有一两个功能、一个维护者,也看不到扩展方向。
判断标准是预期的变化成本。一段代码预计会被修改很多次、被很多人修改、被很多地方依赖,设计的投入就会反复得到回报;反之,简单直接就是最好的设计。
简单也要简单得有意识:知道哪里是有意没有设计的,以后需要时从哪里开始拆。反过来,设计也不需要一次做对,第一版只对当前已知的变化负责;没有对应变化的抽象层,只会增加阅读和修改的成本。
五、检查清单
拿到一个新需求或一份设计文档时,最实用的检验是拿一个真实的变化请求去推演,看它最终要改几个地方。逐条回答:
- 稳定的概念是什么? 能不能用业务的语言说出三到五个核心概念,以及每个概念的主要规则。
- 变化来自哪里? 哪些部分由产品、运营、风控、合作方推动变化,它们的变化频率各是多少。
- 跨边界传递什么? 每个对外接口、每条消息、每张共享表,传的是不是对方真正需要的最小信息。
- 依赖方向对不对? 业务规则代码里有没有出现数据库、HTTP 客户端、消息中间件的类型。
- 替换一个技术组件要改哪里? 比如把 MySQL 换成另一种存储、把一个支付渠道换掉,改动能不能被限制在一个模块里。
- 什么东西不该变? 如果核心模型每个迭代都在大改,先怀疑模型,而不是继续加补丁。
参考资料
- David L. Parnas:On the Criteria To Be Used in Decomposing Systems into Modules(1972)
- Martin Fowler:Is Design Dead?
- John Ousterhout,《A Philosophy of Software Design》
- Eric Evans,《Domain-Driven Design: Tackling Complexity in the Heart of Software》