Chapter 06

战术构件:仓储、工厂、领域服务 ≠ 应用服务

上一章把模型收进了聚合边界——谁是聚合根、不变量多大、事务多大都定下了。本章补齐三个边界外的问题:聚合怎么取出来、怎么造出来、不属于任何单个实体的逻辑放哪,并钉死最易混淆的一对——领域服务 (Domain Service) 与应用服务 (Application Service)。

本章你将建立的 schema

  • 仓储 (Repository) 是聚合根的"集合幻觉":一个聚合根一个仓储,只暴露 add / ofId / remove,不漏 SQL、不返回 DTO
  • 工厂 (Factory) 封装复杂创建,保证产出即满足全部不变量——造出来就是合法的
  • 领域服务装真实领域逻辑(不属于任何单个实体的那一块),名字进统一语言
  • 应用服务是薄编排层:load → 调领域服务 → 工厂造 → 仓储存 → 发事件,零业务规则
  • 把领域逻辑流进应用服务 / 分层 Service 层,是最常见的错误——又退回 01 章的贫血模型

全章用「云市」交易平台的一次下单贯穿:销售上下文 (Sales) 里有 Order 聚合(05 章定下的聚合根),现在要把它取出、造出、定价、落库、发事件。每个战术构件都回到这次下单,看它在这条链路上承担哪一段职责。命名严格沿用统一语言:Order / OrderLine / Money / buyerId / OrderRepository / PricingService / PlaceOrderApplicationService / OrderPlaced。

6.1仓储 Repository:聚合根的集合幻觉 + 持久化无知

仓储是对每个聚合根制造的一层"内存集合"幻觉:领域代码像操作一个 Set<Order> 那样 add / ofId / remove,背后是数据库还是别的,它一律不知道。

为什么需要它

聚合是一致性单位,加载与保存必须以整个聚合为粒度,否则不变量会被绕过(加载半个 Order、只改一行 OrderLine、漏算 total)。如果让应用层直接写 SQL,持久化细节就会渗进领域逻辑,聚合根的边界形同虚设。仓储把"如何持久化"这件事关进一个接口背后——领域层只声明"我要按 id 拿这个聚合 / 把这个聚合放回去",这就是持久化无知 (Persistence Ignorance)。

底层机制(比定义深一层):仓储不是 DAO。DAO 围着表转,方法形如 insertOrderRow / selectLinesByOrderId,按行、按列暴露存储结构;仓储围着聚合根转,进出的永远是一个完整的、不变量已成立的 Order。两条硬约束由此推出:

  • 一个聚合根一个仓储。OrderLine 是 Order 的内部实体(05 章定的),它没有全局身份,外部不可直接持有,所以不存在 OrderLineRepository。要改某一行,先 orderOfId 取出整个 Order,在聚合内部改,再整体存回。
  • 只暴露聚合视角的三个动作:add(order)(放进集合)、orderOfId(orderId)(按身份取出)、remove(order)(移出集合)。接口里不出现 SQL,不返回 DTO——返回 DTO 等于把存储/展示形状泄漏进领域,聚合就不再是唯一真相源。
OrderRepository:聚合根的集合接口(领域层)Java
// 领域层只看到这个接口——它不知道 JPA、不知道 MyBatis、不知道任何 SQL
public interface OrderRepository {

    // 放进"集合":新建或更新整个聚合,以聚合为粒度
    void add(Order order);

    // 按身份取出整个聚合(不是取一行、不是取 DTO)
    Order orderOfId(OrderId orderId);

    // 从"集合"移出
    void remove(Order order);

    // 工厂方法风格的下一个身份——身份由领域决定,不等数据库自增
    OrderId nextIdentity();
}

// 实现放在基础设施层,领域层对它一无所知(持久化无知)
public class JpaOrderRepository implements OrderRepository {
    private final EntityManager em;
    // ...用 JPA 把整个 Order 聚合映射进出;SQL 全部锁在这一层
}

