你口口声声说在做 DDD——那你的限界上下文到底“边界”在哪儿?

举报
bug菌 发表于 2026/01/13 10:52:20 2026/01/13
【摘要】 🏆本文收录于《滚雪球学SpringBoot 3》:https://blog.csdn.net/weixin_43970743/category_12795608.html,专门攻坚指数提升,本年度国内最系统+最专业+最详细(永久更新)。  本专栏致力打造最硬核 SpringBoot3 从零基础到进阶系列学习内容,🚀均为全网独家首发,打造精品专栏,专栏持续更新中…欢迎大家订阅持续学习。...

🏆本文收录于《滚雪球学SpringBoot 3》:https://blog.csdn.net/weixin_43970743/category_12795608.html,专门攻坚指数提升,本年度国内最系统+最专业+最详细(永久更新)。
  
本专栏致力打造最硬核 SpringBoot3 从零基础到进阶系列学习内容,🚀均为全网独家首发,打造精品专栏,专栏持续更新中…欢迎大家订阅持续学习。 如果想快速定位学习,可以看这篇【SpringBoot3教程导航帖】https://blog.csdn.net/weixin_43970743/article/details/151115907,你想学习的都被收集在内,快速投入学习!!两不误。
  
若还想学习更多,可直接前往《滚雪球学SpringBoot(全版本合集)》:https://blog.csdn.net/weixin_43970743/category_11599389.html,涵盖SpringBoot所有版本教学文章。

演示环境说明:

  • 开发工具:IDEA 2021.3
  • JDK版本: JDK 17(推荐使用 JDK 17 或更高版本,因为 Spring Boot 3.x 系列要求 Java 17,Spring Boot 3.5.4 基于 Spring Framework 6.x 和 Jakarta EE 9,它们都要求至少 JDK 17。)
  • Spring Boot版本:3.5.4(于25年7月24日发布)
  • Maven版本:3.8.2 (或更高)
  • Gradle:(如果使用 Gradle 构建工具的话):推荐使用 Gradle 7.5 或更高版本,确保与 JDK 17 兼容。
  • 操作系统:Windows 11

1) 战略设计:用 Spring Boot 多模块把 Bounded Context “钉死”在结构里

先说个很直白的真相:限界上下文如果只写在文档里,那它迟早会变成“情绪价值”
你需要一种机制,让边界“有形”——能被构建系统看到、能被依赖关系表达、最好还能被 CI 保护。多模块就是最朴素但很有效的办法。

Spring 官方就有一篇 Guide 专门讲 Spring Boot 的 multi-module 项目:一个主应用 + 一个/多个库模块(Jar)来组织结构。
这份 Guide 的落点不是 DDD,但它给了一个关键能力:把“边界”从包级别提升到模块级别

1.1 推荐结构:一个 BC = 一个子模块(别在一个模块里硬塞多个 BC)

举个电商场景(你可以换成你自己的领域):

acme-shop/
  pom.xml (parent)
  bootstrap/                 # 只做启动、装配、配置(薄)
  order-bc/                  # Bounded Context: Order
  inventory-bc/              # Bounded Context: Inventory
  payment-bc/                # Bounded Context: Payment
  shared-kernel/             # (可选)共享内核:非常克制地放通用值对象/接口

为什么我建议 BC 用子模块而不是“一个工程里分几个 package”?
因为模块依赖是“硬”的:

  • inventory-bc 想直接引用 order-bc 的内部实现?先过 Maven/Gradle 的依赖声明这一关。
  • 你可以在代码评审里一句话结束争论:“不允许加依赖,这个要用事件/ACL 解决。”
    那种感觉,怎么说呢……有点爽😎。

Spring Boot 的多模块示例 Guide 里就是这种“主应用依赖库模块”的结构化方式。

1.2 模块之间怎么通信?先定“规则”,再选“技术”

DDD 的战略设计层面,模块间通信你大致就三种路线:

  1. 共享内核(Shared Kernel):少量、稳定的共同语言(谨慎使用)
  2. Open Host Service / Published Language:对外发布稳定 API(比如 HTTP/消息)
  3. 防腐层(ACL):把外部模型翻译成内部模型(后面第 4 节细讲)

这里先立个规矩:

  • BC 内部:允许富领域模型、允许强一致事务
  • BC 之间:优先事件驱动或 ACL,避免直接实体耦合
    否则你会得到一个“看似模块化、实则大一统”的怪物:模块在,但语言没边界;上下文在,但模型被污染。

