领域驱动设计(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,然后到处复制。结果两个地方改了同一个实体的不同副本,数据就乱套了。记住,实体一定要有唯一标识,而且这个标识在整个生命周期里不能变。
值对象(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() 方法生成新实例,既安全又符合直觉。
聚合(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 这种查询,如果返回的是聚合根列表,那没问题。但如果返回的是聚合内部的某个值对象,那就说明设计有问题了。
领域事件(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,然后由基础设施层负责把事件发布出去。
知识体系总览
说了这么多,我把这几个概念的关系画成了一张图,方便你理解。
总结一下
DDD 这几个概念,说白了就是帮我们划分职责的:
- 实体:有 ID,可变,关注生命周期
- 值对象:无 ID,不可变,关注属性值
- 聚合:一组对象的集合,保证数据一致性
- 仓库:存取聚合,屏蔽持久化细节
- 领域事件:记录已发生的事情,实现解耦
我个人觉得,DDD 最难的不是理解这些概念,而是在实际项目中坚持使用它们。尤其是当项目工期紧的时候,很容易就回到「面向数据库编程」的老路上去了。但只要你坚持下来,你会发现代码的可维护性和可理解性会提升一个档次。
好了,这一章就到这里。如果你在实际项目中遇到了 DDD 相关的问题,欢迎随时交流。