领域驱动设计(DDD):实体、值对象、聚合、仓库、领域事件

说实话,DDD 这个概念在 Kotlin 圈子里被讨论得挺多的。但很多人一上来就被「实体」「值对象」「聚合」这些术语给唬住了。我刚开始接触 DDD 的时候也一样,觉得这东西太抽象了。直到我在一个电商项目中,用传统 CRUD 方式写订单模块,结果被各种边界不清、状态混乱的问题折磨得够呛——嗯,从那以后我才真正理解了 DDD 的价值。

说白了,DDD 就是一套帮我们把复杂业务理清楚的思维方式。它不依赖任何框架,核心就几个概念。今天我就带你一个个拆开来看。

实体(Entity):有身份的东西

实体是什么?简单说,就是「有身份证」的对象。两个实体即使所有属性都一样,只要 ID 不同,就是两个不同的东西。

举个例子,用户就是典型的实体。两个用户都叫「张三」,年龄都是 25 岁,但他们不是同一个人。为什么?因为 userId 不同。

data class User(
    val id: UserId,   // 唯一标识
    var name: String,
    var email: String
) {
    // 实体的行为方法
    fun changeEmail(newEmail: String) {
        // 可以加校验逻辑
        require(newEmail.contains("@")) { "邮箱格式不对" }
        this.email = newEmail
    }
}

// 值对象包装 ID
@JvmInline
value class UserId(val value: String)

我在项目中遇到过一个问题:有人直接把实体定义成 data class,然后到处复制。结果两个地方改了同一个实体的不同副本,数据就乱套了。记住,实体一定要有唯一标识,而且这个标识在整个生命周期里不能变。

注意:实体用 data class 没问题,但要注意 equals/hashCode 应该基于 ID,而不是所有字段。Kotlin 的 data class 默认比较所有属性,所以你需要手动重写,或者像我一样用 @JvmInline value class 包装 ID。

值对象(Value Object):没有身份,只有值

值对象就简单了——它没有 ID,只看属性值。两个值对象只要属性一样,就是同一个东西。

你想想看,地址是不是值对象?「北京市朝阳区 xxx 号」这个地址,只要内容一样,就是同一个地址。没人会给地址分配一个唯一 ID 吧?

data class Address(
    val province: String,
    val city: String,
    val street: String,
    val zipCode: String
) {
    // 值对象应该是不可变的
    fun withStreet(newStreet: String): Address {
        return this.copy(street = newStreet)
    }
}

我个人习惯把值对象设计成不可变的。为什么?因为值对象没有身份,你改了它,就等于创建了一个新值。用 copy() 方法生成新实例,既安全又符合直觉。

小技巧:Kotlin 的 data class 天然支持 copy(),非常适合做值对象。另外,对于只有一个字段的值对象,可以用 @JvmInline value class,性能更好。

聚合(Aggregate):一致性边界

聚合是 DDD 里最核心的概念,也是很多人最头疼的。我换个说法你就懂了:聚合就是一组必须保持一致的对象的集合。

拿订单来说。一个订单包含多个订单项,每个订单项又关联商品信息。你不能单独修改某个订单项的总价,因为总价必须由数量和单价计算得出。订单和订单项就是一个聚合,订单是聚合根。

class Order(
    val id: OrderId,
    val items: MutableList<OrderItem> = mutableListOf(),
    var status: OrderStatus = OrderStatus.PENDING
) {
    // 聚合根负责维护一致性
    fun addItem(productId: ProductId, quantity: Int, price: Money) {
        // 校验业务规则
        require(quantity > 0) { "数量必须大于0" }
        require(status == OrderStatus.PENDING) { "只能修改待支付订单" }
        
        val existingItem = items.find { it.productId == productId }
        if (existingItem != null) {
            existingItem.increaseQuantity(quantity)
        } else {
            items.add(OrderItem(productId, quantity, price))
        }
    }
    
    fun calculateTotal(): Money {
        return items.fold(Money.ZERO) { acc, item -> acc + item.subtotal }
    }
}

data class OrderItem(
    val productId: ProductId,
    var quantity: Int,
    val unitPrice: Money
) {
    val subtotal: Money get() = unitPrice * quantity
    
    fun increaseQuantity(amount: Int) {
        quantity += amount
    }
}

我曾经在一个项目中,把订单项直接暴露给外部修改,结果出现了「订单总价和订单项总和不一致」的 bug。排查了一整天才发现,是有人直接调了 order.items[0].quantity = 100,绕过了聚合根的计算逻辑。从那以后,我规定所有对聚合内部对象的修改,都必须通过聚合根的方法来操作。