读多怎么办?订单列表页要展示几十条订单的摘要,按聚合一个个 orderOfId 加载既慢又荒谬——那是在用一致性边界干查询的活。这正是把命令侧(改状态,走聚合+仓储,保证不变量)和查询侧(只读展示,走单独的查询服务,直接拼一个扁平视图)分开的理由,社区称作 CQRS(命令查询职责分离,先记白话:写和读用两套模型)。本章只用它的轻量形态——查询服务绕过聚合,直接出 DTO 给展示层;完整的 CQRS 与读模型在 08 章展开。

类比 · 带边界声明

仓储像图书馆的借还台:你只说"我要《某书》"或"我把这本还回去",馆员去哪个书架、用什么编目系统,你一概不管(持久化无知)。类比失效处:图书馆能让你只借走书里的一页,而仓储只整本进出——你不能只借走 Order 里的一个 OrderLine,因为半个聚合的不变量不成立。

想一想

一位工程师为了"性能",给仓储加了个方法 List<OrderLine> findLinesByOrder(OrderId id),直接返回订单项列表,让调用方逐行修改后再各自存回。这破坏了哪条聚合规则?后果是什么?

停 10 秒,再展开

它把聚合内部实体 OrderLine 当成可独立存取的东西暴露了出去,绕过了聚合根 Order。后果:调用方可以只改一行而不重算 total,于是"total == Σ(line.unitPrice × line.qty)"这条真正不变量(05 章)被静默破坏;并发下两个调用各改一行、各自存回,还会互相覆盖。修法是只留 orderOfId 取出整个 Order,让聚合根自己在内部完成修改并维持不变量。

与下一个构件的关系:仓储管"取出与存回已经存在的聚合"。但一个聚合第一次怎么诞生、且诞生即合法?这是工厂的职责。

6.2工厂 Factory:封装复杂创建,产出即满足全部不变量

工厂把"组装一个复杂聚合"的过程封装起来,并保证:一旦它返回了对象,这个对象已经满足全部不变量——不存在"造出来还没初始化好"的中间态。

为什么需要它

一个 new Order() 加一串 setter 的创建方式,会在堆里留下一个"半成品"窗口:total 还没算、状态还没设、订单项还没装。这个窗口里的对象违反不变量,却已经能被引用、被传递。工厂的价值是消灭这个窗口——把校验和组装收进一个原子的创建动作,让"非法的 Order"根本无法被构造出来。

底层机制(比定义深一层):工厂不一定是一个独立的 OrderFactory 类。当创建逻辑和聚合根本身绑得很紧时,最自然的形态是聚合根上的静态工厂方法。「云市」的下单用 Order.place(...):它接收买家身份、订单项原料和一个已算好的定价结果,在方法内部完成组装与全部校验,返回一个状态恰好等于 CREATED、total 已与各行金额对齐、订单项非空的合法 Order。构造器设为私有,外部没有别的路能造出 Order——这就把"合法性"从"调用方的自觉"变成了"类型系统的保证"。

Order.place(...):静态工厂,产出即合法(领域层)Java
public class Order {
    private final OrderId id;
    private final BuyerId buyerId;
    private final List<OrderLine> lines;
    private Money total;
    private OrderStatus status;

    // 构造器私有:外部没有别的路造出 Order,只能走工厂
    private Order(OrderId id, BuyerId buyerId, List<OrderLine> lines, Money total) {
        this.id = id; this.buyerId = buyerId;
        this.lines = lines; this.total = total;
        this.status = OrderStatus.CREATED;
    }

