DDD 理论入门二:领域层里有什么?聚合、实体、值对象又怎么落地?

写这篇时我换了个姿势:不把自己当背书机器,就当给组里新人讲完一轮之后,把当场被问住、后来又想明白的那几句记下来。

八年 Java 做下来,我对 DDD 的态度很务实:它能帮我们把「业务话」和「代码形状」对齐;至于目录叫 domain 还是 core,那是团队习惯,别本末倒置

下面串两篇小册主题:02 领域(Domain)里到底装什么03 聚合 / 实体 / 值对象怎么分工。中间我故意埋了几个「你可能会愣一下」的问题——当年我也愣过。


开篇先对齐:DDD 到底是谁提出来的?

领域驱动设计(Domain-Driven Design,DDD) 是一种软件设计方法,Eric Evans 在《Domain-Driven Design: Tackling Complexity in the Heart of Software》里把它系统化。
一句话:把复杂系统的设计重心,从「技术表结构」拽回「业务问题本身」——软件要解决的,是领域(Domain)里的规则和知识,不是数据库里那张表叫啥。


02|Domain(领域)是什么?领域层都包含什么?

概念:领域不是文件夹名

在 DDD 里,领域(Domain) 指的是:这块业务里共有的知识、规则、数据约束和做事方式的集合
它是软件要扎根的环境,往往还能拆成多个子领域(Subdomain)——比如电商里「下单」「支付」「物流」各是一块,各有一套说法和规则,硬揉成一个类迟早出事。

我自己的大白话:领域就是「这摊生意在真实世界里怎么运转」在脑子里的投影;代码里的 domain 包,只是尽量把这投影摆整齐,摆不齐就继续改模型,别假装已经对齐

特性:为什么总有人提「统一语言」

  • 业务中心:讨论模型时,先问业务在说什么,再问表怎么建。
  • 模型驱动:用实体、值对象、聚合、领域服务等把业务知识显式化,而不是散落在 if-else 和注释里。
  • 语言一致性(Ubiquitous Language,统一语言):产品、运营、开发用同一套词指同一件事——「订单关闭」到底算不算「退单」,在会议上吵清楚,比上线后撕需求单便宜。
  • 边界清晰:子领域之间、聚合之间边界清楚,复杂度才拆得动。

用途:领域模型不是摆设

  • 封装业务逻辑:规则集中在领域模型里,改规则有处可找。
  • 沟通工具:白板上的模型业务能认,比只对程序员友好的分层名有用。
  • 软件设计的基础:架构怎么切、事务画在哪,常常跟着聚合和上下文走

实现手段:战术设计里那一串「积木」

书上常见的一套(英文第一次出现时括号带上,方便你翻原版):

手段 英文 一句话
实体 Entity 唯一标识,生命周期里「还是它」
值对象 Value Object 没身份,靠相等,常不可变
聚合 Aggregate 一致性边界 + 对外只认聚合根
领域服务 Domain Service 行为挂不到某一个实体上时,放这儿
领域事件 Domain Event 已发生的业务事实,用来松耦合联动
仓储 Repository 像集合一样存取聚合根,屏蔽持久化细节
领域适配器 Domain Adapter / Port 实现 领域不被外部协议、SDK 污染的接法(常和六边形/整洁架构一起谈)
工厂 Factory 创建复杂聚合或实体时,把拼装规则收拢

工厂这块我多说一句:OpenAI 对接、抽奖规则、会话模型选型这类「创建时分支多、规则多」的场景,用工厂把创建逻辑从业务方法里拽出来,可读性会好很多——不是炫技,是真省脑

探究:你可能会问的三个问题

Q1:「领域层」和「应用层」到底谁写 if
领域层承载业务规则与不变量(例如「已支付订单不能加明细」);应用层用例编排(先调谁、事务从哪开、发不发消息)。别让应用层变成巨型脚本,也别让领域层去关心「这次 HTTP 返回 200 还是 400」——那是接口层的事。

Q2:领域适配器是不是就是 Spring 里的 @Adapter
不必然。 DDD 里的适配器是角色领域要的能力叫端口(Port),外面换供应商时用适配器(Adapter)接上。Spring 注解名各家项目不一样,心法是「依赖方向朝内」,别纠结类名必须叫啥。

Q3:子领域拆多了,会不会「过度设计」?
会。我的经验是:先能在统一语言下讲清一条主流程,再谈拆几个包;拆不动就别硬拆,否则新人进来只看到一堆空接口,比不拆还糟


