Skip to content

软件设计到底在设计什么:模型、边界与演化规则 ​

「用 Spring Boot,分 Controller、Service、DAO 三层,关键地方用几个设计模式」,这是很多项目的「设计文档」。它描述的其实是技术选型和代码组织模板。换一个业务,这份文档几乎不用改,因为它没有回答真正的设计问题:这个系统里有哪些稳定的概念、它们之间的边界在哪里,以及需求变化时哪些地方允许变、哪些地方不该动。

这是设计原则与模式系列的第一篇,先把「设计的对象」说清楚,后面的关注点分离、原则和模式都建立在这个坐标系上。

一、先说结论 ​

  • 设计的目标是控制变化的影响范围。一次需求变化最终要改几个地方、会不会波及无关功能,是衡量设计好坏最直接的尺子。
  • 设计的产物有三样:业务模型(有哪些概念、它们能做什么)、边界与契约(模块之间知道什么、传递什么)、演化规则(依赖方向、扩展点放在哪里)。
  • 技术选型和分层模板不是设计,它们是设计的一种落地方式。同一个模型可以用不同框架实现,而一个错误的模型换什么框架都救不回来。
  • 判断设计是否清楚,可以问四个问题:稳定的概念是什么?变化来自哪里?跨边界传递什么?替换一个技术组件要改哪里?
  • 不是所有程序都需要认真设计:一次性脚本、寿命很短的内部工具,直接写出来往往就是最好的选择。设计的价值随需求规模和生命周期一起增长。

二、设计的对象:模型、边界与演化规则 ​

之前学习的一些教程里有一句话概括得很准:「软件设计,应该包括模型和规范。」模型回答「系统里有什么」,规范回答「这些东西之间必须遵守什么约束」。本文把后者拆成两部分,边界与契约、演化规则,因为在服务端工程里,这两类约束出问题的方式不同。

需求变化新增支付渠道改用新的存储调整退款流程对外接口加字段设计产物模型业务概念与它们的行为边界与契约谁知道什么、跨边界传什么演化规则依赖方向、扩展点放在哪里实现Controller、Service、Repository、框架与中间件:只是模型和边界的一种落地方式
图 1 · 设计的产物不是框架清单,而是三样东西:业务模型、模块边界与契约、演化规则;它们决定一次需求变化最终要改动多少实现

2.1 模型:业务概念和它们的行为 ​

模型不是数据库表的字段集合。以订单为例,数据视角的订单是一行记录,有状态、金额、创建时间;模型视角的订单还包括它的行为和规则:

  • 只有「待支付」的订单可以取消;
  • 已发货的订单只能走退货流程;
  • 金额由商品价格、优惠和运费计算得出,不能被随意设置。

模型清楚的标志是:业务人员描述规则时用的词,能在代码里找到对应的类或方法。代码里只有 OrderDO、OrderDTO、OrderVO 和一堆 setStatus,说明模型还停留在数据层面,规则散落在各个 Service 里。

2.2 边界与契约:谁知道什么 ​

边界决定一个模块对外暴露什么、对内隐藏什么。契约则是跨越边界时双方的约定:传什么数据、什么情况下失败、失败时返回什么。

契约不只是 Java 的 interface。HTTP 接口、消息格式、数据库表结构、配置文件、命令行参数,都是契约。它们有一个共同点:一旦被别人依赖,修改就要付出协调成本。所以边界要画在「两侧会独立变化」的地方,并且尽量让跨边界传递的东西少而稳定。

2.3 演化规则:变化应该落在哪里 ​

演化规则回答「以后怎么改」:

  • 依赖方向:业务规则不依赖数据库、HTTP、消息中间件,而是由技术实现去适配业务定义的接口。依赖倒置原则讲的就是这件事,见 设计原则。
  • 扩展点:新增一种支付渠道时,应该是「新增一个实现」,而不是「修改三个 switch」。
  • 不该动的地方:核心模型的变化应该是少见的,一旦频繁修改,说明模型没抓住稳定的概念。

三、一个反例:一个对象四处共用 ​

很多项目里,一个 Order 类同时承担四个角色:

java
@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
}

写起来很省事,一个类走天下。问题出在变化的时候:

