你有没有见过这样的代码库:业务逻辑散落在 Controller 和 Service 里,订单状态被几十个if拼出来,一个“小需求”要同时改动五个服务,改完之后还要担心线上会出问题。团队没有停下来说“设计有问题”,而是继续引入新框架、微服务、消息队列,以为技术栈升级了,代码就会变好。结果系统变得更慢、更难测、更不敢动。
这时候,一个有点刺耳的问题值得认真问一次:我们是不是已经忘了怎么做设计?
这篇文章想聊的重点不是某个框架的用法,也不是某种架构的“银弹”,而是软件研发中最基础也最容易被跳过的一环:设计。我会先分析为什么现在很多团队并非能力不够,而是设计缺位;再讲透边界、接口、状态这三个设计的核心概念;然后用一个订单模块作为完整案例,从事件流、状态机、接口定义到存储模型,给出一套可以直接用到项目里的设计流程。最后还会整理常见误区和实践建议。
读完这篇文章,你至少能收获三个判断标准:什么时候该画边界,什么时候该定义接口,什么时候该停下来想状态模型。它们比任何一款新工具都更能决定项目的长期命运。
1. 真正要解决的问题:不是技术不够新,而是设计缺位
很多系统的复杂度,并不是来自业务难,而是来自“没设计就上线”。需求评审结束后,开发同学直接打开编辑器开始写实现,这几乎是当前最常见的研发方式。MVP 阶段这么做没有太大问题,但当业务量涨起来、团队从两三个人变成十几个人的时候,问题就会集中爆发。
这类系统的典型症状很一致:新成员看代码要花很长时间才能搞清楚数据从哪来、往哪去;订单状态在多个地方被修改,导致统计口径对不上;底层表结构被上层接口直接透传,一个字段重命名要牵连所有调用方;重构时没人敢碰核心模块,因为不知道哪个隐藏依赖会被破坏。
这里面真正缺的,不是编码能力,也不是测试覆盖,而是问题发生之前的设计决策。设计不是画几张 UML 图交差,也不是必须做重型领域建模,而是在动手写代码之前,明确了模块之间的边界、数据如何流转、状态如何迁移、哪些逻辑必须收敛到一起。设计本质上是“让未来的修改成本可控”的一组决策。
如果你正在带一个已经出现腐化迹象的项目,或者你正在接手一个没人说得清楚全局的系统,这篇文章会非常有用。即便你只负责一个小模块,也可以把文章里的方法论缩小到一个功能内部去用——设计能力从来不是架构师的专利。
2. 设计的三个基础概念:边界、接口、状态
我见过很多项目的设计文档,画的架构图非常漂亮,分层、分包、微服务样样齐全。但评审的时候一旦被问到底层细节,往往答不上来。原因是大多数讨论都停留在“盒子与连线”层面,而设计真正要回答的是三个更具体的问题。
第一个问题是边界。边界决定了“什么东西属于谁”。经典的分层架构里,Controller 不应该直接操作数据库查询;订单服务不应该去修改支付记录的金额;数据库表结构不应该在不经过领域层的情况下被外部消费。边界不清的直接后果是职责错位,你会看到校验逻辑散落在前端、Controller、Service 和数据库触发器里,同一个规则有四种实现,迟早会出现不一致。
第二个问题是接口。接口不是编程语言里的interface,而是模块之间的一种约束。它规定外部通过什么方式、传什么参数、拿到什么结果,并且隐藏内部实现。接口设计得好,替换内部实现时外面无感;接口设计得差,任何内部改动都会引发连锁修改。很多团队在写代码之前没有定义接口的习惯,往往是 A 服务需要订单数据,就直接查订单表;B 服务需要订单数据,也直接查订单表。这其实是把数据库当成了公共接口,短期很爽,长期很痛。
第三个问题是状态。状态是业务复杂度的最大来源。一个订单从创建到完成,中间有哪些合法状态,哪些状态之间可以互相转换,哪些操作会导致状态跳跃,这些问题如果不先想清楚,代码里就会长出无数个if (status == ...)。状态建模的目标,是把“允许发生什么”和“不允许发生什么”显式地表达出来,而不是靠每个开发自己临时判断。
这三者的关系可以这样理解:边界划分出模块,接口定义模块之间的通信方式,状态描述模块内部的业务变化规律。设计的过程,就是不断回答“边界应该画在哪、接口应该长什么样、状态应该怎么走”。
| 设计视角 | 要回答的问题 | 常见失控信号 |
|---|---|---|
| 边界 | 职责属于谁,依赖方向是什么 | 一个改动牵动多个模块 |
| 接口 | 外部如何与模块协作 | 内部实现变化导致调用方频繁改动 |
| 状态 | 业务在什么规则下流转 | 状态判断散落各处,逻辑重复 |
3. 设计工作的前置条件与工具准备
很多人一想到设计,就以为要买软件、建模型、画标准 UML。其实真正需要的工具非常轻。
你可以用白板先画一版事件流,用 Markdown 记录决策和接口约定,用代码仓库保存版本,用测试框架验证设计是否真的可以被实现。重点是让设计“可见、可评审、可演进”,而不是追求形式美观。
如果你希望边设计边验证,建议准备一个最小的可运行环境。以 Python 为例,下面的命令可以快速创建一个干净的虚拟环境,方便后续写领域逻辑示例:
python -m venv .venv source .venv/bin/activate python --version这里不需要安装任何重量级框架。设计阶段的核心产出物应当包括:
- 业务事件流:描述一次业务动作发生后,系统内部发生了什么。
- 核心状态表:列出所有关键状态和合法迁移路径。
- 接口契约:定义参数、返回值、异常语义。
- 存储模型:明确哪些数据是主数据,哪些是派生数据。
- 架构决策记录(ADR):记录关键决策和备选方案。
ADR 是我非常推荐的一种轻量级设计文档。它不需要几十页,只需要把决策背景、方案、后果写清楚。下面是一个模板:
# 4. 订单状态迁移收敛到领域层 状态:已接受 日期:2025-XX-XX 背景: 目前订单状态在多个 Service 里被直接修改,校验逻辑重复且不一致。 决策: 所有订单状态迁移必须通过 OrderService 暴露的方法完成, 技术上由领域层统一校验并落库。 后果: 新增状态时需要同时修改领域模型和状态机定义, 但外部接口和存储表结构可以保持稳定。这里要特别说明:版本和日期请以你实际项目为准。真正重要的不是 ADR 模板本身,而是团队开始“把设计决策写下来”的这个动作。
4. 一个可落地的设计流程
设计不用一上来就追求全局完美。更推荐的方式是:选定一个核心业务场景,走完一遍从业务到代码的完整设计,跑通后再推广到其他模块。
4.1 用事件流理解业务
事件流的设计思路很简单:不是从数据表开始,而是从业务结果开始。先问“用户做了什么动作,系统需要产生什么结果”,再把结果拆成事件序列。
比如一个订单模块,主流程可以是:创建订单 -> 支付成功 -> 发货 -> 确认收货 -> 订单完成。异常分支可以是:创建订单后取消,支付后取消,发货后拒收。把这些事件写出来后,你会发现很多隐藏需求会浮现,比如“支付成功但库存扣减失败怎么办”“发货后用户申请退款怎么处理”。
事件流不需要用复杂工具,文本就能表达清楚:
已创建订单 -> 支付成功 -> 已发货 -> 订单完成 已创建订单 -> 订单取消 已支付订单 -> 订单取消4.2 识别核心实体与关系
事件流里出现频率最高的名词,就是候选的实体。订单、支付单、物流单、商品、库存,都是典型实体。然后你要明确实体之间的关系是一对一、一对多还是多对多,以及关系的生命周期。
这一步要避免过早进入数据库范式讨论。先关心业务规则,再关心表结构设计。比如订单和支付单之间是 1 对 1 还是 1 对多,取决于业务是否允许部分支付、多次支付。设计时就要先定义清楚,否则后面表结构和接口都会摇摆。
4.3 定义接口契约
接口契约是设计的“硬交付物”。哪怕是内部模块,也要像对待外部 API 一样对待它。接口的参数不是简单的字段列表,而是要表达出“调用方的意图”。比如支付操作,接口方法应该是pay(PayOrderCommand command),而不是updateStatus(orderId, "PAID")。前者表达业务意图,后者暴露实现细节。
4.4 建模核心状态机
找到核心实体后,逐个画出状态迁移图。状态机最大的价值,是让“非法操作”尽早暴露。如果订单状态是“已完成”,再调用取消接口到底应该返回异常还是忽略?这类规则必须以设计结论的形式定下来,而不是让每个开发临时写分支判断。
4.5 确定存储与一致性边界
存储模型要回答三个问题:核心业务数据落在哪张表,哪些数据可以被异步计算,哪些操作需要强一致。在这个阶段引入事件溯源或 CQRS 要非常谨慎,它们是很重的架构风格,不适合所有系统。大部分场景下,用一张订单表、一张订单事件表就能覆盖业务需求。
4.6 用测试用例反向验证
设计是否完整,最好的验证方式是写测试用例。如果设计出来的接口能写出清晰、独立、不依赖实现细节的测试,说明边界是合理的。如果一个测试需要 mock 掉几乎整个系统,说明模块之间的耦合已经失控了。
5. 完整示例:订单模块从设计到代码
下面用一个最常见的订单模块示例,演示从事件流、状态机、接口到存储模型的完整落地过程。这个例子不追求生产级完善,只为了展示设计思路。
5.1 业务场景
用户创建订单,然后支付。支付成功后,运营人员发货,用户确认收货后订单完成。用户在未支付前可以取消订单,支付后如果还未发货,也可以取消并安排退款。这里简化处理:取消订单时暂不实现退款细节。
5.2 事件流与状态表
状态集合为:
CREATED:已创建,等待支付。PAID:已支付,等待发货。SHIPPED:已发货,等待确认。COMPLETED:订单完成。CANCELED:订单取消。
合法迁移为:
CREATED -> PAIDCREATED -> CANCELEDPAID -> SHIPPEDPAID -> CANCELEDSHIPPED -> COMPLETED
这段状态表是后续所有代码的判据。它回答了一个关键问题:只有支付成功的订单才能发货,只能对未支付或未发货的订单取消。把这条规则做成显式的状态机,而不是分散到各个 Service 的if里。
5.3 状态机代码实现
下面的 Python 代码把状态迁移规则集中保存,并提供一个统一的校验函数:
# order_domain/status.py from enum import Enum class OrderStatus(str, Enum): CREATED = "CREATED" PAID = "PAID" SHIPPED = "SHIPPED" COMPLETED = "COMPLETED" CANCELED = "CANCELED" # 状态机:key 是当前状态,value 是所有允许迁移到的状态集合 ORDER_TRANSITIONS = { OrderStatus.CREATED: {OrderStatus.PAID, OrderStatus.CANCELED}, OrderStatus.PAID: {OrderStatus.SHIPPED, OrderStatus.CANCELED}, OrderStatus.SHIPPED: {OrderStatus.COMPLETED}, OrderStatus.COMPLETED: set(), OrderStatus.CANCELED: set(), } def can_transition(current: OrderStatus, target: OrderStatus) -> bool: """判断订单是否可以从 current 迁移到 target。""" return target in ORDER_TRANSITIONS[current] def transition_or_raise(current: OrderStatus, target: OrderStatus) -> None: """做状态迁移前的校验,不合法时抛出异常。""" if not can_transition(current, target): raise ValueError(f"Invalid order status transition: {current} -> {target}")这个设计的好处是:所有状态规则集中在一个文件里,新增状态只需要修改OrderStatus和ORDER_TRANSITIONS,而不是在十几个方法里找if。
5.4 服务接口与命令定义
接口要表达业务意图,而不是暴露数据库操作。用 Java 接口展示更贴近后端团队的习惯:
public interface OrderService { OrderCreateResult createOrder(CreateOrderCommand command); void payOrder(PayOrderCommand command); void shipOrder(ShipOrderCommand command); void completeOrder(CompleteOrderCommand command); void cancelOrder(CancelOrderCommand command); }每个命令类可以包含业务所需参数。比如:
public class PayOrderCommand { private String orderId; private BigDecimal paidAmount; private String paymentChannel; }之所以不使用void updateOrderStatus(orderId, targetStatus),是因为这种接口把状态机的规则暴露给了调用方。每次调用方都可能传入非法状态,最终只能靠一堆临时校验去补漏洞。意图型接口让调用方无法跳过领域规则。
5.5 存储模型
状态迁移需要落到数据库。最简单的模型是“订单主表 + 订单状态变迁表”,前者保存当前状态,后者保存历史轨迹。不建议只在主表上存一个字段,因为一旦状态回看和审计成为需求,很难追溯“什么时候从哪个状态变成哪个状态”。
-- order_main.sql CREATE TABLE order_main ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL UNIQUE, user_id BIGINT NOT NULL, total_amount DECIMAL(12,2) NOT NULL, status VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user_id (user_id) ); -- order_status_history.sql CREATE TABLE order_status_history ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, from_status VARCHAR(32), to_status VARCHAR(32) NOT NULL, operator_id BIGINT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order_id (order_id) );这里主表里依然保留status字段,是为了查询当前状态更快;历史表则专门用于审计和状态追溯。两表都需要在 Service 层的同一个事务里写入,保证一致性。
5.6 项目结构
把领域规则和数据库访问隔离,可以让代码结构更清晰。下面是一个最小目录结构:
order-service/ ├── order_domain/ │ ├── __init__.py │ ├── status.py # 状态枚举与状态机 │ ├── model.py # 订单实体 │ └── service.py # 订单业务逻辑 ├── adapter/ │ ├── repository.py # 数据库访问 │ └── api.py # 对外接口 ├── tests/ │ └── test_order_status.py └── main.py分层看起来多了一个order_domain,但真正的价值是:接口层和数据库层都无法单独改变领域规则,所有业务校验都被限制在service.py和status.py内部。
5.7 运行与验证
可以用一段简单的测试脚本验证状态机:
# tests/test_order_status.py import pytest from order_domain.status import OrderStatus, can_transition, transition_or_raise def test_paid_can_ship(): assert can_transition(OrderStatus.PAID, OrderStatus.SHIPPED) def test_paid_can_cancel(): assert can_transition(OrderStatus.PAID, OrderStatus.CANCELED) def test_completed_cannot_transition(): assert not can_transition(OrderStatus.COMPLETED, OrderStatus.CANCELED) def test_invalid_transition_raises(): with pytest.raises(ValueError): transition_or_raise(OrderStatus.SHIPPED, OrderStatus.CANCELED)运行测试:
pytest -q如果全部通过,说明状态机的核心规则已经固化。遇到非法状态迁移时,就会早点抛错,而不是等到业务数据出错后才发现。
6. 如何判断设计是否合格
设计是否合格,不能靠“看起来专业”来评判。一个更接地气的评判方法,是模拟一次需求变更,看需要改动哪些地方。
比如给上面的订单模块新增一个需求:发货后允许用户发起售后,售后审核通过后退款并关闭订单。请先不要急着写代码,而是问几个问题:
- 订单状态是否需要新增
REFUNDING?还是复用COMPLETED? - 退款成功后,订单终态是
CANCELED还是新增CLOSED? - 状态机表需要同步修改哪些迁移路径?
- 存储模型是否要新增退款表?
- 外部接口调用方会不会受到影响?
如果答案非常明确,说明设计边界是合理的。如果答案要讨论很久且牵涉很多模块,说明当初的边界没有画对。
还可以从可测试性来评估:核心业务逻辑能否不启动外部依赖就完成单元测试?如果设计完的代码,测试时仍然需要启动数据库、Redis、消息队列,那这个设计大概率没有把外部依赖隔离好。
与此同时,也要警惕过度设计。不是每个模块都需要 DDD、事件溯源、CQRS。看到一个几万行的小系统就套上六边形架构,往往只会增加理解成本。设计的正确率应该以“是否更好支撑未来变化”为标准,而不是以“使用了多少个模式”为标准。
7. 常见设计误区与排查思路
下面这张表总结了我在代码评审和项目复盘里经常看到的问题。如果你正在为某个模块头疼,不妨对照排查。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 改一个业务规则要改多个 Service | 业务规则散落各处,没有收敛到领域层 | 搜索相同的if判断和状态赋值 | 用状态机或领域服务统一业务规则入口 |
| 接口参数经常变化 | 接口没有表达业务意图,直接暴露内部对象 | 检查是否调用了updateStatus或save这类通用方法 | 定义意图型命令,比如payOrder |
| 模块之间直接查询对方数据库表 | 边界模糊,数据库成为公共接口 | 查看调用链和 SQL 归属 | 引入服务接口,禁止跨库访问 |
| 状态判断重复出现 | 状态机规则没有被显式定义 | 搜索status ==或state == | 抽出状态机,集中管理迁移规则 |
| 设计文档和代码不一致 | 文档停留在“画图阶段”,没有落到接口和状态表 | 对照 ADR 和代码结构评审 | 让文档包含事件流、状态表、接口契约,并纳入 code review |
| 每次重构都影响外部调用方 | 内部实现细节通过接口泄露 | 检查接口是否有“透传 DTO 字段”现象 | 收缩接口,只暴露稳定语义所需参数 |
这几种情况往往同时出现。比如状态判断散落常常和接口暴露细节一起发生,根因都是缺少设计层的约束。
8. 最佳实践:在快节奏团队中重新练好设计
找到问题之后,怎么在真实项目里把设计能力捡回来?我给出的建议不是立刻启动“架构重构”,也不是让团队停掉所有业务做三个月领域建模,而是用低成本的持续动作,让设计回归日常。
第一个建议是让“接口先定义”成为硬性要求。任何新功能,先写接口签名,再讨论实现。接口签名写不出来,说明业务意图还没想清楚。这个动作单独看起来很小,但它会逼着团队在编码前先思考边界和语义。
第二个建议是用 ADR 记录关键决策。不需要长篇大论,只需要记录:背景、决策、后果。当三个月后有人问“为什么这里要这么设计”时,不用靠某个人的记忆去解释,而是直接看文档。它能省下大量重复讨论的时间。
第三个建议是用测试倒逼接口设计。如果你发现测试代码很难写、mock 依赖很重,第一步不是加强 mock 框架,而是回头检查接口边界是不是出了问题。好的设计应该让核心逻辑很容易测试,而不是让开发者搭一整套测试环境才能验证一个分支。
第四个建议是给 AI 辅助编程设定边界。现在很多团队用 AI 生成代码,这个趋势无法忽视。但更稳妥的做法是:先定义接口和状态机,再让 AI 在约束范围内生成实现。否则 AI 生成出的代码往往只是把现有的混乱风格复制得更多。AI 是效率放大器,但它不会替你完成设计决策。
第五个建议是定期做“删除练习”。每次重构时,不仅要加新代码,还要看哪些方法可以被删掉、哪些字段可以被收敛。大量的设计腐化不是一次大改造成的,而是来自长期只加不改、只堆不删。
最后,不要试图一次性设计完整个系统。最可持续的做法是:先在一个模块里试点,把事件流、状态机、接口契约、ADR 这套方法跑通。当团队看到效果后,再逐步复制到其他核心模块。设计能力是靠一个个项目练出来,而不是靠开会讲出来的。
9. 总结与后续学习方向
回到标题的问题:我们是否遗忘了如何设计?
从很多代码库的现状来看,答案是“一定程度上是的”。不过这不意味着能力不可恢复,而是说明设计还没有被当作一项必须刻意练习的技能。工具可以解决一部分效率和重复劳动问题,但边界怎么划分、状态怎么收敛、接口怎么定义,仍然需要人来判断。
这篇文章重点讲了设计的三个核心概念,边界、接口、状态,并结合订单模块给出了一个可落地的最小设计流程。实际项目里不需要照搬上面的代码,但可以把事件流、状态机、接口契约、ADR 这四个产出物引入到你的下一个模块中。
后续如果你希望深入,建议按这个顺序学习:先掌握状态机建模和接口设计,再读领域驱动设计相关的资料,最后了解 Event Storming 和事件溯源。这些知识不是用来证明“我用过什么”,而是为了在遇到复杂业务时,能更快地做出高质量的设计决策。
从今天开始,选一个你正在开发或维护的模块,画出它的事件流,列出所有核心状态和迁移路径,定义它对外提供的接口契约。你会发现,很多“改不动”的问题,其实在设计阶段就已经埋下了。