系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:系列起点 · 下一篇:第2篇

系列:Han Menu 外卖系统实践 · P0

面向读者:掌握 Java 类、接口和基本 Spring Boot 用法,准备开始学习 DDD 的开发者。

阅读目标:能够解释一个业务能力为什么属于某个模块,知道类应该放在哪个包,并把这些约束变成代码检查。

本文按项目当前确定的技术路线重新组织 P0 教程。代码采用 JDK 25、Spring Boot 4.1.1、Spring Modulith 2.1.1;版本是本系列的固定基线,不代表读者阅读时的最新版本。文中的代码片段直接展示关键实现;标注省略内容的片段用于解释设计,不是独立完整工程。

系列阅读导航

本系列按后端领域建模、业务闭环与管理端交付的顺序展开,各篇保留对应阶段的实现范围。

1. 接到“做一个外卖系统”之后,第一件事是什么

假设我们要做一个外卖系统。

管理员维护菜品,顾客浏览菜单,把喜欢的商品加入购物车,然后下单付款。商家接单、配送,顾客还可以取消订单或催单。后台需要查看营业额和热门菜品。

很多人的第一反应是创建几个目录:

controller/
service/
mapper/
entity/

接着建立 DishControllerDishServiceDishMapper,再把数据库字段复制成 Java 属性。

这样的项目可以运行。但当需求变成“套餐里的菜品停售后,套餐是否还能出售”“顾客修改地址后,历史订单里的地址是否一起变化”时,目录结构并不会告诉我们规则属于谁。

我们需要先回答三个问题:

  1. 业务中有哪些概念?
  2. 哪个对象负责维护哪个规则?
  3. 不同业务能力之间应该怎样合作?

DDD,领域驱动设计,提供的正是分析这些问题的方法。它要求我们围绕业务语言和规则组织模型,再让技术实现服务于模型。

在 Han Menu 中,我们参考苍穹外卖的业务能力,重新设计一个全新系统。没有旧用户、旧数据库或旧接口需要兼容。顾客端计划使用 Flutter App,支付学习使用支付宝沙箱。

P0 的任务是建立业务边界、工程组织和运行基础。这个阶段不会实现一个假的下单流程,也不会把空接口包装成已经完成的业务。

2. 先建立一份大家都理解的业务词典

2.1 一个词,在不同地方可能代表不同东西

先看“菜品价格”。

商品管理页面显示的是当前售价,运营人员可以调整它。

订单详情页显示的是下单时的成交单价。即使第二天商品涨价,昨天的订单金额也不能改变。

它们都叫“价格”,却承担不同的业务含义。

场景 我们真正关心的内容 谁负责
商品维护 当前售价、是否可售、口味选项 商品目录模块
购物车展示 顾客选择了什么、选择数量 购物车模块;价格展示需要引用商品信息
提交订单 此刻能否购买、服务端确认的金额 下单用例协调商品与订单规则
历史订单 当时购买了什么、以什么价格成交 订单模块保存快照

由此可以得到第一条建模经验:看到相同的名词,不要立刻决定共用同一个 Java 类。先确认它在各个场景中的含义。

2.2 什么是统一语言

统一语言,就是业务讨论、文档、代码和测试尽量使用相同的概念。

例如业务上说“停用员工账号”,代码里就应当出现账号状态变更、账号是否可登录这样的概念。不能所有操作都叫 update,然后依靠调用方记住哪些字段应该一起修改。

可以从一张小表开始:

业务词 在本项目中的含义 容易混淆的地方
员工账号 登录管理端、执行被授权操作的身份 不是完整的人事档案
顾客 使用移动端下单的人 不应直接复用员工权限模型
菜品 单独销售的商品及其口味、可售状态 不等于订单里的商品快照
套餐 按组合销售的商品 需要考虑所引用菜品的销售规则
订单 一次购买约定及其履约状态 不等于支付渠道交易
支付单 某次支付尝试及渠道结果 订单取消不代表退款已经完成
营业额 按指定口径计算的统计结果 需要明确是否扣除退款、按哪一天统计

词典不必一开始就完整。遇到歧义时补充定义,比先创建大量“通用对象”更有帮助。

3. 从业务能力划分限界上下文

3.1 三个经常一起出现的概念