    // 静态工厂:传入买家、订单项原料、已算好的定价,产出一个合法的 CREATED 订单
    public static Order place(OrderId id, BuyerId buyerId,
                              List<OrderLine> lines, Pricing pricing) {
        if (lines == null || lines.isEmpty())
            throw new IllegalArgumentException("订单项不能为空");      // 不变量①
        Money total = pricing.totalOf(lines);
        if (!total.equals(sumOf(lines)))
            throw new IllegalStateException("总额必须等于各行金额之和"); // 不变量②
        return new Order(id, buyerId, List.copyOf(lines), total);
        // 返回时:lines 非空、total 已对齐、status = CREATED —— 全部不变量成立
    }
}

这里的校验不是"防御性编程"的随手 if,而是把 05 章定下的真正不变量钉在创建入口:订单项非空、total == Σ(line.unitPrice × line.qty)、状态从 CREATED 起步。工厂和聚合根的不变量是同一套,工厂只是把"进入合法状态"这一步做成原子的。

类比 · 带边界声明

工厂像出厂质检线:车开下生产线时已经四个轮子都装好、刹车测过,不存在"先交车再补刹车"的中间态。类比失效处:真实质检可能放过瑕疵品;而领域工厂是类型层面的保证——构造器私有,违反不变量的路径在编译/构造期就被堵死,不是"大概率合格"。

与下一个构件的关系:工厂里那个 Pricing pricing 参数——一笔订单的价格怎么算出来的?它既不只是 Order 的事,也不只是某张优惠券的事。这种"不属于任何单个实体"的逻辑,要放进领域服务。

6.3领域服务 Domain Service:不属于任何单个实体的真实领域逻辑

当一段真实的领域逻辑横跨多个对象、不自然归属于任何一个实体或值对象时,把它放进一个领域服务——它装的是领域逻辑本身,名字进入统一语言。

为什么需要它

「云市」算一笔订单价 = 商品单价 + 优惠券 + 会员折扣 + 满减。强行塞进 Order,Order 就得知道优惠券规则和会员体系;强行塞进 Coupon,优惠券就得知道整张订单和会员等级。它不纯粹属于其中任何一个。硬塞会让某个实体膨胀成无所不知的上帝对象。领域服务给这段逻辑一个它自己的家,且这个家在领域层、有领域名字。

底层机制(比定义深一层):领域服务有三个识别特征,缺一不可——否则它就该退回某个实体的方法。① 它表达一个领域概念("定价 Pricing"是销售上下文里业务方天天说的词,不是技术名词);② 它操作的是领域对象(订单项、优惠券、会员等级),进出参数都是领域类型,不是 DTO;③ 它无状态,逻辑只依赖入参。PricingService 三条都满足,所以它是领域服务,名字必须进统一语言——业务方说"定价",代码里就叫 PricingService。

PricingService:定价是领域逻辑,不归任何单个实体(领域层)Java
// 领域服务:无状态,进出都是领域对象,名字"定价"进入统一语言
public class PricingService {

    private final CouponPolicy couponPolicy;
    private final MembershipPolicy membershipPolicy;

    public PricingService(CouponPolicy couponPolicy, MembershipPolicy membershipPolicy) {
        this.couponPolicy = couponPolicy;
        this.membershipPolicy = membershipPolicy;
    }

    // 一笔订单价 = 单价合计 + 优惠券 + 会员折扣 + 满减,横跨多个领域对象
    public Pricing priceFor(List<OrderLine> lines, Coupon coupon, MemberLevel level) {
        Money subtotal   = sumOf(lines);                       // 单价 × 数量 合计
        Money afterCoupon = couponPolicy.apply(subtotal, coupon);   // 优惠券
        Money afterMember = membershipPolicy.discount(afterCoupon, level); // 会员折扣
        Money afterThreshold = applyFullReduction(afterMember); // 满减
        return new Pricing(afterThreshold);
    }
}

注意 PricingService 里没有事务、没有 orderRepository、没有发事件。它只回答一个领域问题:"这些订单项在这张优惠券、这个会员等级下,应该收多少钱?"取数据、存订单、发事件全不是它的事——那是下一节应用服务的活。这个"只算、不调度"的纯度,正是区分领域服务和应用服务的分水岭。