2) 战术实现:Entity、Value Object(Records)、Aggregate Root ——别写成“只有 getter 的展览品”

战术建模最容易踩坑的点是:
你以为你在写 Entity,其实你在写 DTO;你以为你在写聚合,其实你在写数据库表的镜像。
DDD 的对象要承载业务规则,不是只承载字段。

下面我用一个“下单”聚合给你一套可落地代码(Java 17+,Spring Boot 3 默认就是 Java 17 起步)。

2.1 Value Object:用 Java Records 写“值对象”,又短又硬

OpenJDK 的 Records(JEP 395)定位非常明确:它是不可变数据的透明载体,会自动生成构造器、访问器、equals/hashCode/toString 等。
这恰好非常适合 Value Object:不可变 + 值相等

示例:Money、OrderId(值对象)

package com.acme.order.domain;

import java.math.BigDecimal;
import java.util.Currency;
import java.util.Objects;

public record Money(BigDecimal amount, Currency currency) {

  public Money {
    Objects.requireNonNull(amount, "amount");
    Objects.requireNonNull(currency, "currency");
    if (amount.scale() > 2) {
      throw new IllegalArgumentException("Money scale must be <= 2");
    }
    if (amount.signum() < 0) {
      throw new IllegalArgumentException("Money cannot be negative");
    }
  }

  public Money add(Money other) {
    requireSameCurrency(other);
    return new Money(this.amount.add(other.amount), this.currency);
  }

  private void requireSameCurrency(Money other) {
    if (!this.currency.equals(other.currency)) {
      throw new IllegalArgumentException("Currency mismatch");
    }
  }
}
package com.acme.order.domain;

import java.util.Objects;
import java.util.UUID;

public record OrderId(UUID value) {
  public OrderId {
    Objects.requireNonNull(value, "orderId");
  }
  public static OrderId newId() {
    return new OrderId(UUID.randomUUID());
  }
}

注意我在 record 里写了“紧凑构造器”做校验,这很关键:
值对象的规则要在创建时就成立,否则你会在系统里到处写 if/validate,最后谁都不信任数据。

2.2 Entity:有身份(Identity),状态会变,但规则要收敛在行为里

订单行(OrderLine)通常是实体或聚合内部实体:有 lineId,数量可变,价格不可变(看业务)。

package com.acme.order.domain;

import java.util.Objects;
import java.util.UUID;

public class OrderLine {

  private final UUID lineId;
  private final String sku;
  private int quantity;
  private final Money unitPrice;

  private OrderLine(UUID lineId, String sku, int quantity, Money unitPrice) {
    this.lineId = Objects.requireNonNull(lineId);
    this.sku = Objects.requireNonNull(sku);
    if (quantity <= 0) throw new IllegalArgumentException("quantity must be > 0");
    this.quantity = quantity;
    this.unitPrice = Objects.requireNonNull(unitPrice);
  }

  public static OrderLine create(String sku, int quantity, Money unitPrice) {
    return new OrderLine(UUID.randomUUID(), sku, quantity, unitPrice);
  }

  public void increase(int delta) {
    if (delta <= 0) throw new IllegalArgumentException("delta must be > 0");
    this.quantity += delta;
  }

  public Money lineTotal() {
    return new Money(unitPrice.amount().multiply(new java.math.BigDecimal(quantity)), unitPrice.currency());
  }

  // getters(尽量少暴露可变状态的直接 setter)
  public UUID lineId() { return lineId; }
  public String sku() { return sku; }
  public int quantity() { return quantity; }
  public Money unitPrice() { return unitPrice; }
}

2.3 Aggregate Root:业务一致性边界,别让外界随便拿内部实体“做手工活”

订单聚合作为 Aggregate Root:

  • 保证不变量(例如:状态流转合法、不能对已支付订单再改价)
  • 控制内部集合的修改入口
  • 负责发布领域事件(下一节会用 AbstractAggregateRoot

3) Domain Events:用 AbstractAggregateRoot 把“领域事件发布”变成标准动作

领域事件这东西,写对了像开挂:解耦、可扩展、可审计;写歪了像灾难:到处发 event、到处监听、业务链路变成“玄学漂流瓶”。
Spring Data 在这块给了你很官方、很顺滑的一套机制。

Spring Data 文档专门有一节讲“从聚合根发布领域事件”:你可以用 @DomainEvents 暴露事件集合,并用 @AfterDomainEventPublication 在发布后做清理。

同时,AbstractAggregateRoot 作为聚合根基类,提供 registerEvent(...) 来收集领域事件,并通过 domainEvents() 暴露。