03|聚合、实体、值对象

领域模型(Domain Model)是对业务的抽象,聚合、实体、值对象是其中最常用的三块砖。下面按「概念 → 特性 → 用途 → 实现手段」走,代码保留示例结构,我顺手把类名、空格、笔误理了一遍,能直接当教学代码读


聚合(Aggregate)

概念

聚合是一组强相关对象的集合,对外表现成一个单元
关键句(建议背): 聚合内尽量 事务一致性聚合之间往往用最终一致性(事件、消息、补偿)。

特性

  • 一致性边界:聚合内部状态要一起合法,别出现「订单已付、明细却是空的」这种半吊子。
  • 聚合根(Aggregate Root)唯一对外入口,外部不准绕过根去改内部实体。
  • 事务边界:一个事务里改一个聚合通常最省心;跨聚合?想想事件和幂等

用途

封装业务规则、降低模型复杂度、把「能一起改」的东西绑在一起

实现手段(落地 checklist)

  1. 选好谁是根——能代表整坨业务、有稳定标识。
  2. 只通过根修改内部对象。
  3. 事务尽量对齐聚合边界;跨聚合用领域事件等。
  4. 规则写在聚合内,别漏到 Service 里偷偷判断。
  5. 持久化以聚合根为粒度去谈仓储(Repository)。

探究:一个聚合 = 一张表吗?

不一定。 聚合是模型概念;表是存储投影。常见是根 + 内部实体落库在有关联的一组表里,但别为了表结构反推聚合——先模型合理,再谈 ORM 怎么映射


代码示例:订单聚合(简化)

下面示例展示:封装规则,内部实体不对外暴露修改捷径。

// 实体基类
public abstract class BaseEntity {

    protected Long id;

    public Long getId() {
        return id;
    }
}

// 值对象基类(示例占位:值对象通常不可变,不通过 setter 暴露修改)
public abstract class ValueObject {
}
// 订单项:聚合内的实体
public class OrderItem extends BaseEntity {

    private String productName;
    private int quantity;
    private double price;

    public OrderItem(String productName, int quantity, double price) {
        this.productName = productName;
        this.quantity = quantity;
        this.price = price;
    }

    public double getTotalPrice() {
        return quantity * price;
    }

    // 省略 getter / setter
}
import java.util.ArrayList;
import java.util.List;

// 订单聚合根:外部只能通过它改订单
public class OrderAggregate extends BaseEntity {

    private final List<OrderItem> orderItems;
    private String customerName;
    private boolean paid;

    public OrderAggregate(String customerName) {
        this.customerName = customerName;
        this.orderItems = new ArrayList<>();
        this.paid = false;
    }

    public void addItem(OrderItem item) {
        if (!paid) {
            orderItems.add(item);
        } else {
            throw new IllegalStateException("Cannot add items to a paid order.");
        }
    }

    public double getTotalAmount() {
        return orderItems.stream().mapToDouble(OrderItem::getTotalPrice).sum();
    }

    public void markAsPaid() {
        if (getTotalAmount() > 0) {
            paid = true;
        } else {
            throw new IllegalStateException("Order total must be greater than 0 to be paid.");
        }
    }

    // 省略 getter / setter
}
public class OrderDemo {

    public static void main(String[] args) {
        OrderAggregate order = new OrderAggregate("xiaomaozhu");

        order.addItem(new OrderItem("手机", 1, 1000.00));
        order.addItem(new OrderItem("数据线", 2, 25.00));

        System.out.println("Total amount: " + order.getTotalAmount());

        order.markAsPaid();
    }
}

这段代码想说明什么:
加明细、标记支付,都走聚合根;规则(未支付才能加、金额大于 0 才能付)锁在根里。你以后加「优惠券」「运费」,先问聚合还能不能扛别在 Controller 里再写一遍 if


实体(Entity)

概念

实体 = 唯一标识 + 状态 + 行为
「是不是同一个用户」不看名字看 id——标识在整个生命周期里稳定。

特性

  • 唯一标识:业务里能区分「这一个」的东西。
  • 领域标识 vs 技术标识:有时是业务号(订单号),有时是数据库自增 id——只要在全系统里能唯一定位,可以,但别用技术细节冒充业务概念糊弄自己。
  • 行为与状态一起变:实体不只是 getter/setter 容器