与下一个构件的关系:PricingService 算出了价,但谁去 load 购物车喂给它、谁拿它的结果调工厂造 Order、谁把 Order 存回仓储、谁开事务发事件?这条流水线的调度者是应用服务。

6.4应用服务 Application Service:薄编排层,零业务规则

应用服务是一层薄薄的编排:把用户的一个意图(下单)翻译成对领域对象的若干次调用,负责事务、安全、装配,自己不含任何业务规则。

为什么需要它

领域对象(聚合、领域服务)应当是纯净的领域逻辑,不该知道事务边界、当前登录用户、消息总线这些技术编排。但一个用例总要有人来开事务、查权限、按顺序把构件串起来。应用服务就是这个"调度者"——它存在的唯一理由是编排,所以它必须薄。一旦它开始写 if(校验、状态判断、金额计算),它就僭越了领域层。

底层机制(比定义深一层):「云市」的 PlaceOrderApplicationService 处理"下单"这个用例,它的方法体是一条线性流水线,每一步都是"委托给某个领域构件",自己不做决策:

PlaceOrderApplicationService:只编排,不含业务规则(应用层)Java
@Service
public class PlaceOrderApplicationService {

    private final CartRepository cartRepository;
    private final OrderRepository orderRepository;
    private final PricingService pricingService;       // 领域服务
    private final DomainEventPublisher eventPublisher;

    // 一个事务 = 一个用例;安全/事务是应用层的横切,不是领域的事
    @Transactional
    public OrderId placeOrder(PlaceOrderCommand cmd) {
        // 1. load:把输入装配成领域对象
        Cart cart = cartRepository.cartOf(cmd.buyerId());
        List<OrderLine> lines = cart.toOrderLines();

        // 2. 委托领域服务算价(业务规则在 PricingService 里,不在这里)
        Pricing pricing = pricingService.priceFor(
                lines, cmd.coupon(), cmd.memberLevel());

        // 3. 委托工厂造聚合(不变量校验在 Order.place 里,不在这里)
        OrderId id = orderRepository.nextIdentity();
        Order order = Order.place(id, cmd.buyerId(), lines, pricing);

        // 4. 存回仓储(怎么持久化是基础设施的事)
        orderRepository.add(order);

        // 5. 发领域事件(库存上下文异步预留,见 07 章)
        eventPublisher.publish(new OrderPlaced(id, cmd.buyerId(), lines));
        return id;
    }
}

数一数这段代码里的业务规则:零。订单项非空在 Order.place 里、金额算法在 PricingService 里、状态机在 Order 里。应用服务只做四件纯编排的事——开事务(@Transactional)、装配(cart → lines)、按顺序委托、发事件。它读起来像一份说明书的步骤列表,而不像一段算法。这就是"薄"的可观测标准:删掉它,没有任何领域知识丢失,只是没人调度了。

应用服务 PlaceOrder 只编排 购物车仓储 cartOf → lines 领域服务 PricingService 算价(领域逻辑) 工厂 Order.place 造出合法订单 订单仓储 add(order) ① load ② 调领域服务 ③ 工厂造 ④ 存回 ⑤ 发 OrderPlaced
图 6.1一次下单的调用编排:应用服务依次委托仓储、领域服务、工厂,最后发事件。注意:所有领域知识都在右侧被调用的构件里;左边的应用服务只画出"调用顺序",自己不含任何业务规则——这正是"薄"的样子。

与下一节的关系:领域服务和应用服务的方法签名长得很像(都接收领域对象、都返回结果),新手最常把它们混为一谈,甚至把领域逻辑顺手写进应用服务。下一节用对照把这条界线钉死。

6.5领域服务 ≠ 应用服务 ≠ 分层"Service 层"