第一次接触 DDD,容易把领域、子域和限界上下文混在一起。

  • 领域:软件要解决的业务范围,例如外卖经营。
  • 子域:领域中的某一部分业务能力,例如商品销售、订单履约、支付处理。
  • 限界上下文:某一套模型和语言有效的边界。在这个边界内,“订单”“顾客”等词具有明确、一致的含义。

子域帮助我们理解业务,限界上下文帮助我们组织模型。两者可以接近,但不是必须一一对应。

Han Menu 使用一个 Spring Modulith 应用模块承载一个候选限界上下文。这里说“候选”,是因为业务理解还会演进。模块包名确定了,不代表边界永远不能调整。

3.2 不要按数据库表的数量决定模块数量

如果一个模块对应一张表,那么 dishdish_flavorsetmealsetmeal_dish 可能被拆成四个模块。

但这些对象经常一起参与商品维护和销售规则,拆开后反而产生大量跨模块调用。

更实用的划分依据是:

  • 哪些概念使用相同的业务语言?
  • 哪些规则经常一起变化?
  • 谁拥有数据、谁可以修改数据?
  • 哪些协作必须立即得到结果,哪些可以稍后完成?

以这些问题为依据,当前得到九个模块:

模块 负责什么 不应顺手接管什么
identity 员工身份、账号状态、权限和会话 顾客收货地址、商品管理规则
customer 顾客资料、地址簿和默认地址 员工权限体系
shop 营业状态和门店经营规则 所有订单的状态迁移
catalog 分类、菜品、口味、套餐和可售性 历史订单的成交价格
cart 待购商品、规格和数量 最终支付金额的决定权
ordering 下单、取消、接单和履约 渠道签名、支付 SDK 细节
payment 支付、退款和渠道结果 订单模块内部的聚合对象
notification 来单、催单等通知的投递 决定订单是否支付成功
reporting 工作台、经营统计和报表 直接修改订单或商品数据

这份表既描述职责,也描述限制。只有“负责什么”而没有“不该做什么”,模块很容易逐渐变成万能服务。

3.3 用“改价”检查边界是否合理

业务提出:“明天起,番茄鸡蛋面的价格从 18 元改为 20 元。”

试着沿着模块边界思考:

  1. 商品目录修改当前售价。
  2. 新订单按照重新确认的价格下单。
  3. 已经完成的订单保持当时的金额。
  4. 购物车中的展示价格是否刷新、是否提示变化,由购物车与结算用例明确处理。
  5. 报表统计历史营业额时,使用订单事实,不能拿商品现价重新计算。

如果一次改价会让所有历史订单变动,说明模型边界或数据引用方式存在问题。

用一个具体业务变化去“推演”设计,比只看分包图更容易发现问题。

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);
}

这里出现的都是业务需要的类型:账号、标识、是否查到结果。没有 EntityManagerPageable 或数据库连接。

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.applicationidentity.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-jpaspring-boot-starter-flywayflyway-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 的成果可以具体到以下几件事:

  1. 业务词典和候选模块职责能够被解释。
  2. 每个模块的数据所有权清晰,模块之间通过契约协作。
  3. Java 包结构与职责划分对应。
  4. Modulith 与 ArchUnit 能发现非法依赖。
  5. 数据库、缓存和对象存储能够统一管理。
  6. 格式、规范和测试有统一执行入口。
  7. 后续业务实现不会被旧项目的接口、表结构和默认账号束缚。

此时,大部分模块还只有包说明,这是正常的。下一阶段真正实现一个用例时,我们才决定它需要哪些聚合、值对象、端口和适配器。

留给读者的练习

练习一:历史订单与顾客地址。

顾客修改默认地址后,昨天的订单配送地址应该变化吗?请说明地址簿模型和订单地址快照分别归谁维护。

练习二:依赖方向。

OrderService 需要知道支付结果。如果订单模块导入支付模块的 JPA 实体,边界哪里出了问题?可以用什么公开契约替代?

练习三:分包。

把“校验套餐是否可以上架”“接收上架 HTTP 请求”“保存套餐”“调用领域规则并提交事务”分别放进四个分层,写出你的理由。

参考思路是:先识别谁维护规则,再看谁组织流程,最后才选择技术实现。目录只是这个判断的结果。

下一篇将实现一个真正的业务规则:**员工被停用后,旧令牌立即失效;再次启用,也不能让旧令牌恢复有效。**我们会沿着这个规则,从 Java 对象一直走到数据库和 HTTP 接口。

延伸阅读

系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:系列起点 · 下一篇:第2篇