换句话说:你不需要自己造“事件列表 + 清理逻辑”的轮子,Spring Data 已经把常见模式封装好了。

3.1 聚合根示例:Order 继承 AbstractAggregateRoot

package com.acme.order.domain;

import org.springframework.data.domain.AbstractAggregateRoot;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public class Order extends AbstractAggregateRoot<Order> {

  private OrderId id;
  private OrderStatus status;
  private final List<OrderLine> lines = new ArrayList<>();

  protected Order() {} // for JPA if needed

  private Order(OrderId id) {
    this.id = Objects.requireNonNull(id);
    this.status = OrderStatus.DRAFT;
  }

  public static Order draft() {
    return new Order(OrderId.newId());
  }

  public void addLine(String sku, int qty, Money unitPrice) {
    ensureStatus(OrderStatus.DRAFT);
    lines.add(OrderLine.create(sku, qty, unitPrice));
  }

  public Money total() {
    Money sum = new Money(java.math.BigDecimal.ZERO, java.util.Currency.getInstance("USD"));
    for (var line : lines) {
      sum = sum.add(line.lineTotal());
    }
    return sum;
  }

  public void place() {
    ensureStatus(OrderStatus.DRAFT);
    if (lines.isEmpty()) {
      throw new IllegalStateException("Cannot place order with no lines");
    }
    this.status = OrderStatus.PLACED;

    // 关键:注册领域事件(不会立刻发,交给 Spring Data 在 repository 保存后发布)
    registerEvent(new OrderPlaced(this.id.value()));
  }

  private void ensureStatus(OrderStatus expected) {
    if (this.status != expected) {
      throw new IllegalStateException("Invalid state transition: " + status + " -> expected " + expected);
    }
  }

  public OrderId id() { return id; }
  public OrderStatus status() { return status; }
  public List<OrderLine> lines() { return List.copyOf(lines); }
}
package com.acme.order.domain;

import java.util.UUID;

public record OrderPlaced(UUID orderId) {}
package com.acme.order.domain;

public enum OrderStatus {
  DRAFT, PLACED, PAID, CANCELLED
}

3.2 为什么这个发布方式“靠谱”?