三个"服务"是三件事:领域服务装领域逻辑、应用服务只编排、传统分层里的"Service 层"是一个位置而非一种职责——把领域逻辑倒进应用服务或 Service 层,就退回了 01 章的贫血模型。

为什么需要它

这是 DDD 落地时最常见的错误,且后果最重。熟悉 Controller/Service/DAO 三层的工程师,习惯把一切业务逻辑写进 Service 类。换上 DDD 的壳后,他们往往只是把 OrderService 改名叫 PlaceOrderApplicationService,逻辑原封不动——状态机、金额、非空校验还堆在里面,Order 依旧是一袋 getter/setter。这不是 DDD,这是贴了 DDD 标签的贫血模型。钉死这条界线,是这整章的重点。

判定法则(一句话):问"这段逻辑删掉,丢失的是领域知识还是调度步骤?"——丢失领域知识(怎么算价、能否从 CREATED 跳到 PAID、订单项能否为空),它属于领域层(实体方法或领域服务);丢失的只是调度步骤(先查谁、再调谁、在哪开事务),它属于应用服务。下表把三者钉在一起对照:

表 6.1 · 三个"服务"的职责边界
维度领域服务 Domain Service应用服务 Application Service分层"Service 层"
装什么真实领域逻辑(横跨多对象、不属任一实体)编排:load / 调用 / 存回 / 发事件一个位置,不规定职责(常被塞满一切)
所在层领域层 (Domain)应用层 (Application)传统三层的中间层
名字来源统一语言(PricingService)用例(PlaceOrder…)技术习惯(XxxService)
是否含业务规则是,这是它的全部否,含即僭越不定——这正是危险所在
事务 / 安全不碰由它负责常混在一起
典型例子PricingServicePlaceOrderApplicationService贫血的 OrderService.createOrder()

下面把同一个"下单"写成贫血反例,对照前面正确的 PlaceOrderApplicationService,看领域逻辑是怎么流进应用层、把模型抽空的:

反例:领域逻辑流进应用服务,Order 退化成数据袋Java
// ❌ 贫血反例:名字叫 Application Service,干的却是领域层的活
@Service
public class OrderApplicationService {

    @Transactional
    public OrderId createOrder(PlaceOrderCommand cmd) {
        Cart cart = cartRepository.cartOf(cmd.buyerId());
        List<OrderLine> lines = cart.toOrderLines();

        // ❌ 非空校验:这是订单的不变量,本该在 Order 里
        if (lines.isEmpty()) throw new IllegalArgumentException("订单项为空");

        // ❌ 金额算法:这是领域逻辑,本该在 PricingService 里
        Money total = Money.zero();
        for (OrderLine line : lines) total = total.plus(line.amount());
        total = total.minus(cmd.coupon().value());          // 优惠券规则散落在这
        if (cmd.memberLevel() == MemberLevel.VIP)            // 会员折扣硬编码在这
            total = total.times(0.95);

        // ❌ 状态机:从哪到哪能转,本该在 Order 里
        Order order = new Order();        // 半成品窗口:此刻 total/status 都还没设
        order.setBuyerId(cmd.buyerId());  // 一串 setter —— Order 沦为数据袋
        order.setLines(lines);
        order.setTotal(total);
        order.setStatus(OrderStatus.CREATED);

        orderRepository.add(order);
        return order.getId();
    }
}
// Order 这边只剩 getter/setter,没有任何行为 —— 这就是 01 章点名的贫血模型

两段代码外形几乎一样——都叫 Application Service、都开事务、都 load-then-save。差别全在那几行业务规则的位置:正确版把它们委托给 Order.place 和 PricingService,反例版把它们摊在应用层。后者的代价在 01 章讲过:业务规则散落、无法复用、Order 无法保护自己的不变量,任何人 new Order() 再乱设 setter 都能造出非法订单。同一段逻辑放错层,模型就死了。