用途

表达业务对象(用户、订单、账号),封装规则维护自己的一致性

实现手段

定义类、保证 id、方法里写规则、ORM 映射、跨实体复杂协调可考虑领域服务、状态变更可发领域事件

探究:equals 只比 id 够不够?

在聚合边界内,实体通常用 id 区分值对象比属性。混用会导致集合去重、缓存 key 全乱——这是真 bug,不是风格问题


代码示例:用户实体

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

public class UserEntity {

    private final UUID id;
    private String name;
    private String email;

    public UserEntity(UUID id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    public void updateName(String newName) {
        this.name = newName;
    }

    public void updateEmail(String newEmail) {
        this.email = newEmail;
    }

    public UUID getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        UserEntity user = (UserEntity) o;
        return id.equals(user.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

    @Override
    public String toString() {
        return "UserEntity{"
                + "id=" + id
                + ", name='" + name + '\''
                + ", email='" + email + '\''
                + '}';
    }
}
public class UserEntityDemo {

    public static void main(String[] args) {
        UserEntity user = new UserEntity(
                UUID.randomUUID(),
                "XiaoFuGe",
                "xiaofuge@qq.com");

        System.out.println(user);
        user.updateName("XiaoFuGe");
        user.updateEmail("xiaofuge@qq.com");
        System.out.println(user);
    }
}

值对象(Value Object)

概念

值对象 = 用一组属性描述的概念,没有「身份」;两个值对象属性全一样,就视为同一个值
常不可变:要改?换一个新对象,别在原地改。

特性

不可变、按相等、可替换、可复用;线程安全往往更好做。

用途

金额与货币、度量、区间、地址、状态枚举等——凡是「描述性」强、不需要单独追 id 的,都合适。

实现手段

私有字段、构造或工厂创建、equals / hashCode全部属性;需要落库时拆列或 JSON别偷懒塞一个 varchar 里啥都往里倒(除非团队明确接受)。

探究:枚举算不算值对象?

可以算一种极简值对象——值域封闭、不可变。复杂规则(例如不同币种精度)再升级成类不迟。


代码示例:订单状态(枚举)

public enum OrderStatusVO {

    PLACED(0, "下单"),
    PAID(1, "支付"),
    COMPLETED(2, "完成"),
    CANCELLED(3, "退单");

    private final int code;
    private final String description;

    OrderStatusVO(int code, String description) {
        this.code = code;
        this.description = description;
    }

    public int getCode() {
        return code;
    }

    public String getDescription() {
        return description;
    }

    public static OrderStatusVO fromCode(int code) {
        for (OrderStatusVO status : values()) {
            if (status.getCode() == code) {
                return status;
            }
        }
        throw new IllegalArgumentException("Invalid code for OrderStatus: " + code);
    }
}

存库可以只存 code,读出来用 fromCode 转回枚举,避免魔法数字满天飞。


代码示例:地址(不可变类)

import java.util.Objects;

public final class AddressVO {

    private final String street;
    private final String city;
    private final String zipCode;
    private final String country;

    public AddressVO(String street, String city, String zipCode, String country) {
        this.street = street;
        this.city = city;
        this.zipCode = zipCode;
        this.country = country;
    }

    public String getStreet() {
        return street;
    }

    public String getCity() {
        return city;
    }

    public String getZipCode() {
        return zipCode;
    }

    public String getCountry() {
        return country;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        AddressVO address = (AddressVO) o;
        return street.equals(address.street)
                && city.equals(address.city)
                && zipCode.equals(address.zipCode)
                && country.equals(address.country);
    }

    @Override
    public int hashCode() {
        return Objects.hash(street, city, zipCode, country);
    }
}

用户和店铺都可以复用 AddressVO不会出现「同一个地址两个类各写一遍」的重复。


收个尾:别用 DDD 当优越感

八年经验里我见过两种极端:一种是文件夹很 DDD,业务照旧写在 Service;一种是一听 DDD 就烦,觉得都是空话
我现在的用法是:统一语言 + 聚合边界 + 实体值对象分清,先让迭代能跑目录和接口可以慢慢收。

若你正在读小傅哥原文或 bugstack 系资料,把示例跑通再对照自己的业务改,比背定义有用得多。


附录:和前文索引

文档 说明
DDD_技术小册理论.md 小册 01:DDD 是什么、战略/战术等

(完)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容