共享一个对象Order四方共用HTTP 接口数据库表Kafka 消息业务规则加一个接口字段 → 表、消息、规则都被波及边界上转换Order领域模型接口请求对象表行对象消息事件每端的变化停在自己的转换代码里代价:多了几个类和转换代码。只有当各端确实会独立变化时,这笔代价才值得付小型内部工具、生命周期很短的程序,共用一个对象往往是更好的选择
图 2 · 同一个 Order 类同时充当接口 DTO、ORM 实体、消息体和业务对象时,任何一端的变化都会扩散到另外三端;各端使用自己的对象,在边界上转换,变化就停在边界内
变化影响范围
接口要多返回一个展示字段实体多了一个非持久化字段,要加 @Transient;消息体也多了这个字段,下游要确认兼容
表结构拆分,金额挪到另一张表接口返回的 JSON 结构跟着变,调用方被迫升级
消息消费方要求字段改名数据库列名或接口字段被迫跟着改,或者堆上各种注解做映射
业务要限制「只有待支付订单可以改金额」任何拿到对象的地方都能调 setAmount,规则无处安放

每一端的变化都会扩散到另外三端,这就是边界缺失。改进的方向是让各端使用自己的对象,在边界上转换:接口用请求和响应对象,持久化用行对象,消息用事件对象,业务规则写在领域模型里。

这不是说每个项目都要四套对象。只有当这几端确实会独立变化时,拆开的收益才大于转换代码的成本。一个只有内部使用、没有外部消费方的小服务,共用一个对象完全可以接受。

四、设计与技术选型、分层模板的区别 ​

回答的问题例子换一个业务还适用吗
技术选型用什么工具Spring Boot、MySQL、Kafka基本适用
分层模板代码放在哪Controller、Service、Repository基本适用
软件设计概念、边界和变化规则是什么订单状态流转规则、计价与支付的边界、渠道扩展方式不适用,必须重新做

一个快速的检验方法:把设计文档里的业务名词全部删掉,看它还剩多少内容。如果剩下的部分几乎完整,说明这份文档讲的主要是技术选型和分层模板。

分层本身有价值,它把「和框架打交道的代码」与「业务代码」分开。但三层结构并不保证关注点已经分离:一个 3,000 行的 OrderService 同样可以把风控、计价、通知、持久化全部混在一起。怎样拆开这些关注点,是下一篇 关注点分离与可测试性 的内容。

五、什么时候不必认真设计 ​

设计有成本:思考时间、抽象带来的间接层、团队的沟通。下面这些情况,直接写出能工作的代码通常更划算:

  • 一次性脚本和数据修复:运行一次就丢弃;
  • 生命周期很短的原型:目的是验证想法,验证完会重写;
  • 需求规模很小且稳定:只有一两个功能、一个维护者,也看不到扩展方向。

判断标准是预期的变化成本。一段代码预计会被修改很多次、被很多人修改、被很多地方依赖,设计的投入就会反复得到回报;反之,简单直接就是最好的设计。

六、检查清单 ​

拿到一个新需求或一份设计文档时,逐条回答:

  1. 稳定的概念是什么? 能不能用业务的语言说出三到五个核心概念,以及每个概念的主要规则。
  2. 变化来自哪里? 哪些部分由产品、运营、风控、合作方推动变化,它们的变化频率各是多少。
  3. 跨边界传递什么? 每个对外接口、每条消息、每张共享表,传的是不是对方真正需要的最小信息。
  4. 依赖方向对不对? 业务规则代码里有没有出现数据库、HTTP 客户端、消息中间件的类型。
  5. 替换一个技术组件要改哪里? 比如把 MySQL 换成另一种存储、把一个支付渠道换掉,改动能不能被限制在一个模块里。
  6. 什么东西不该变? 如果核心模型每个迭代都在大改,先怀疑模型,而不是继续加补丁。

七、常见误区 ​

  • 「设计就是选框架、画分层」:框架和分层是实现手段,它们换一个业务依然适用,说明没有回答业务相关的问题。
  • 「设计要一次做对」:设计是随需求演化的,第一版只需要对当前已知的变化负责。
  • 「接口就是 Java interface」:HTTP 接口、消息格式、表结构都是契约,而且它们的修改成本往往更高。
  • 「多写几层抽象总没错」:没有对应变化的抽象只会增加阅读和修改的成本。
  • 「小项目不需要设计」:规模小的时候可以简单,但要简单得有意识:知道哪里是有意没设计的,以后需要时从哪里开始拆。

小结 ​

软件设计处理的是变化:用模型抓住业务里稳定的概念,用边界和契约控制变化的传播,用演化规则约定未来的变化落在哪里。技术选型和分层模板是在这之后才需要做的决定。判断一个设计好不好,最实用的方法是拿一个真实的变化请求去推演,看它最终要改几个地方。


参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权