正确:逻辑在领域层 应用服务 薄 · 只编排 领域服务 算价 Order 聚合 不变量/状态机 逻辑各归其位 模型有行为 反例:逻辑流进应用层 应用服务(臃肿) 校验+金额+状态机 全堆在这 Order(数据袋) 只剩 getter/setter = 01 章的贫血模型
图 6.2同一次下单,两种职责分布。注意:左边领域服务和聚合都"有行为"(朱红实框);右边逻辑全被吸进应用服务,Order 退成虚线数据袋——外形相似,但右边的模型已经死了。
想一想

同事说:"PricingService 和 PlaceOrderApplicationService 都叫 Service、都在算账,合并成一个 OrderService 不是更简单?"用本节的判定法则,给出一句话反驳。

先答,再展开

用删除测试:删掉 PricingService,丢失的是怎么算价这个领域知识(业务方关心);删掉 PlaceOrderApplicationService,丢失的只是调用顺序和事务这个调度步骤。一个装知识、一个装调度,合并就等于把领域逻辑和事务编排焊死在一起——既无法在不开事务的情况下单测定价,也让 Order 失去保护不变量的机会,正是退回贫血模型。两者分层不同(领域层 vs 应用层),不该合并。

与下一个构件的关系:领域服务、应用服务、聚合、仓储都需要一个归置它们的地方——按什么把这些类分组?不是按技术分层(所有 Service 一个包),而是按领域含义。这是模块的职责。

6.6模块 Module:按领域含义划分,名字进统一语言

模块(在 Java 里通常是包)是高层次的"主题归类":按领域含义把相关的聚合、领域服务、仓储分在一起,模块名本身也是统一语言的一部分。

为什么需要它

当类多到一屏放不下,就需要分组。常见的错误分法是按技术类型分:所有 Repository 进 repository 包、所有 Service 进 service 包。这样分,一个完整的业务概念(下单)被切碎散落在四五个技术包里,读代码要在包之间反复横跳,包名也讲不出任何领域故事。按领域含义分,每个模块自带语义,模块结构就成了领域的目录。

底层机制(比定义深一层):模块要遵循统一语言——包名就是领域名词。「云市」的销售上下文里,com.yunshi.sales.order 这个模块装下与订单相关的一切:Order、OrderLine、OrderRepository、PricingService、PlaceOrderApplicationService;com.yunshi.sales.pricing 可以单独装定价相关。读者看包名就知道里面讲什么领域故事,而不是"这里放的全是接口"。模块的目标是高内聚、低耦合:模块内的类频繁协作,模块之间的依赖尽量少且方向清晰。这与 03 章的限界上下文是不同尺度的概念——限界上下文是语义边界(同一个词在两个上下文里是两个类),模块是上下文内部的主题归类,一个上下文里可以有多个模块。

类比 · 带边界声明

模块像书的章节划分:按主题(领域含义)分章,而不是"把所有插图放一章、所有表格放另一章"(按技术类型)。类比失效处:书的章节是线性阅读顺序;而模块强调的是内聚与依赖方向——模块 A 依赖 B 不依赖 C,这种依赖图谱比"先读哪章"重要得多。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. 为什么"一个聚合根一个仓储"?为什么不该有 OrderLineRepository?答到聚合边界和不变量。
  2. 领域服务和应用服务怎么分?给出一条可操作的判定法则,并用 PricingService / PlaceOrderApplicationService 各举一例。
  3. 如果把"订单项非空 + 金额计算 + 状态机"写进应用服务,会发生什么?这和 01 章的哪个概念对上了?
  4. 工厂(如 Order.place)到底解决了什么问题?为什么 new Order() 加一串 setter 不行?
