软件设计到底在设计什么:模型、边界与演化规则
「用 Spring Boot,分 Controller、Service、DAO 三层,关键地方用几个设计模式」,这是很多项目的「设计文档」。它描述的其实是技术选型和代码组织模板。换一个业务,这份文档几乎不用改,因为它没有回答真正的设计问题:这个系统里有哪些稳定的概念、它们之间的边界在哪里,以及需求变化时哪些地方允许变、哪些地方不该动。
这是设计原则与模式系列的第一篇,先把「设计的对象」说清楚,后面的关注点分离、原则和模式都建立在这个坐标系上。
一、先说结论
- 设计的目标是控制变化的影响范围。一次需求变化最终要改几个地方、会不会波及无关功能,是衡量设计好坏最直接的尺子。
- 设计的产物有三样:业务模型(有哪些概念、它们能做什么)、边界与契约(模块之间知道什么、传递什么)、演化规则(依赖方向、扩展点放在哪里)。
- 技术选型和分层模板不是设计,它们是设计的一种落地方式。同一个模型可以用不同框架实现,而一个错误的模型换什么框架都救不回来。
- 判断设计是否清楚,可以问四个问题:稳定的概念是什么?变化来自哪里?跨边界传递什么?替换一个技术组件要改哪里?
- 不是所有程序都需要认真设计:一次性脚本、寿命很短的内部工具,直接写出来往往就是最好的选择。设计的价值随需求规模和生命周期一起增长。
二、设计的对象:模型、边界与演化规则
之前学习的一些教程里有一句话概括得很准:「软件设计,应该包括模型和规范。」模型回答「系统里有什么」,规范回答「这些东西之间必须遵守什么约束」。本文把后者拆成两部分,边界与契约、演化规则,因为在服务端工程里,这两类约束出问题的方式不同。
2.1 模型:业务概念和它们的行为
模型不是数据库表的字段集合。以订单为例,数据视角的订单是一行记录,有状态、金额、创建时间;模型视角的订单还包括它的行为和规则:
- 只有「待支付」的订单可以取消;
- 已发货的订单只能走退货流程;
- 金额由商品价格、优惠和运费计算得出,不能被随意设置。
模型清楚的标志是:业务人员描述规则时用的词,能在代码里找到对应的类或方法。代码里只有 OrderDO、OrderDTO、OrderVO 和一堆 setStatus,说明模型还停留在数据层面,规则散落在各个 Service 里。
2.2 边界与契约:谁知道什么
边界决定一个模块对外暴露什么、对内隐藏什么。契约则是跨越边界时双方的约定:传什么数据、什么情况下失败、失败时返回什么。
契约不只是 Java 的 interface。HTTP 接口、消息格式、数据库表结构、配置文件、命令行参数,都是契约。它们有一个共同点:一旦被别人依赖,修改就要付出协调成本。所以边界要画在「两侧会独立变化」的地方,并且尽量让跨边界传递的东西少而稳定。
2.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 换成另一种存储、把一个支付渠道换掉,改动能不能被限制在一个模块里。
- 什么东西不该变? 如果核心模型每个迭代都在大改,先怀疑模型,而不是继续加补丁。
七、常见误区
- 「设计就是选框架、画分层」:框架和分层是实现手段,它们换一个业务依然适用,说明没有回答业务相关的问题。
- 「设计要一次做对」:设计是随需求演化的,第一版只需要对当前已知的变化负责。
- 「接口就是 Java interface」:HTTP 接口、消息格式、表结构都是契约,而且它们的修改成本往往更高。
- 「多写几层抽象总没错」:没有对应变化的抽象只会增加阅读和修改的成本。
- 「小项目不需要设计」:规模小的时候可以简单,但要简单得有意识:知道哪里是有意没设计的,以后需要时从哪里开始拆。
小结
软件设计处理的是变化:用模型抓住业务里稳定的概念,用边界和契约控制变化的传播,用演化规则约定未来的变化落在哪里。技术选型和分层模板是在这之后才需要做的决定。判断一个设计好不好,最实用的方法是拿一个真实的变化请求去推演,看它最终要改几个地方。
参考资料
- 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》