核心原则:外部只能引用聚合根,不能直接获取聚合内部的实体或值对象引用。数据修改必须通过聚合根的方法。

仓库(Repository):聚合的存储

仓库就是用来存取聚合的。注意,仓库只负责聚合根,不负责普通实体或值对象。为什么?因为聚合内部的对象只能通过聚合根来访问,仓库没必要单独管它们。

interface OrderRepository {
    fun findById(id: OrderId): Order?
    fun save(order: Order): Order
    fun delete(id: OrderId)
}

// 实现类可以基于任何持久化技术
class OrderRepositoryImpl(
    private val database: Database
) : OrderRepository {
    override fun findById(id: OrderId): Order? {
        // 从数据库加载聚合根,同时加载内部对象
        return database.query("SELECT * FROM orders WHERE id = ?", id.value)
            .map { row ->
                val items = loadItems(id)
                restoreOrder(row, items)
            }
            .firstOrNull()
    }
    
    override fun save(order: Order) {
        // 保存整个聚合,保证一致性
        database.transaction {
            upsertOrder(order)
            deleteItems(order.id)
            insertItems(order.items)
        }
    }
    
    private fun restoreOrder(row: Row, items: List<OrderItem>): Order {
        // 从持久化数据重建聚合
        return Order(
            id = OrderId(row["id"]),
            items = items.toMutableList(),
            status = OrderStatus.valueOf(row["status"])
        )
    }
}

我个人习惯在仓库接口中只定义针对聚合根的操作。像 findByCustomerId 这种查询,如果返回的是聚合根列表,那没问题。但如果返回的是聚合内部的某个值对象,那就说明设计有问题了。

避坑指南:我曾经见过有人把仓库设计成「万能 DAO」,一个接口里塞了几十个查询方法。结果聚合的封装性完全被破坏,业务逻辑散落在各个查询里。记住,仓库的职责是存取聚合,不是做报表查询。

领域事件(Domain Event):发生了什么

领域事件就是「已经发生的事情」的记录。比如「订单已支付」「用户已注册」。它的作用是解耦——一个聚合发生状态变化后,通过事件通知其他部分。

// 领域事件通常是不变的
data class OrderPaidEvent(
    val orderId: OrderId,
    val paidAmount: Money,
    val paidAt: Instant
)

// 在聚合中发布事件
class Order(
    val id: OrderId,
    // ... 其他属性
) {
    private val events = mutableListOf<Any>()
    
    fun markAsPaid(amount: Money) {
        require(amount == calculateTotal()) { "支付金额必须等于订单总额" }
        require(status == OrderStatus.PENDING) { "订单状态不正确" }
        
        this.status = OrderStatus.PAID
        // 记录事件
        events.add(OrderPaidEvent(id, amount, Instant.now()))
    }
    
    fun pullEvents(): List<Any> {
        val result = events.toList()
        events.clear()
        return result
    }
}

嗯,这里要注意一点:领域事件是在聚合的方法内部产生的,而不是在外部手动创建。比如支付成功后,聚合根自己记录 OrderPaidEvent,然后由基础设施层负责把事件发布出去。

实践建议:在 Kotlin 中,可以用 sealed class 来定义事件类型,配合 when 表达式做模式匹配,非常优雅。另外,事件的名字要用过去时态,比如 Paid、Created、Shipped,表示已经发生的事情。

知识体系总览

说了这么多,我把这几个概念的关系画成了一张图,方便你理解。

DDD 核心概念关系图 聚合(Aggregate)边界 聚合根(Aggregate Root) Order(订单) 实体(Entity) OrderItem(订单项) 值对象(Value Object) Address、Money 仓库(Repository) 领域事件(Domain Event) 说明:聚合根是外部访问的唯一入口,内部实体和值对象通过聚合根管理。

总结一下

DDD 这几个概念,说白了就是帮我们划分职责的:

  • 实体:有 ID,可变,关注生命周期
  • 值对象:无 ID,不可变,关注属性值
  • 聚合:一组对象的集合,保证数据一致性
  • 仓库:存取聚合,屏蔽持久化细节
  • 领域事件:记录已发生的事情,实现解耦

我个人觉得,DDD 最难的不是理解这些概念,而是在实际项目中坚持使用它们。尤其是当项目工期紧的时候,很容易就回到「面向数据库编程」的老路上去了。但只要你坚持下来,你会发现代码的可维护性和可理解性会提升一个档次。

好了,这一章就到这里。如果你在实际项目中遇到了 DDD 相关的问题,欢迎随时交流。


公众号:蓝海资料掘金营,微信deep3321