DDD 业务建模与 Spring Boot 实现:从改价规则到聚合与事务
商品当前价格为 100.00 元,改价申请希望调整到 120.00 元。在申请获批前,交易仍使用 100.00 元;批准后,新交易使用 120.00 元,已经成交的订单保留原来的成交价。
提交改价:当前价 100.00 ── 待审目标价 120.00
批准申请:当前价 120.00 ── 申请结清,保存批准记录
驳回申请:当前价 100.00 ── 申请结清,保存驳回记录这组规则需要同时表达两种金额、一份申请及允许执行的动作。若接口只有 updateProduct(price, status),调用方就要自行组合字段,决定什么时候改变当前价、什么时候结束申请。把这些条件集中到可执行的业务模型中,才有机会让 HTTP 接口、后台任务和其他入口遵守同一套规则。
用业务语言确定模型的含义
DDD 在开发过程中做什么
领域驱动设计(Domain-Driven Design,DDD)围绕业务领域建立模型,并让模型参与软件实现。业务人员和开发者共同解释术语、讨论具体例子,代码和测试再把这些理解变为可以执行的行为。发现规则含糊或模型无法表达新业务时,继续修正模型。Domain-Driven Design
这项工作同时涉及两个尺度:战略设计处理业务重点、模型适用范围及不同模型之间的协作;战术设计在一个模型内部安排实体、值对象、聚合和服务。只讨论类应该放在哪一层,会跳过“这个类究竟表达什么业务”的前半段工作。
改价规则简单时,短小的应用服务也可以实现正确行为。规则逐渐涉及多种状态、条件相互制约,并且被多个入口使用时,显式领域模型能把这些变化集中起来。代价是需要维护业务语言、模型、存储转换和相应测试。纯查询、资料登记等简单功能可以继续使用直接的 CRUD 结构。
把“修改价格”拆成明确操作
“支持改价审核”还不能直接生成可靠代码。需要确定:申请时是否影响售价,批准是立即生效还是预约生效,是否允许多份申请同时等待,以及驳回后能否再次申请。不同答案会形成不同模型。
下面是一组完整且较小的业务约定:
| 术语或规则 | 确定的含义 | 对代码的影响 |
|---|---|---|
| 当前价 | 当前新交易可采用的定价,固定 CNY、正数、两位小数 | 单独保存,提交申请不能覆盖它 |
| 改价申请 | 带有申请 ID 的目标价格 | 金额相同的两次申请仍可由 ID 区分 |
| 待审 | 同一商品最多存在一份待审申请 | 新申请必须检查已有申请 |
| 批准 | 指定待审申请的目标价立即生效 | 改变当前价并结束同一份申请 |
| 驳回 | 指定申请结束,当前价保持原值 | 保存驳回结果,允许以后重新申请 |
| 无变化申请 | 目标价与当前价相同 | 拒绝,不能生成没有价格变化的申请 |
| 审核结果 | 可按申请 ID 查询已批准或已驳回 | 当前状态之外,还需保留决定记录 |
这些是示例业务的选择。赠品可能允许零价,多渠道业务可能同时存在多个价格,预约改价还需要生效时间和调度规则;实现前应由实际业务确定,不能从数据库字段类型或框架默认值反推。
操作名称也应保留意图。提交改价是一个可能被拒绝的命令;“改价已批准”描述已经完成的事实。把所有动作都压成 save 或 updateStatus,会丢掉“为什么发生变化”和“允许怎样变化”。
统一语言(Ubiquitous Language)要求这些词持续出现在业务讨论、接口语义、代码和测试中。若业务把“批准”改为“只同意申请,稍后由发布动作生效”,原来立即改价的 approve 就必须一起调整。只维护一张术语表而代码仍使用另一套含义,难以发现这种偏差。Ubiquitous Language
子域、上下文与服务部署分别怎样划分
领域是软件所处理的业务问题范围。商品交易可以再分成商品资料、定价、订单、库存等子域。子域关注业务做什么;限界上下文(Bounded Context)规定一套模型和语言在哪个范围内保持一致,并明确它与其他模型的交互。Bounded Context
商品资料上下文:商品名称、规格、展示内容
│ 提供稳定商品 ID
▼
定价上下文:当前价格、待审改价、审核结果
│ 提供某次报价及其版本
▼
订单上下文:成交商品、成交单价、数量、订单状态定价模型只引用商品 ID,无需把图片、库存数量和所有历史订单装入自己的 Product 类。订单取得报价后保存成交快照;以后当前价变化,历史成交金额仍有原来的解释。这里的交互可以是同一应用中的模块调用,也可以是服务 API,部署方式再按运行与维护需求选择,见服务边界与拆分。
业务问题与实现模型也未必一一对应。遗留系统可能用一个大模型处理几个子域,同一个子域也可能暂时由新旧模型共同服务。需要记录哪个模型维护什么数据,以及双方怎样翻译,不能只给目录重新命名。
核心域是企业需要重点投入、形成竞争差异的业务部分;支撑子域为业务提供必要配合;通用子域可优先评估成熟产品或现成方案。定价可能是某家企业的核心优势,在另一家企业却只是固定价格维护。分类要结合业务战略,技术复杂度和代码行数不能代替这项判断。术语与模式的完整定义可查 Eric Evans 的 DDD Reference。
从一致性要求推导聚合
哪些状态必须一起有效
批准申请时,当前价、待审申请和批准结果存在直接关系。如果先把价格改为 120.00,再单独清除申请,第二步失败后就会留下“价格已生效,申请仍待审”的组合。另一个请求还可能再次批准这份申请。
聚合(Aggregate)把需要统一维护规则的一组对象组织成一个操作单位,外部通过聚合根(Aggregate Root)发起修改。DDD Aggregate 在这组改价规则下,可以形成:
Pricing:某个商品的定价聚合根
├── productId 稳定业务标识
├── currentPrice 当前 Money
├── pending 空,或唯一待审申请
│ ├── proposalId 申请标识
│ └── target 目标 Money
└── version 持久化并发版本
允许修改:submit / approve / reject
只读结果:当前价格、待审申请、版本Pricing 决定能否提交和审核,调用者不能取得内部可变申请后自行结束它。Money 表达金额的值;申请 ID 区分要审核的是哪一次申请。版本则用于识别保存时是否有人先修改了这份状态,不属于价格计算规则。
聚合大小由必须一起维护的条件决定。商品名称变更不会破坏“批准后目标价生效”的规则,因而无需并入定价聚合。把商品的所有外键关联都装进来,会让不相关修改争用同一个对象和版本。反过来,把当前价和唯一待审申请分成两个互不协调的写入口,又会把本来很小的规则拆散。
历史记录与当前状态分开保存
判断是否允许新申请,需要检查目标价、当前价以及是否已有待审申请,不需要加载该商品历次审批。可以用一行当前快照保存聚合,把已决申请追加到独立历史表:
pricings pricing_decisions
当前价、待审字段、版本 申请 ID、商品 ID、目标价
批准/驳回、旧价、新价、提交版本
└──── 同一个本地事务提交 ────┘历史记录保存这次决定的业务事实,查询按申请 ID 定位。即使以后价格又被改过,仍能判断先前申请是否获批。应用提供追加与查询操作;是否进一步通过数据库角色限制历史修改,需要在实际权限设计中落实。
聚合是业务上的操作单位,表是存储结构。它们可以一对一、一对多;选择单行快照,是因为当前状态很小,一次查询就能得到当前价和对应的待审信息。将来增加多份申请、跨商品批量审核或按渠道独立生效,需要重新检查模型和事务粒度。
MVC 与领域模型怎样组合
实际业务应用可以按以下职责组合;认证和授权还需要接入相应的身份系统。
HTTP 请求
→ Controller:解析 DTO、检查格式、取得可信身份
→ 应用服务:授权、载入 Pricing、调用动作、保存结果
→ Pricing / Money:执行状态规则和金额规则
→ PricingRepository:以业务对象表达读写需求
→ JDBC 或 MyBatis 实现:参数绑定、行转换、条件更新Spring MVC 继续处理 HTTP,DDD 决定业务模型如何表达。Controller–Service–Mapper 的技术分层也可以保留;变化在于 Service 中哪些代码只负责用例编排,哪些规则应该由领域对象执行。
领域服务适合有业务含义、却不自然属于某个实体或值对象的操作,例如根据一组独立定价政策计算报价。当前的批准规则只涉及一份 Pricing,直接放在聚合方法中即可。创建一个只转调 pricing.approve() 的 PricingDomainService,没有增加必要职责。
Lombok 可以简化持久化行对象的访问器,但领域对象不宜开放允许任意改价的通用 setter。MyBatis-Plus 的 Mapper 可以用于仓储内部,负责具体 SQL 和行映射;领域接口无需暴露 QueryWrapper 或让聚合继承 ServiceImpl。常用依赖和配置见 Spring Boot 业务接口开发,模型转换的取舍见 DTO、Entity 与领域对象。
启动应用,完成一次改价
工程和运行环境
下载 DDD 定价实验工程。在 Linux amd64、Bash 环境操作,安装 Docker Engine、Docker Compose、curl、jq 和 unzip;普通用户需要解压目录写权限与 Docker 操作权限。Docker 的安装与命令基础见 从代码到进程。
工程使用 Spring Boot 4.1.1、Java 编译目标 17、Maven 3.9.12、PostgreSQL 18.6,应用运行镜像使用 Temurin 25。Boot 4.1.1 的 Java 与构建工具要求可查 System Requirements。依赖只加入 Web MVC、Validation、JDBC、PostgreSQL 驱动和测试组件;JDBC 用于直接展示保存聚合的 SQL,领域模型不需要专用 DDD Starter。
在下载文件所在目录执行:
LAB_DIR=$(mktemp -d -t ddd-pricing.XXXXXX)
unzip ddd-pricing-lab.zip -d "$LAB_DIR"
cd "$LAB_DIR/ddd-pricing"
mkdir -p .m2
docker version
docker compose version
docker compose up -d --wait db
docker compose run --rm --user "$(id -u):$(id -g)" verifydb 健康检查使用 TCP,确认 PostgreSQL 最终服务接受连接。verify 执行 clean verify,创建真实测试表并运行测试,成功后产生 target/ddd-pricing-1.0.0.jar 和测试报告。应用库与测试库使用不同账号,测试清理不接触应用数据。
一次性 Maven 容器按宿主 UID/GID 写入 target 和 .m2,应用容器以 10001:10001 运行。Docker daemon 或 BuildKit 的身份由宿主配置决定;PostgreSQL 镜像入口先准备数据目录,再以数据库用户运行服务。Compose 中的固定口令只供本地隔离实验,实际接入应使用受控凭据。
依赖或镜像下载失败时,先确认本机到 Maven 仓库和镜像仓库的访问;企业代理、内部镜像和离线缓存需要按组织允许的方式配置。权限错误则检查解压目录、.m2 与 target 的实际所有者,不以长期切换 root 代替修正挂载权限。
构建成功后启动应用:
docker compose up -d --build app
docker compose logs -f app出现 Spring Boot 的 Started 日志并确认没有数据源或初始化异常后,按 Ctrl+C 结束日志跟随,容器继续运行。端口只发布到宿主 127.0.0.1:18089。up 的构建、后台运行和健康等待参数见 Docker Compose up。
接口没有接入登录与角色系统,本机能够连接该端口的程序都可以调用。真正提供审批功能时,必须从认证上下文取得身份,检查审核角色及商品操作权限;请求体中的角色或用户 ID 不能自行赋予权限。
建立当前价格并提交申请
继续在解压后的工程目录操作。生成商品 ID,建立初始价:
API=http://127.0.0.1:18089
PRODUCT_ID=$(cat /proc/sys/kernel/random/uuid)
RUN_DIR=$(mktemp -d -t pricing-http.XXXXXX)
jq -n --arg id "$PRODUCT_ID" \
'{productId:$id,currentPrice:"100.00"}' > "$RUN_DIR/create.json"
curl -q --noproxy '*' --fail-with-body -sS \
-H 'Content-Type: application/json' \
--data-binary @"$RUN_DIR/create.json" \
"$API/pricings" -o "$RUN_DIR/current.json"
jq . "$RUN_DIR/current.json"响应包含生成的商品 ID、currentPrice=100.00、currency=CNY、version=0 和空的 pending。JSON 数字可能显示为 100.0 或 100.00,比较金额时按数值解释。这里直接提供 UUID 表示定价引用的商品标识;工程没有商品资料服务,实际系统应通过商品模块的公开能力确认商品可被定价。
申请调整到 120.00:
VERSION=$(jq -er '.version' "$RUN_DIR/current.json")
jq -n --argjson v "$VERSION" \
'{version:$v,targetPrice:"120.00"}' > "$RUN_DIR/submit.json"
curl -q --noproxy '*' --fail-with-body -sS \
-H 'Content-Type: application/json' \
--data-binary @"$RUN_DIR/submit.json" \
"$API/pricings/$PRODUCT_ID/proposals" -o "$RUN_DIR/pending.json"
jq . "$RUN_DIR/pending.json"
PROPOSAL_ID=$(jq -er '.pending.proposalId' "$RUN_DIR/pending.json")当前价仍为 100.00,pending.targetPrice 为 120.00,pending.proposalId 是服务器生成的申请 ID,版本推进到 1。保存返回的版本,用它表达后续操作基于哪份状态。
Pricing.version 跟踪整个聚合的修改。提交申请和驳回申请也会递增版本,即使当前售价没有变化。业务如果需要“售价第几次生效”的编号,应另行定义,不能直接复用这个并发版本。
批准后检查当前状态与历史
VERSION=$(jq -er '.version' "$RUN_DIR/pending.json")
jq -n --argjson v "$VERSION" '{version:$v}' > "$RUN_DIR/approve.json"
curl -q --noproxy '*' --fail-with-body -sS \
-H 'Content-Type: application/json' \
--data-binary @"$RUN_DIR/approve.json" \
"$API/pricings/$PRODUCT_ID/proposals/$PROPOSAL_ID/approve" \
-o "$RUN_DIR/approved.json"
jq . "$RUN_DIR/approved.json"
curl -q --noproxy '*' --fail-with-body -sS \
"$API/decisions/$PROPOSAL_ID" -o "$RUN_DIR/decision.json"
jq . "$RUN_DIR/decision.json"聚合响应应为当前价 120.00、pending=null、版本 2;决定记录应标明同一个商品和申请、APPROVED、旧价 100.00、新价 120.00,以及提交版本 2。决定来自领域批准动作,与当前快照一起保存,应用服务不用根据 HTTP 路径重新猜测结果。
查询历史尤其适合处理响应丢失。客户端等待超时后,先按这次申请 ID 查询决定;只看当前价格,无法区分“这次没有生效”和“这次已生效,后来又发生其他改价”。不存在决定时,再查询当前待审申请并核对版本,决定是否仍可重试。
旧版本不能再次批准
保留刚才的 approve.json,再次提交相同命令。这次预期 HTTP 409,因此不使用 --fail-with-body,而是分别检查传输结果与 HTTP 状态:
STATUS=$(curl -q --noproxy '*' -sS \
-H 'Content-Type: application/json' \
--data-binary @"$RUN_DIR/approve.json" \
"$API/pricings/$PRODUCT_ID/proposals/$PROPOSAL_ID/approve" \
-o "$RUN_DIR/conflict.json" -w '%{http_code}')
TRANSPORT=$?
test "$TRANSPORT" -eq 0 || exit 1
test "$STATUS" = 409 || exit 1
jq -e '.code == "STALE_VERSION"' "$RUN_DIR/conflict.json"
curl -q --noproxy '*' --fail-with-body -sS \
"$API/pricings/$PRODUCT_ID" -o "$RUN_DIR/after-conflict.json"
jq -e '.currentPrice == 120 and .pending == null and .version == 2' \
"$RUN_DIR/after-conflict.json"旧版本触发 STALE_VERSION,数据库状态保持原样。若改用最新版本,但仍要求处理已结清的申请,则因没有匹配的待审申请而拒绝。服务采用显式冲突策略,没有把重复批准包装成自动幂等成功。
版本检查解决并发覆盖,申请 ID 标识某个业务申请,幂等键则识别同一次操作的重复投递。这些字段用途不同。特别是提交申请时 ID 由服务器生成,若提交响应丢失,需要读取当前待审申请;需要严格识别重复提交的接口,还应增加客户端操作 ID 和结果保存,见 超时、重试与幂等。
把规则写进领域对象,再接入存储
代码按职责组织
工程中的主要类型位于 src/main/java/com/example/pricing:
PricingApplication @SpringBootApplication,启动和组件扫描
├── web/
│ ├── PricingController HTTP DTO、路由、输出转换
│ └── ApiExceptionHandler 业务错误 → HTTP 错误
├── application/
│ └── PricingService @Service,用例事务与加载/调用/保存
├── domain/
│ ├── Pricing 聚合根,提交/批准/驳回
│ ├── Money、PendingChange 金额值与待审申请信息
│ ├── Decision、RuleViolation 审核结果与规则拒绝
│ └── PricingRepository 聚合存储接口
└── persistence/
└── JdbcPricingRepository @Repository,SQL 与对象重建启动类放在共同父包中,Spring 扫描到 Controller、Service 和 Repository。domain 不导入 Spring、HTTP 或 JDBC 类型;应用层接受 Spring 的事务注解,以减少装配代码。需要将应用层也保持纯 Java 时,可以把事务交给外部执行器,见 Service、Repository 与 Mapper 职责。
Money 在创建时建立有效值
实验只处理 CNY,金额范围约定为 0.01 至 9999999999.99。Money 的核心实现为:
public record Money(BigDecimal amount) {
public static final String CURRENCY = "CNY";
private static final BigDecimal MAX = new BigDecimal("9999999999.99");
public Money {
if (amount == null || amount.signum() <= 0 || amount.compareTo(MAX) > 0) {
throw new RuleViolation("INVALID_MONEY", "金额须为 0.01 至 9999999999.99 的 CNY 金额");
}
try {
amount = amount.setScale(2, RoundingMode.UNNECESSARY);
} catch (ArithmeticException error) {
throw new RuleViolation("INVALID_MONEY", "金额必须能够精确表示到分");
}
}
}1.230 可以精确归一为 1.23;1.234 需要舍入,会被拒绝。归一后,100.0 与 100.00 对应相等的 Money,无变化申请的判断不会因小数位数不同而失效。BigDecimal.equals 同时比较数值和 scale,具体运算与舍入约定见 BigDecimal API。
HTTP DTO 使用 @NotNull 检查必填,金额规则放在 Money 中,因此任务和内部调用同样受约束。金额上限是该业务契约的选择,数据库采用匹配的 NUMERIC(12,2);数据库类型本身不能替应用决定零价是否有效。扩展多币种时,币种必须进入值相等及运算检查,不能只修改响应中的 currency 文本。
聚合通过动作修改,不开放字段组合
Pricing.submit 先核对版本,再拒绝已有待审申请和相同目标价。通过后创建不可变 PendingChange,版本递增,当前价不变。approve 与 reject 共享对待审申请的检查:
private Decision decide(long expectedVersion, UUID proposalId, Decision.Outcome outcome) {
checkVersion(expectedVersion);
if (pending == null || !pending.proposalId().equals(proposalId)) {
throw new RuleViolation("NO_MATCHING_PROPOSAL", "未找到匹配的待审申请");
}
Money oldPrice = currentPrice;
Money target = pending.target();
if (outcome == Decision.Outcome.APPROVED) {
currentPrice = target;
}
pending = null;
version++;
return new Decision(proposalId, productId, target, outcome, oldPrice, currentPrice, version);
}公开方法 approve 固定传入 APPROVED,reject 固定传入 REJECTED。外部调用者无需分别设置当前价、申请状态和结果,也没有任意组合这三个字段的写入口。
Decision 保存动作产生的不可变结果,包括原价、目标价、动作后的价格和版本。驳回时新价等于原价,但目标价仍保留,能够解释被拒绝的是哪次改价。它目前只是应用内的业务结果,保存成功才有对应的数据库历史。
从数据库读取时,仓储通过 Pricing.restore(...) 重建对象,检查版本非负、待审价不同于当前价等状态约束。重建不调用 submit 或 approve,也不产生新的决定。否则每次查询都可能重复推进版本,甚至把旧记录当成新业务操作。
应用服务确定一次操作的事务范围
PricingService 标有 @Service,通过构造器注入 PricingRepository。批准用例的完整方法很短:
@Transactional
public Pricing approve(UUID productId, UUID proposalId, long version) {
Pricing pricing = load(productId);
Decision decision = pricing.approve(version, proposalId);
repository.save(pricing, version, decision);
return pricing;
}load 找不到定价时抛出 PRICING_NOT_FOUND。领域方法检查当前对象,仓储保存整个修改;正常返回后,事务代理尝试提交,Controller 才把结果转换为响应 DTO。RuleViolation 是运行时异常,数据访问异常也会离开方法触发回滚。
该事务依赖调用经过 Spring 代理。Controller 注入并调用 Service 可以满足这一点;手工 new PricingService(...),或在同一个对象内直接调用自己的事务方法,不会自动得到同样的代理拦截。默认回滚规则和代理限制见 Spring 事务注解。
真实审批还需要在用例入口检查可信主体的权限。如果增加“申请人不能批准自己的申请”这一业务规则,模型就要保存申请人,并接收经过认证的操作者标识。只在 Controller 判断 JSON 中的 role=admin,无法建立可信的授权条件。
仓储保存业务对象,数据库处理并发提交
领域存储接口声明 find、add、save 和按申请查询决定的 findDecision。add 只用于首次建立定价,已存在时返回冲突;save 更新已有聚合,同时保存本次可选的决定,不能从名称推断成任意 upsert。
JDBC 实现先更新当前快照:
PendingChange pending = pricing.pending();
int changed = jdbc.update("""
UPDATE pricings SET current_price = ?, version = ?, pending_id = ?, pending_price = ?
WHERE product_id = ? AND version = ?
""", pricing.currentPrice().amount(), pricing.version(),
pending == null ? null : pending.proposalId(),
pending == null ? null : pending.target().amount(), pricing.productId(), expectedVersion);
if (changed != 1) {
throw new RuleViolation("STALE_VERSION", "定价版本已变化,请重新读取后判断");
}成功后若存在 Decision,再向 pricing_decisions 插入申请、结果、各金额和提交版本。两条 SQL 使用同一个 JdbcTemplate、DataSource 和应用事务;参数通过占位符绑定,金额或标识不拼进 SQL 文本。Spring JDBC 核心操作
pricings 的约束要求待审 ID 和金额同时为空,或同时有效且目标价不同于当前价。决定表以申请 ID 为主键,并检查批准时新价等于目标价、驳回时新价等于原价。对象方法给出业务错误,数据库约束防止错误状态被实际存入;约束的空值与唯一性规则见 PostgreSQL Constraints。
快照与决定分两条 SQL 写入,所以事务仍然必要。第二条失败后,数据库会恢复原价、待审申请和原版本;刚才内存里已经修改的 Pricing 不会自动倒退。应用让异常结束请求,丢弃这份对象,下次重新加载,避免继续使用没有成功提交的内存状态。
验证规则在失败与并发时仍然成立
驳回后可以继续申请
在前面已经批准到 120.00、版本为 2 的商品上,提交新的 130.00 申请,然后驳回:
curl -q --noproxy '*' --fail-with-body -sS \
-H 'Content-Type: application/json' -d '{"version":2,"targetPrice":"130.00"}' \
"$API/pricings/$PRODUCT_ID/proposals" -o "$RUN_DIR/second-pending.json"
SECOND_ID=$(jq -er '.pending.proposalId' "$RUN_DIR/second-pending.json")
VERSION=$(jq -er '.version' "$RUN_DIR/second-pending.json")
jq -n --argjson v "$VERSION" '{version:$v}' > "$RUN_DIR/reject.json"
curl -q --noproxy '*' --fail-with-body -sS \
-H 'Content-Type: application/json' --data-binary @"$RUN_DIR/reject.json" \
"$API/pricings/$PRODUCT_ID/proposals/$SECOND_ID/reject" \
-o "$RUN_DIR/rejected.json"
jq -e '.currentPrice == 120 and .pending == null and .version == 4' \
"$RUN_DIR/rejected.json"
curl -q --noproxy '*' --fail-with-body -sS \
"$API/decisions/$SECOND_ID" -o "$RUN_DIR/rejected-decision.json"
jq . "$RUN_DIR/rejected-decision.json"决定为 REJECTED,目标价 130.00,旧价和新价都为 120.00,提交版本 4。批准和驳回都会结束申请,区别在于是否应用目标价。用版本 4 可以再次申请;已经结束的申请 ID 不会重新成为待审申请。
两处版本检查分别防住什么
领域方法先比较请求版本与载入版本,处理客户端已经过期的操作。但两个请求可能在任何一方提交之前,都读到版本 1:
事务 A:读取 v1 → 领域批准通过 → UPDATE WHERE version=1 → 成功 → 写决定 → COMMIT
事务 B:读取 v1 → 领域批准通过 → UPDATE WHERE version=1 → 等待 A → 影响 0 行
└→ 冲突,回滚两份内存对象都满足批准条件,真正竞争发生在数据库行上。在 PostgreSQL 默认 Read Committed 隔离级别下,竞争更新等待前一个事务结束;前一个事务提交后,会对新行版本重新判断更新条件。版本已经变为 2,后一个更新便不能匹配 version=1。PostgreSQL Transaction Isolation
PricingApiTest 的并发用例使用两个线程、两个真实事务和一个会合点,要求双方都读取版本 1 后才开始更新。结果必须恰好是一次 COMMITTED、一次 STALE_VERSION,数据库保留版本 2 和一条决定。它与先后发送两个 curl 请求不同:后者能观察旧版本拒绝,但未必制造读后竞争窗口。
收到冲突后,应保留原申请 ID,重新读取状态和决定,再由调用方判断。自动换成最新版本会绕过调用方对新状态的确认;这里仍有申请 ID 检查,可以阻止审核后续的另一份申请,不能连申请 ID 也一起替换后重试。若选择悲观锁,锁等待和事务时长会改变;若选择跨聚合异步流程,还要设计中间状态和补偿,不能只删除版本条件。
第二条写入失败时检查所有相关状态
回滚测试通过真实 HTTP 提交批准,并仅在专用测试库的决定表上安装临时触发器,令 INSERT pricing_decisions 抛出数据库异常。失败位于快照更新之后,因而能检查事务是否真的覆盖两次写入。
| 观察位置 | 第二条写入失败后 | 移除测试触发器并重试后 |
|---|---|---|
| 当前价 | 保持原价 | 变为待审目标价 |
| 待审申请 | 仍是原申请 | 清空 |
| 聚合版本 | 保持原版本 | 增加 1 |
| 决定记录 | 无新增 | 对应申请恰有 1 条 |
| HTTP 响应 | 500,通用内部错误 | 200,返回新快照 |
测试方法没有外层事务,读回的是请求结束后数据库已经提交的状态。临时故障只存在于测试代码,公开接口没有“让数据库失败”的开关。触发器移除后,还要用同一份有效申请成功批准,检查连接和后续操作可以继续。
在工程目录单独运行这组真实数据库与 HTTP 测试:
docker compose run --rm --user "$(id -u):$(id -g)" verify \
mvn -B -ntp -Dmaven.repo.local=/cache/repository -Duser.home=/tmp \
-Dtest=PricingApiTest clean verifytarget/surefire-reports/com.example.pricing.PricingApiTest.txt 保存结果。日志中的故意数据库异常不等同于测试失败,应检查报告的 Failures 和 Errors。没有找到测试类或报告时,不能把空执行解释成验证成功。
金额归一化、非法金额、提交与审核规则、重建和版本上限由纯 Java 的 PricingTest 覆盖,无需启动 Spring。默认完整 verify 同时运行这两类测试。使用另一个 JDK 复核时:
MAVEN_IMAGE=maven:3.9.12-eclipse-temurin-25 \
docker compose run --rm --user "$(id -u):$(id -g)" verify编译目标仍为 Java 17。修改源码后先重新 verify,再执行 docker compose up -d --build app;Dockerfile 复制已生成的 JAR,单独执行 up --build 不会替代 Java 编译。
新规则进入后怎样调整设计
用新的业务例子检查原有假设
规则变化会指出应该调整的对象。例如批准后还要预约生效,原来的“批准即改价”便失效,需要区分已批准、待生效和已生效,并确定生效失败时的处理。直接给当前 approve 方法增加一个时间参数,却继续立即覆盖当前价,会使接口名称和真实行为分离。
如果按销售渠道分别定价,可考虑把商品 ID 与渠道组合成定价身份,让互不影响的渠道修改不争用同一个版本。若业务允许多份申请竞争,则需要规定先批准哪份、其他申请是否作废,以及过期申请能否继续按原目标价生效;“把 pending 改成 List”还不足以回答这些问题。
从现有 CRUD 迁移时,可以先选改价这一项操作:补行为测试,抽出 Money 和改价动作,再让应用服务调用它们,最后把原有 SQL 或 MyBatis-Plus Mapper 放入仓储实现。原来的通用改价入口必须同步收紧,否则仍可绕过审批直接写售价。查询页面可以继续使用简单 DTO,无需为了统一形式加载整个聚合。
领域事件怎样交给其他上下文
批准后,搜索页面或价格缓存可能需要获知“价格已变更”。领域事件表达这种已经发生的事实,通常携带业务 ID、变更后的值和可用于识别顺序的版本。驳回也会产生审核结果,却没有发生价格生效变化,不能将所有决定都命名为 PriceChanged。
当前工程把决定保存到本地历史,没有实现发布器、消息确认或重试。需要可靠通知时,可在同一数据库事务中追加 Outbox 记录,由独立发布过程投递,消费者再处理重复和顺序,见 Outbox、CDC 与领域事件。提交后直接调用一次消息发送,仍会留下“数据库成功、发送失败”的窗口。
历史表也没有承担事件溯源:当前状态从 pricings 快照加载,并未通过重放所有历史事件重建。是否采用事件溯源、完整工作流引擎或跨上下文流程管理,应由历史重建、审批变化和协作需求决定,不作为引入 DDD 的固定附带组件。
从具体错误定位到对应实现
| 现象或错误码 | 先检查什么 | 处理方向 |
|---|---|---|
INVALID_MONEY / 400 | 数值是否正数、范围与精度是否有效 | 修正输入;需要舍入时先确定业务舍入规则 |
PENDING_EXISTS / 409 | 当前 pending.proposalId | 处理现有申请,不能覆盖它 |
STALE_VERSION / 409 | 最新快照与本次申请的决定 | 重新判断操作,不盲目替换为最新版本重试 |
NO_MATCHING_PROPOSAL / 409 | 指定申请是否已结束,或 ID 是否来自其他商品 | 按申请 ID 查询决定,核对商品关联 |
PRICING_NOT_FOUND / 404 | 商品是否已建立定价,数据库是否选错 | 确认正确环境,再建立必要状态 |
| 500,日志有 SQL 或连接错误 | 应用日志、数据库连通性、对应表约束 | 修复实际原因,再读回快照和决定确认结果 |
| 修改源码后接口仍是旧行为 | JAR 是否重建、容器是否重新创建 | verify 后重新构建并启动 app |
只查看本次应用和数据库:
docker compose ps
docker compose logs --tail=100 app
docker compose exec -T db pg_isready -h 127.0.0.1 -U postgres -d postgres
curl -q --noproxy '*' --fail-with-body -sS \
"$API/pricings/$PRODUCT_ID" -o "$RUN_DIR/diagnose.json"
jq . "$RUN_DIR/diagnose.json"应用通过 SPRING_DATASOURCE_URL、SPRING_DATASOURCE_USERNAME 和 SPRING_DATASOURCE_PASSWORD 连接 pricing_app;verify 使用 pricing_test。schema.sql 帮助初次建表,CREATE TABLE IF NOT EXISTS 不会为旧表补列。字段变化应走实际数据库迁移流程,不能把删除数据卷作为升级步骤。
实验结束在工程目录执行 docker compose down,停止当前项目的容器并移除网络。数据库命名卷、源码、缓存和测试报告保留,不需要清理 Docker 中的其他应用。
权威资料与规范地址
领域建模与模型协作
- DDD 的定位与持续建模:https://martinfowler.com/bliki/DomainDrivenDesign.html
- 统一语言:https://martinfowler.com/bliki/UbiquitousLanguage.html
- 限界上下文:https://martinfowler.com/bliki/BoundedContext.html
- 聚合与聚合根:https://martinfowler.com/bliki/DDD_Aggregate.html
- Eric Evans 的模式与术语参考:https://www.domainlanguage.com/ddd/reference/
Java、Spring 与运行命令
- BigDecimal 精度、比较与舍入:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/math/BigDecimal.html
- Spring Boot 系统要求:https://docs.spring.io/spring-boot/system-requirements.html
- Spring 事务注解与代理调用:https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/annotations.html
- JdbcTemplate 与参数绑定:https://docs.spring.io/spring-framework/reference/data-access/jdbc/core.html
- Docker Compose 启动与健康等待:https://docs.docker.com/reference/cli/docker/compose/up/