答案(先做完再展开)
  1. 因为加载/保存必须以整个聚合为粒度才能维持不变量;仓储是聚合根的集合幻觉。OrderLine 是 Order 的内部实体、无全局身份、外部不可直接持有,给它单独仓储就等于允许绕过聚合根改一行而不重算 total,破坏"total == Σ(line.unitPrice × line.qty)"这条真正不变量。要改行先 orderOfId 取出整个 Order。
  2. 判定法则:删掉这段逻辑,丢失的是领域知识(怎么算价、能否跳状态、能否为空)→ 领域层;丢失的只是调度步骤(先查谁、再调谁、在哪开事务)→ 应用服务。PricingService 装"怎么算价"(领域知识),是领域服务;PlaceOrderApplicationService 装"load→调价→造单→存回→发事件"(调度),是应用服务。
  3. 这些都是 Order 的领域逻辑/不变量,写进应用服务后业务规则散落、无法复用,Order 退化成只有 getter/setter 的数据袋,任何人 new Order() 乱设就能造出非法订单——这正是 01 章点名的贫血模型 (Anemic Domain Model),给应用服务改个 DDD 名字并不能改变这个事实。
  4. 工厂保证产出即满足全部不变量,消灭"半成品窗口"。new Order() 加 setter 会留下一个 total 未算、状态未设、订单项未装的中间态对象,它违反不变量却已能被引用;而 Order.place(...) 构造器私有、把校验和组装做成原子动作,让"非法的 Order"根本无法被构造。
进阶挑战 · 刚好够不着

把状态机写进了应用服务——错在哪,怎么移回聚合?

下面这段"确认订单"的应用服务,把订单状态机的规则写在了应用层。请指出它违反了本章/05 章的哪条原则,并说明该把哪部分逻辑移回 Order 聚合、移完之后应用服务还剩什么。

ConfirmOrderApplicationService(有问题的写法)Java
@Service
public class ConfirmOrderApplicationService {
    @Transactional
    public void confirm(OrderId id) {
        Order order = orderRepository.orderOfId(id);

        // ↓↓↓ 状态机规则被写在了应用层 ↓↓↓
        if (order.getStatus() != OrderStatus.CREATED)
            throw new IllegalStateException("只有 CREATED 才能确认");
        if (order.getLines().isEmpty())
            throw new IllegalStateException("确认时订单项不能为空");
        order.setStatus(OrderStatus.CONFIRMED);   // 直接 set 状态
        // ↑↑↑

        orderRepository.add(order);
    }
}
参考解答(先自己写完再展开)

错在哪:状态机是 Order 聚合的真正不变量(05 章:"CREATED → CONFIRMED → PAID → SHIPPED → COMPLETED 单向不可跳级/回退";"CONFIRMED 及之后订单项不能为空")。这里用 getStatus() 判断、用 setStatus() 强改,等于把不变量的守卫从聚合根搬到了应用层——Order 暴露了 setter,任何调用方都能把它设成任意状态,聚合再也无法保护自己。这就是 6.5 节的"逻辑流进应用服务",退回贫血模型。

怎么移回:在 Order 上加一个表达意图的领域方法 confirm(),把"只有 CREATED 才能确认""订单项非空"这两条判断和状态迁移都收进去;去掉 setStatus。

移回聚合后Java
// 领域层:不变量回到聚合根,状态只能由意图方法迁移
public class Order {
    public void confirm() {
        if (this.status != OrderStatus.CREATED)
            throw new IllegalStateException("只有 CREATED 才能确认");
        if (this.lines.isEmpty())
            throw new IllegalStateException("确认时订单项不能为空");
        this.status = OrderStatus.CONFIRMED;   // 状态机藏在聚合内部
    }
}

// 应用层:剩下的只有编排——取出、委托、存回
@Service
public class ConfirmOrderApplicationService {
    @Transactional
    public void confirm(OrderId id) {
        Order order = orderRepository.orderOfId(id);
        order.confirm();              // 规则全在聚合里
        orderRepository.add(order);
    }
}

移完后应用服务只剩三行编排:取出聚合、委托 order.confirm()、存回。这正是 6.4 节"薄编排层"的样子——零业务规则。