从零重构外卖系统(一):先理解业务,再用 Spring Modulith 搭建 DDD 骨架
系列:Han Menu 外卖系统实践 · 从第一篇开始
上一篇:系列起点 · 下一篇:第2篇
系列:Han Menu 外卖系统实践 · P0
面向读者:掌握 Java 类、接口和基本 Spring Boot 用法,准备开始学习 DDD 的开发者。
阅读目标:能够解释一个业务能力为什么属于某个模块,知道类应该放在哪个包,并把这些约束变成代码检查。
本文按项目当前确定的技术路线重新组织 P0 教程。代码采用 JDK 25、Spring Boot 4.1.1、Spring Modulith 2.1.1;版本是本系列的固定基线,不代表读者阅读时的最新版本。文中的代码片段直接展示关键实现;标注省略内容的片段用于解释设计,不是独立完整工程。
系列阅读导航
本系列按后端领域建模、业务闭环与管理端交付的顺序展开,各篇保留对应阶段的实现范围。
- 01|先理解业务,再用 Spring Modulith 搭建 DDD 骨架
- 02|把员工账号规则落地为领域模型、JPA 和 REST API
- 03|商品、套餐与营业状态,怎样从 CRUD 走向业务建模
- 04|顾客身份、默认地址与购物车,怎样设计归属和一致性
- 05|订单快照、幂等提交与购物车结算,怎样保证一次下单只发生一次
- 06|接入支付宝沙箱,怎样把支付、退款和订单履约做成可恢复的业务流程
- 07|可靠通知、鉴权 WebSocket 与经营统计,怎样让消息可补查、报表可重建
- 08|从管理页面反推后端契约,补齐检索、顾客状态与安全审计
- 09|Vue 管理端从登录开始,怎样建立可靠的会话、请求与权限基础
- 10|经营资料管理怎样处理草稿、版本冲突与商品约束
- 11|订单作业怎样连接快照、履约确认与异步退款
- 12|实时通知怎样做到断线可补、已读可恢复、换账号不串消息
- 13|从经营分析到生产交付,怎样让管理端的数据、维护与发布都可核对
1. 接到“做一个外卖系统”之后,第一件事是什么
假设我们要做一个外卖系统。
管理员维护菜品,顾客浏览菜单,把喜欢的商品加入购物车,然后下单付款。商家接单、配送,顾客还可以取消订单或催单。后台需要查看营业额和热门菜品。
很多人的第一反应是创建几个目录:
controller/
service/
mapper/
entity/
接着建立 DishController、DishService、DishMapper,再把数据库字段复制成 Java 属性。
这样的项目可以运行。但当需求变成“套餐里的菜品停售后,套餐是否还能出售”“顾客修改地址后,历史订单里的地址是否一起变化”时,目录结构并不会告诉我们规则属于谁。
我们需要先回答三个问题:
- 业务中有哪些概念?
- 哪个对象负责维护哪个规则?
- 不同业务能力之间应该怎样合作?
DDD,领域驱动设计,提供的正是分析这些问题的方法。它要求我们围绕业务语言和规则组织模型,再让技术实现服务于模型。
在 Han Menu 中,我们参考苍穹外卖的业务能力,重新设计一个全新系统。没有旧用户、旧数据库或旧接口需要兼容。顾客端计划使用 Flutter App,支付学习使用支付宝沙箱。
P0 的任务是建立业务边界、工程组织和运行基础。这个阶段不会实现一个假的下单流程,也不会把空接口包装成已经完成的业务。
2. 先建立一份大家都理解的业务词典
2.1 一个词,在不同地方可能代表不同东西
先看“菜品价格”。
商品管理页面显示的是当前售价,运营人员可以调整它。
订单详情页显示的是下单时的成交单价。即使第二天商品涨价,昨天的订单金额也不能改变。
它们都叫“价格”,却承担不同的业务含义。
| 场景 | 我们真正关心的内容 | 谁负责 |
|---|---|---|
| 商品维护 | 当前售价、是否可售、口味选项 | 商品目录模块 |
| 购物车展示 | 顾客选择了什么、选择数量 | 购物车模块;价格展示需要引用商品信息 |
| 提交订单 | 此刻能否购买、服务端确认的金额 | 下单用例协调商品与订单规则 |
| 历史订单 | 当时购买了什么、以什么价格成交 | 订单模块保存快照 |
由此可以得到第一条建模经验:看到相同的名词,不要立刻决定共用同一个 Java 类。先确认它在各个场景中的含义。
2.2 什么是统一语言
统一语言,就是业务讨论、文档、代码和测试尽量使用相同的概念。
例如业务上说“停用员工账号”,代码里就应当出现账号状态变更、账号是否可登录这样的概念。不能所有操作都叫 update,然后依靠调用方记住哪些字段应该一起修改。
可以从一张小表开始:
| 业务词 | 在本项目中的含义 | 容易混淆的地方 |
|---|---|---|
| 员工账号 | 登录管理端、执行被授权操作的身份 | 不是完整的人事档案 |
| 顾客 | 使用移动端下单的人 | 不应直接复用员工权限模型 |
| 菜品 | 单独销售的商品及其口味、可售状态 | 不等于订单里的商品快照 |
| 套餐 | 按组合销售的商品 | 需要考虑所引用菜品的销售规则 |
| 订单 | 一次购买约定及其履约状态 | 不等于支付渠道交易 |
| 支付单 | 某次支付尝试及渠道结果 | 订单取消不代表退款已经完成 |
| 营业额 | 按指定口径计算的统计结果 | 需要明确是否扣除退款、按哪一天统计 |
词典不必一开始就完整。遇到歧义时补充定义,比先创建大量“通用对象”更有帮助。
3. 从业务能力划分限界上下文
3.1 三个经常一起出现的概念
第一次接触 DDD,容易把领域、子域和限界上下文混在一起。
- 领域:软件要解决的业务范围,例如外卖经营。
- 子域:领域中的某一部分业务能力,例如商品销售、订单履约、支付处理。
- 限界上下文:某一套模型和语言有效的边界。在这个边界内,“订单”“顾客”等词具有明确、一致的含义。
子域帮助我们理解业务,限界上下文帮助我们组织模型。两者可以接近,但不是必须一一对应。
Han Menu 使用一个 Spring Modulith 应用模块承载一个候选限界上下文。这里说“候选”,是因为业务理解还会演进。模块包名确定了,不代表边界永远不能调整。
3.2 不要按数据库表的数量决定模块数量
如果一个模块对应一张表,那么 dish、dish_flavor、setmeal、setmeal_dish 可能被拆成四个模块。
但这些对象经常一起参与商品维护和销售规则,拆开后反而产生大量跨模块调用。
更实用的划分依据是:
- 哪些概念使用相同的业务语言?
- 哪些规则经常一起变化?
- 谁拥有数据、谁可以修改数据?
- 哪些协作必须立即得到结果,哪些可以稍后完成?
以这些问题为依据,当前得到九个模块:
| 模块 | 负责什么 | 不应顺手接管什么 |
|---|---|---|
identity |
员工身份、账号状态、权限和会话 | 顾客收货地址、商品管理规则 |
customer |
顾客资料、地址簿和默认地址 | 员工权限体系 |
shop |
营业状态和门店经营规则 | 所有订单的状态迁移 |
catalog |
分类、菜品、口味、套餐和可售性 | 历史订单的成交价格 |
cart |
待购商品、规格和数量 | 最终支付金额的决定权 |
ordering |
下单、取消、接单和履约 | 渠道签名、支付 SDK 细节 |
payment |
支付、退款和渠道结果 | 订单模块内部的聚合对象 |
notification |
来单、催单等通知的投递 | 决定订单是否支付成功 |
reporting |
工作台、经营统计和报表 | 直接修改订单或商品数据 |
这份表既描述职责,也描述限制。只有“负责什么”而没有“不该做什么”,模块很容易逐渐变成万能服务。
3.3 用“改价”检查边界是否合理
业务提出:“明天起,番茄鸡蛋面的价格从 18 元改为 20 元。”
试着沿着模块边界思考:
- 商品目录修改当前售价。
- 新订单按照重新确认的价格下单。
- 已经完成的订单保持当时的金额。
- 购物车中的展示价格是否刷新、是否提示变化,由购物车与结算用例明确处理。
- 报表统计历史营业额时,使用订单事实,不能拿商品现价重新计算。
如果一次改价会让所有历史订单变动,说明模型边界或数据引用方式存在问题。
用一个具体业务变化去“推演”设计,比只看分包图更容易发现问题。
4. 为什么采用模块化单体
单体表示应用作为一个整体运行和部署。模块化表示内部有明确的业务边界。
它们可以同时成立。
flowchart TB
Admin[管理端] --> App[一个 Spring Boot 应用]
Mobile[Flutter App:后续实现] --> App
subgraph Modules[应用内部的业务模块]
Identity[identity]
Catalog[catalog]
Ordering[ordering]
Payment[payment]
Others[customer / shop / cart / notification / reporting]
end
App --> Modules
Modules --> DB[(PostgreSQL)]
Modules --> Redis[(Redis)]
Modules --> Storage[(RustFS)]
这张图表达的是目标结构,不表示 P0 已完成所有模块的业务实现。
对于个人项目,模块化单体有几个直接好处:
- 一个应用就能调试完整调用链。
- 模块内和部分跨模块操作可以使用本地数据库事务。
- 不必一开始解决服务发现、网络重试、分布式追踪和跨服务部署问题。
- 仍然可以通过架构检查约束依赖,避免代码全部混在一起。
单个 Maven 工程不妨碍业务模块化。本项目没有为了每个业务模块创建独立 Maven 子工程。
也没有采用 Java 的 module-info.java。Spring Modulith 的模块主要建立在包结构和应用模型上,它与 JDK 的 JPMS 模块不是同一个概念。
5. 先按业务分包,再在业务内部划分职责
5.1 顶层目录回答“这是哪块业务”
项目根包为:
com.hanserwei.hanmenu
├── HanMenuApplication
├── identity
├── customer
├── shop
├── catalog
├── cart
├── ordering
├── payment
├── notification
└── reporting
Java 初学者可以把 package 理解为类的命名空间。它参与类型命名和访问控制,也帮助我们组织代码。
但仅仅把文件移动到一个包里,不会自动获得正确的架构。接下来还要限制这些包之间的依赖。
5.2 模块内部回答“这个类承担什么责任”
每个模块内部按下面的结构组织:
identity/
├── package-info.java
├── api/
├── events/
├── domain/
├── application/
├── infrastructure/
│ └── persistence/ # P1 实现持久化后加入
└── web/
├── admin/
└── app/
逐个解释这些包:
domain:业务概念和规则。
例如员工账号知道自己是否启用,知道管理员不能在当前阶段被停用。这里不需要知道请求来自浏览器,也不需要知道数据库使用 Hibernate。
application:完成一个用例的步骤。
例如“管理员停用员工”需要检查操作者、读取员工、调用领域行为、保存结果并记录审计。应用服务负责组织这些步骤。
infrastructure:端口的技术实现。
例如如何用 JPA 保存员工、如何访问 Redis 限流。领域定义需要什么能力,基础设施实现这个能力。
web:把 HTTP 世界翻译成应用能理解的输入。
它接收 JSON、验证基本格式、提取当前身份、调用应用服务,最后把结果变成响应 DTO。
api:允许其他模块引用的同步契约。
这里的 API 指模块之间的 Java 契约,不专指 HTTP。一个 Java 接口、一个不可变身份快照,都可以是公开契约。
events:允许其他模块订阅的集成事件。
例如未来可能有“支付已确认”这样的事实。事件不是“请替我更新数据库”的万能命令,也不应直接携带完整 ORM 实体。
5.3 用一道分类题判断是否理解
下面几个类分别放在哪里?
| 类或能力 | 推荐位置 | 原因 |
|---|---|---|
| 检查管理员能否停用某账号 | domain,配合应用用例的操作者校验 |
这是业务规则 |
| 读取员工并提交状态变更 | application |
这是一个用例的编排 |
| 使用 Hibernate 更新账号行 | infrastructure/persistence |
这是技术存取方式 |
| 接收 PATCH 请求里的状态和版本 | web/admin |
这是 HTTP 输入协议 |
| 供其他模块识别操作者的身份快照 | api |
这是模块公开契约 |
如果只是因为一个类带有 @Service 就认为它属于应用层,还不够。判断依据是职责,注解只是框架注册方式。
6. 依赖倒置怎样体现在 Java 接口上
先看一个不理想的依赖:
EmployeeAdministration → JpaEmployeeRepository
应用服务直接引用 JPA 实现。这样一来,用例代码会逐渐知道持久化细节。
我们希望它依赖业务定义的端口:
flowchart LR
A[EmployeeAdministration] --> P[EmployeeRepository 接口]
J[JpaEmployeeRepository] --> P
J --> S[Spring Data JPA]
箭头表示 Java 类型依赖。
仓储端口放在 domain 包中,关键方法如下:
public interface EmployeeRepository {
/** 通过聚合标识加载账号. */
Optional<EmployeeAccount> findById(UUID id);
/** 更新聚合状态,持久化实现必须阻止陈旧版本覆盖. */
void update(EmployeeAccount account);
}
这里出现的都是业务需要的类型:账号、标识、是否查到结果。没有 EntityManager、Pageable 或数据库连接。
Java 中,接口描述“能做什么”。实现类通过 implements EmployeeRepository 提供具体行为。Spring 再通过构造器把实现对象传给应用服务。
执行时,应用服务当然最终会调用到适配器。依赖倒置改变的是源代码知道谁,不是禁止运行时调用外层代码。
这也是为什么架构图需要注明箭头含义。源码依赖图与一次请求的执行顺序图不是同一张图。
本项目把仓储和若干能力端口集中放在
domain。更严格的六边形架构实现也可能把登录限流、令牌等用例端口放在application。目录命名可以调整,关键是领域对象不反向依赖技术实现。
7. 让 Spring Modulith 认识业务边界
7.1 package-info.java 是什么
它是一个特殊的 Java 源文件,用于描述一个包,可以包含包级 Javadoc 和包级注解。
P0 中我们通过它声明模块:
/** 账号与权限模块,拥有员工账号及认证规则. */
@ApplicationModule(
displayName = "账号与权限",
allowedDependencies = {})
package com.hanserwei.hanmenu.identity;
import org.springframework.modulith.ApplicationModule;
这是按当前配置整理的骨架写法。allowedDependencies = {} 明确表示当前模块不允许依赖其他业务模块。
不要把“留空”理解为“随便调用”。这里的空数组是一份空白名单。
7.2 public 不等于模块公开
Java 子包之间没有自动的“父子可见性”。例如 identity.application 与 identity.domain 是不同包,一些类型需要声明为 public 才能互相使用。
但是,我们并不希望其他业务模块也可以导入它们。
Spring Modulith 在 Java 可见性之外,增加了应用模块层面的边界模型。模块内部子包的类型,不会因为 Java 上是 public 就自动成为对外 API。
需要公开的包要明确标记:
/** 账号与权限模块对外发布的同步契约. */
@NamedInterface("api")
package com.hanserwei.hanmenu.identity.api;
import org.springframework.modulith.NamedInterface;
未来其他模块确实需要身份契约时,再精确开放:
// 后续阶段的配置示意:当前不表示 customer 已实现这些调用。
@ApplicationModule(allowedDependencies = "identity :: api")
package com.hanserwei.hanmenu.customer;
import org.springframework.modulith.ApplicationModule;
这样读者看到依赖声明,就能知道这个模块为什么需要其他模块。
7.3 用测试验证边界
模块架构测试会执行下面的代码:
var modules = ApplicationModules.of(HanMenuApplication.class).verify();
new Documenter(modules).writeDocumentation();
verify() 检查模块内部访问、声明的依赖限制及循环依赖等问题。Documenter 从代码中的模块模型生成文档。
例如未来 ordering 偷偷导入 catalog.infrastructure.persistence 中的实体,就应当由模块检查阻止,而不只是依靠评审时有人发现。
需要注意:Modulith 的 Java 依赖检查不会自动理解每一条 SQL 的业务含义。遵守类型边界之外,还需要明确数据归属。不能在一个模块的仓储里直接读取另一个模块的业务表。
8. Modulith 管模块,ArchUnit 管模块内部
Modulith 可以告诉我们 ordering 能不能调用 catalog。它不替我们决定 domain 是否应该依赖 Spring MVC。
后一个问题交给 ArchUnit。
ArchUnit 会读取编译后的 Java 类,检查类之间的依赖关系。项目的规则节选如下:
noClasses()
.that()
.resideInAPackage("..domain..")
.should()
.dependOnClassesThat()
.resideOutsideOfPackages("java..", "com.hanserwei.hanmenu..domain..")
.allowEmptyShould(true)
.check(classes);
把它翻译成人话:领域包里的类,只能依赖 Java 标准库和领域类型。
allowEmptyShould(true) 是为了让还没有实现类的骨架包合法存在。它不会让已经加入的类免于检查。
这条规则允许匹配到其他领域包,并不意味着跨模块领域对象可以随意互相引用;跨模块的限制由前面的 Modulith 白名单继续检查。不同规则负责不同维度,组合起来才构成项目约束。
项目还约束应用层不依赖 Web 和持久化实现、Web 不直接访问持久化适配器,并禁止生产代码依赖直接 JDBC API。
这样,项目“约定”就逐渐变成可以执行的检查,而不只是 README 里的文字。
9. 为完整项目选择持久化方案
9.1 JPA、Hibernate、Spring Data JPA 分别是什么
三个名字经常同时出现,但承担的职责不同:
| 名称 | 可以怎样理解 |
|---|---|
| JPA / Jakarta Persistence | Java 持久化规范,定义实体、关联、持久化上下文等概念 |
| Hibernate | JPA 的一种实现,真正完成对象与数据库的映射 |
| Spring Data JPA | 在 JPA 上提供仓储接口、派生查询和分页等能力 |
Han Menu 使用这套组合处理业务持久化。
领域模型放在 domain,JPA 实体和映射放在 infrastructure/persistence。两者分开的原因将在 P1 中用员工账号展开。
本项目的目标是构建可维护的完整业务系统,因此常规存取交给 ORM,避免每个用例都维护 INSERT、UPDATE 和 ResultSet 映射。
ORM 不会消除数据库知识。事务隔离、索引、唯一约束和查询数量依然需要理解;只是把重复存取工作交给框架。
9.2 为什么仍然保留 Flyway SQL
Flyway 管的是数据库结构的演进,例如:
- 建表;
- 创建唯一约束;
- 增加索引;
- 为新业务添加字段。
Hibernate 管的是运行时的对象存取。两者的职责不同。
项目采用:
spring:
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
validate 表示启动时检查实体映射与数据库是否一致,不让 Hibernate 自动替我们修改表结构。
open-in-view: false 表示不把持久化上下文一直延长到 Web 响应阶段。需要的数据在事务内加载完成,再变成普通响应对象。
这能避免一次 JSON 序列化意外触发额外 SQL。它也要求开发者认真设计实体关联和查询,而不是依赖“序列化到哪里,数据库就查到哪里”。
9.3 依赖版本怎样管理
当前技术基线为:
| 组件 | 本系列版本 | 主要用途 |
|---|---|---|
| JDK | 25 | Java 语言与运行时 |
| Spring Boot | 4.1.1 | 应用装配与依赖管理 |
| Spring Modulith | 2.1.1 | 模块边界、测试、事件基础设施 |
| Spring Data JPA | 4.1.1 | 仓储与查询 |
| Hibernate | 7.4.5.Final | ORM 实现 |
| PostgreSQL | 18.6 | 持久化数据 |
| Redis | 8.10.1 | P1 登录限流;后续按需加入缓存 |
| RustFS | 1.0.0 | S3 兼容对象存储,后续保存商品图片 |
Spring Data 与 Hibernate 版本由 Boot 的 BOM 管理。BOM 可以理解为“经过组合管理的一组依赖版本清单”。不需要给每个 Spring 组件单独指定版本。
Modulith 使用自己的 BOM,配置如下:
<properties>
<java.version>25</java.version>
<spring-modulith.version>2.1.1</spring-modulith.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>${spring-modulith.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
主要持久化依赖是 spring-boot-starter-data-jpa、spring-boot-starter-flyway、flyway-database-postgresql、PostgreSQL 驱动,以及 spring-modulith-starter-jpa。
这些依赖按各自职责装配应用。理解每个依赖解决的问题,比记住十几个 artifactId 更有帮助。
10. 模块协作:何时同步调用,何时使用事件
这是后续业务设计示例,不是 P0 已实现的订单功能。
提交订单时,需要立即知道商品当前是否可售。此时适合通过商品模块的公开契约同步查询。
订单完成后,经营报表可能需要更新统计。报表更新失败,不应把已经完成的订单恢复成未完成。此时可以使用提交后的异步事件。
sequenceDiagram
participant O as ordering
participant C as catalog.api
participant DB as PostgreSQL
participant R as reporting
O->>C: 校验商品并取得当前报价
C-->>O: 返回不可变报价结果
O->>DB: 保存业务状态及相关事件登记
DB-->>O: 提交成功
O-->>R: 提交后处理相关业务事件
R->>DB: 更新自己的统计模型
事件协作还要考虑失败。如果业务提交成功后进程退出,监听器还没来得及处理,不能简单认为事件已经完成。
Spring Modulith 提供持久化事件登记基础设施:需要投递给相应事务监听器的事件登记,可以与业务变更参与同一事务。之后的失败恢复仍需要幂等处理、重试和状态观察。
它不是自动提供“恰好一次”,也不是把所有业务事件永久保存起来的事件溯源系统。
P0 准备这套基础设施,P1 通过测试验证其事务关系;真正的支付、通知、报表监听器会在对应阶段实现。
11. 让学习环境容易启动,也容易停止
本项目把三项本机中间件放进同一份 infra/compose.yml:
han-menu-postgres → han-menu-postgres-data
han-menu-redis → han-menu-redis-data
han-menu-rustfs → han-menu-rustfs-data
容器是运行实例,数据卷是持久化存储。停止容器不等于删除数据卷。
项目初始化脚本负责生成本地随机凭证、创建开发库和测试库、准备对象存储 Bucket。凭证写入被 Git 忽略的 .env,不会放在 Java 源码或博客里。
在已有仓库中,可以按以下顺序操作:
# 准备 PostgreSQL、Redis 和 RustFS。
python3 scripts/middleware.py up
# 安装本机用户级统一管理服务,可选。
python3 scripts/middleware.py install-service
# 安装提交前检查。
./scripts/install-hooks.sh
# 启动应用。
./scripts/with-env.sh ./mvnw spring-boot:run
这些是本项目提供的脚本,不是 Spring Boot 自带命令。开发机需要先安装 JDK 25、Python 3、Podman 和 podman-compose。
Maven Wrapper,也就是 mvnw,用来固定构建工具版本。团队成员或读者使用同一份 wrapper,可以减少“我这里 Maven 版本不同”的问题。
当前仓库已经包含 P1,执行上述命令会运行现有完整代码,而不是回到历史 P0 提交。这篇文章讲的是骨架构建思路,文章顺序不等于要求切回已经淘汰的试验版本。
12. 代码规范也应成为工程的一部分
“遵循 Google Java Style”如果只写在文档里,很容易变成个人习惯。
项目同时使用两类工具:
- Spotless 调用 Google Java Format,统一缩进、换行和导入等格式。
- Checkstyle 使用官方
google_checks.xml,检查命名、Javadoc、布局等规则。
Checkstyle 配置中的关键部分:
<configLocation>google_checks.xml</configLocation>
<includeTestSourceDirectory>true</includeTestSourceDirectory>
<violationSeverity>warning</violationSeverity>
<failOnViolation>true</failOnViolation>
为什么把警告也作为失败处理?因为 Google 检查里有些违规以 warning 级别报告。如果只检查 error,就可能出现“扫描执行了,但没有真正阻止违规”的情况。
日常流程是:
./mvnw spotless:apply
./scripts/verify.sh
前者修正格式,后者执行完整验证,包括规范、架构和真实基础设施集成测试。
中文注释需要说明职责、约束和设计原因。下面的注释比“设置状态”更有用:
/** 停用员工并撤销旧会话;管理员不允许通过员工管理入口停用. */
这里摘要以英文句点结束,是为了遵循当前 Google Checkstyle 原始规则。正文仍然可以使用中文标点。
自动格式化无法替代语义审查。一个名称格式合法的方法,仍然可能承担了错误的业务职责。
13. P0 完成时,应该交付什么
P0 的成果可以具体到以下几件事:
- 业务词典和候选模块职责能够被解释。
- 每个模块的数据所有权清晰,模块之间通过契约协作。
- Java 包结构与职责划分对应。
- Modulith 与 ArchUnit 能发现非法依赖。
- 数据库、缓存和对象存储能够统一管理。
- 格式、规范和测试有统一执行入口。
- 后续业务实现不会被旧项目的接口、表结构和默认账号束缚。
此时,大部分模块还只有包说明,这是正常的。下一阶段真正实现一个用例时,我们才决定它需要哪些聚合、值对象、端口和适配器。
留给读者的练习
练习一:历史订单与顾客地址。
顾客修改默认地址后,昨天的订单配送地址应该变化吗?请说明地址簿模型和订单地址快照分别归谁维护。
练习二:依赖方向。
OrderService 需要知道支付结果。如果订单模块导入支付模块的 JPA 实体,边界哪里出了问题?可以用什么公开契约替代?
练习三:分包。
把“校验套餐是否可以上架”“接收上架 HTTP 请求”“保存套餐”“调用领域规则并提交事务”分别放进四个分层,写出你的理由。
参考思路是:先识别谁维护规则,再看谁组织流程,最后才选择技术实现。目录只是这个判断的结果。
下一篇将实现一个真正的业务规则:**员工被停用后,旧令牌立即失效;再次启用,也不能让旧令牌恢复有效。**我们会沿着这个规则,从 Java 对象一直走到数据库和 HTTP 接口。
延伸阅读
系列:Han Menu 外卖系统实践 · 从第一篇开始
上一篇:系列起点 · 下一篇:第2篇