因为 Spring Data 的域事件机制是围绕聚合根与仓库生命周期设计的:

  • 聚合根收集事件(registerEvent / @DomainEvents
  • 仓库保存聚合时发布事件(作为 Spring 应用事件)
  • 发布后可触发回调做清理(@AfterDomainEventPublication

这套设计最大的好处是:事件跟聚合的状态变更绑定得很紧,不会出现“状态没落库但事件先飞出去”的尴尬(当然,你仍然要考虑事务边界与最终一致,这属于架构层面的选择)。

4) Repository 模式 + Spring Data 的防腐层(ACL):别让 Spring Data 的接口“污染”你的领域语言

这一节我想讲得更“狠”一点:
很多项目所谓的 Repository,其实就是把 JpaRepository 直接暴露给领域/应用层,然后大家开始在服务里写:
orderRepo.findByStatusAndCreatedAtBetweenAnd...
写着写着你会发现,你的领域语言变成了“SQL 语言的口音”,而且还夹着 Spring Data 的各种细节。
这就是模型被“基础设施”反向绑架的经典现场。

Spring Data 的 Repository 抽象本意是减少数据访问样板代码,提供一致的仓库接口概念。
但 DDD 的 Repository 模式强调:仓库是领域概念的一部分(集合的抽象),而不是数据库查询 DSL。

4.1 目标:领域层定义 Repository 接口,基础设施层用 Spring Data 去实现(适配器)

推荐分层(按 DDD 常见四层):

order-bc
  ├─ domain
  │   ├─ Order (Aggregate Root)
  │   ├─ OrderRepository (领域接口)
  │   └─ ...
  ├─ application
  │   ├─ PlaceOrderService
  │   └─ ...
  └─ infrastructure
      ├─ JpaOrderEntity / mapping
      ├─ SpringDataOrderJpaRepository
      └─ OrderRepositoryAdapter (实现 domain.OrderRepository)

4.2 领域层:定义“说人话”的仓库接口

package com.acme.order.domain;

import java.util.Optional;
import java.util.UUID;

public interface OrderRepository {

  Optional<Order> findById(UUID id);

  void save(Order order);
}

你看,这接口很朴素,但它的好处是:

  • 它不暴露 Spring Data
  • 它不强迫领域知道 JPA Entity / 注解 / 懒加载
  • 它只表达“领域需要什么集合能力”

4.3 基础设施层:Spring Data 只在这里出现(防腐层的第一步)

Spring Data 的文档明确说明了仓库抽象的核心概念与接口体系(比如 Repository、CrudRepository 等)。
但我们把它关在 infrastructure 里:

package com.acme.order.infrastructure;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.UUID;

interface SpringDataOrderJpaRepository extends JpaRepository<OrderJpaEntity, UUID> {}

接着写适配器,把 Spring Data 的 Entity 映射为领域模型:

package com.acme.order.infrastructure;

import com.acme.order.domain.Order;
import com.acme.order.domain.OrderRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;
import java.util.UUID;

@Repository
class OrderRepositoryAdapter implements OrderRepository {

  private final SpringDataOrderJpaRepository jpa;

  OrderRepositoryAdapter(SpringDataOrderJpaRepository jpa) {
    this.jpa = jpa;
  }

  @Override
  public Optional<Order> findById(UUID id) {
    return jpa.findById(id).map(OrderMapper::toDomain);
  }

  @Override
  public void save(Order order) {
    jpa.save(OrderMapper.toJpa(order));
  }
}

OrderMapper 负责隔离 JPA 注解与领域对象结构(这就是防腐层的“翻译官”角色)。

4.4 为什么这算“防腐层”?

因为你的领域模型不会被这些东西污染:

  • JPA 的 @Entity/@Id/@ManyToOne
  • Spring Data 的查询方法命名规则
  • 延迟加载、脏检查、Entity 生命周期等持久化细节

更现实一点说:
你以后要把 JPA 换成别的存储,或者要引入 CQRS 读模型,领域层基本不用动——痛苦主要集中在 infrastructure,这就叫“把复杂留给边界”。

结尾:落地 DDD 的关键不是“写了多少战术名词”,而是“你敢不敢守住边界”

如果你读到这里有一点点共鸣,那我想再送你一句非常不性感但很实用的话:
DDD 不是一次性重构,是持续的边界维护。

你可以从三件小事开始(真的不夸张):

  1. 先把 BC 切到多模块(让边界有形)
  2. 把 Value Object 用 records 写起来(让不变量早失败)
  3. 聚合根统一用 AbstractAggregateRoot 管领域事件(让事件发布有标准姿势)

🧧福利赠与你🧧

  无论你是计算机专业的学生,还是对编程有兴趣的小伙伴,都建议直接毫无顾忌的学习此专栏「滚雪球学SpringBoot」,bug菌郑重承诺,凡是学习此专栏的同学,均能获取到所需的知识和技能,全网最快速入门SpringBoot,就像滚雪球一样,越滚越大, 无边无际,指数级提升。

最后,如果这篇文章对你有所帮助,帮忙给作者来个一键三连,关注、点赞、收藏,您的支持就是我坚持写作最大的动力。

同时欢迎大家关注公众号:「猿圈奇妙屋」 ,以便学习更多同类型的技术文章,免费白嫖最新BAT互联网公司面试题、4000G PDF编程电子书、简历模板、技术文章Markdown文档等海量资料。

ps:本文涉及所有源代码,均已上传至Gitee:https://gitee.com/bugjun01/SpringBoot-demo 开源,供同学们一对一参考 Gitee传送门https://gitee.com/bugjun01/SpringBoot-demo,同时,原创开源不易,欢迎给个star🌟,想体验下被🌟的感jio,非常感谢❗

🫵 Who am I?

我是 bug菌:

  • 热活跃于 CSDN:https://blog.csdn.net/weixin_43970743 | 掘金:https://juejin.cn/user/695333581765240 | InfoQ:https://www.infoq.cn/profile/4F581734D60B28/publish | 51CTO:https://blog.51cto.com/u_15700751 | 华为云:https://bbs.huaweicloud.com/community/usersnew/id_1582617489455371 | 阿里云:https://developer.aliyun.com/profile/uolxikq5k3gke | 腾讯云:https://cloud.tencent.com/developer/user/10216480/articles 等技术社区;
  • CSDN 博客之星 Top30、华为云多年度十佳博主&卓越贡献奖、掘金多年度人气作者 Top40;
  • 掘金、InfoQ、51CTO 等平台签约及优质作者;
  • 全网粉丝累计 30w+

更多高质量技术内容及成长资料,可查看这个合集入口 👉 点击查看:https://bbs.csdn.net/topics/612438251 👈️
硬核技术公众号 「猿圈奇妙屋」https://bbs.csdn.net/topics/612438251 期待你的加入,一起进阶、一起打怪升级。

- End -

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。