Rust 做 DDD,值对象是甜点,聚合根是硬骨头
后台有读者私信问:如果想用 Rust 构建产品,DDD(领域驱动设计)该怎么落地?
这个问题我琢磨了很久。
DDD 是 Eric Evans 在 2003 年提出的一套设计思想,但它在 Java/C# 生态里被实践得最多——那套战术模式(聚合根、仓储、领域事件)的典型实现带着浓厚的 OOP 味道。
要搬到 Rust,不是简单的 API 翻译——有些地方 Rust 的类型系统让 DDD 变得更好用了,有些地方所有权系统会让你抓狂。
先说结论:Rust 做 DDD,值对象和领域事件是杀手级体验,聚合根是最头疼的部分。
先搞清楚 DDD 在 Rust 里到底意味着什么
DDD 的核心不是那堆战术模式(实体、值对象、仓储……),而是一个思想:让代码的结构反映业务的结构。你写的不是 CRUD,而是让领域专家看了能点头的东西。
在 Java 里,DDD 的经典实现是一堆 class:Order 是聚合根,OrderItem 是子实体,Money 是值对象,OrderRepository 负责持久化。靠着继承、接口、ORM,这套东西能跑得很顺。
Rust 没有 class,没有继承,没有 GC,ORM 生态也不像 Java 那么"魔法"。所以你不能照搬,得翻译。而翻译的过程中,你会发现有些概念在 Rust 里反而更自然了。
值对象:Rust 的 newtype 模式是天生的值对象
DDD 里的值对象有三个特点:没有身份标识、不可变、通过值相等来比较。在 Java 里你得写一堆 equals()、hashCode(),还得祈祷没人把字段改成 mutable。
Rust 的做法简洁得多——用 newtype 模式:
/// 金额:一个值对象,没有身份,只有值
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Money {
amount: i64, // 用最小货币单位存储,避免浮点精度问题
currency: Currency,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Currency {
CNY,
USD,
EUR,
}
impl Money {
/// 构造时就校验,不是到处调 validate()
pub fn new(amount: i64, currency: Currency) -> Result<Self, DomainError> {
if amount < 0 {
return Err(DomainError::InvalidAmount("金额不能为负数".into()));
}
Ok(Self { amount, currency })
}
/// 加法要检查货币类型一致
pub fn add(self, other: Money) -> Result<Money, DomainError> {
if self.currency != other.currency {
return Err(DomainError::CurrencyMismatch);
}
Money::new(self.amount + other.amount, self.currency)
}
}注意几个细节:
Money是不可变的——没有&mut self的 setter。想改?创建一个新的。这跟 DDD 对值对象的要求完美契合。- 构造时校验——
new()返回Result,确保你不可能造出一个非法的Money。Java 里你得靠@Valid注解或者手动校验,经常漏。 derive(PartialEq, Eq)——值相等比较免费送的,不用写equals()。
说实话,这比 Java 爽多了。 Java 的值对象你得写构造器、getter、equals、hashCode、toString,一堆样板代码。Rust 几行 derive 搞定,而且编译器保证不可变性。
再看一个更典型的例子——用户邮箱:
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Email(String);
impl Email {
pub fn new(value: String) -> Result<Self, DomainError> {
if !value.contains('@') || !value.contains('.') {
return Err(DomainError::InvalidEmail(value));
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}这就是 Rust 的 newtype pattern——一个 tuple struct 包着 String,外面拿不到内部值,只能通过 as_str() 读取。类型系统帮你封装了,不需要 private 关键字。
实体和聚合根:这里开始头疼了
DDD 里实体有唯一标识,聚合根是一致性边界。在 Java 里,聚合根通常持有一堆子实体的引用,修改的时候大家一起变。
Rust 的所有权系统在这里会给你上一课。看这个订单的例子:
#[derive(Debug, Clone)]
pub struct Order {
id: OrderId,
customer_id: CustomerId,
items: Vec<OrderItem>, // 聚合根拥有子实体
status: OrderStatus,
total: Money,
created_at: chrono::NaiveDateTime,
}
#[derive(Debug, Clone)]
pub struct OrderItem {
product_id: ProductId,
quantity: u32,
unit_price: Money,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OrderStatus {
Created,
Paid,
Shipped,
Delivered,
Cancelled,
}看起来挺清爽的,对吧?问题出在你要修改聚合的时候。
问题一:修改子实体的借用冲突
假设你要给订单加一个商品:
impl Order {
pub fn add_item(
&mut self,
product_id: ProductId,
quantity: u32,
unit_price: Money,
) -> Result<(), DomainError> {
// 先检查有没有重复的商品
if let Some(existing) = self.items.iter_mut().find(|i| i.product_id == product_id) {
existing.quantity += quantity;
return Ok(());
}
// 检查订单状态
if self.status != OrderStatus::Created {
return Err(DomainError::OrderAlreadyProcessed);
}
let item = OrderItem { product_id, quantity, unit_price };
self.items.push(item);
self.recalculate_total()?;
Ok(())
}
fn recalculate_total(&mut self) -> Result<(), DomainError> {
let total = self.items.iter().try_fold(
Money::new(0, Currency::CNY)?,
|acc, item| {
let line_total = item.unit_price.clone().multiply(item.quantity as i64)?;
acc.add(line_total)
},
)?;
self.total = total;
Ok(())
}
}这段代码能跑通,但你注意到了吗?所有修改都是 &mut self。这意味着在同一个作用域里,你不能同时持有对聚合根不同部分的可变引用。
在 Java 里你可以 order.getItems().add(item); order.recalculateTotal();,两行搞定。Rust 里 self.items 和 self.total 都在同一个 &mut self 下面,借用检查器不会让你同时修改它们——但因为是同一个方法调用,其实是安全的。
这是 Rust 做 DDD 最常见的摩擦点。 你得习惯"把修改逻辑放在聚合根的方法里",而不是像 Java 那样到处拿引用改来改去。
问题二:跨聚合引用怎么办
DDD 的经验法则是聚合之间通过 ID 引用,不要直接持有对方的引用。在 Rust 里,这反而是最自然的做法:
/// 订单引用客户,但只存 ID,不存整个 Customer 对象
pub struct Order {
id: OrderId,
customer_id: CustomerId, // 不是 customer: Customer
// ...
}
/// 用强类型 ID 防止搞混
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct OrderId(uuid::Uuid);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CustomerId(uuid::Uuid);强类型 ID 是 Rust 做 DDD 的一个甜点。 你不可能把 OrderId 传给需要 CustomerId 的地方——编译器不让你过。Java 里你得靠命名规范或者包装类,Rust 里类型系统直接帮你挡了。
领域事件:enum 就是为这个设计的
领域事件是 DDD 的精髓之一——"订单已创建"、"商品已发货"、"库存已扣减"。在 Java 里你得定义一堆 Event class,然后用 instanceof 或者 visitor pattern 来处理。
Rust 的 enum + 模式匹配简直是为此量身定做的:
#[derive(Debug, Clone)]
pub enum OrderEvent {
Created {
order_id: OrderId,
customer_id: CustomerId,
items: Vec<OrderItem>,
created_at: chrono::NaiveDateTime,
},
ItemAdded {
order_id: OrderId,
product_id: ProductId,
quantity: u32,
},
Paid {
order_id: OrderId,
amount: Money,
paid_at: chrono::NaiveDateTime,
},
Shipped {
order_id: OrderId,
tracking_number: String,
shipped_at: chrono::NaiveDateTime,
},
Cancelled {
order_id: OrderId,
reason: String,
cancelled_at: chrono::NaiveDateTime,
},
}然后处理的时候:
impl OrderEvent {
pub fn order_id(&self) -> &OrderId {
match self {
Self::Created { order_id, .. } => order_id,
Self::ItemAdded { order_id, .. } => order_id,
Self::Paid { order_id, .. } => order_id,
Self::Shipped { order_id, .. } => order_id,
Self::Cancelled { order_id, .. } => order_id,
}
}
}编译器会确保你处理了每一个 variant。 加了新事件但忘了处理某个地方?编译不过。这在 Java 里你得靠 sealed class(Java 17+)或者运行时反射才能做到。
而且 Rust 的 enum 携带数据的方式比 Java 的 class 链灵活得多——每个 variant 可以有完全不同的字段,用 struct 语法命名,清晰直观。
仓储模式:trait 就是接口
仓储(Repository)是 DDD 里连接领域层和基础设施层的桥梁。在 Java 里是 interface,Rust 里是 trait:
/// 仓储 trait——领域层定义,基础设施层实现
#[async_trait]
pub trait OrderRepository {
async fn find_by_id(&self, id: &OrderId) -> Result<Option<Order>, DomainError>;
async fn save(&self, order: &Order) -> Result<(), DomainError>;
async fn find_by_customer(&self, customer_id: &CustomerId) -> Result<Vec<Order>, DomainError>;
}然后基础设施层给你一个 Postgres 实现:
pub struct PostgresOrderRepository {
pool: sqlx::PgPool,
}
#[async_trait]
impl OrderRepository for PostgresOrderRepository {
async fn find_by_id(&self, id: &OrderId) -> Result<Option<Order>, DomainError> {
let row = sqlx::query_as!(
OrderRow,
"SELECT * FROM orders WHERE id = $1",
id.0
)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Repository(e.to_string()))?;
Ok(row.map(|r| r.into_domain()))
}
async fn save(&self, order: &Order) -> Result<(), DomainError> {
// 实际项目里这里会有 upsert 逻辑、事件发布等
sqlx::query!(
"INSERT INTO orders (id, customer_id, status, total_amount, total_currency, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET status = $3, total_amount = $4",
order.id.0,
order.customer_id.0,
order.status.to_string(),
order.total.amount,
order.total.currency.to_string(),
order.created_at,
)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Repository(e.to_string()))?;
Ok(())
}
async fn find_by_customer(&self, customer_id: &CustomerId) -> Result<Vec<Order>, DomainError> {
// ... 类似实现
todo!()
}
}这里有个实际的坑:ORM 不好使。 Java 的 Hibernate 能自动把数据库行映射成聚合根,包括子实体、值对象,一层层嵌套。Rust 的 Diesel 和 SeaORM 做不到这么"魔法"——你得自己写 FromRow 转换逻辑。
我个人的建议是:别指望 ORM,直接用 sqlx 写 SQL。 反正 DDD 的仓储本来就应该隐藏持久化细节,你手动写转换代码反而更可控。
CQRS:命令和查询分开,Rust 的 enum 再次立功
CQRS(命令查询职责分离)经常跟 DDD 搭配使用。核心思想是:写操作(Command)和读操作(Query)走不同的模型。
/// 命令:改变状态的操作
#[derive(Debug)]
pub enum OrderCommand {
CreateOrder {
customer_id: CustomerId,
items: Vec<(ProductId, u32, Money)>,
},
AddItem {
order_id: OrderId,
product_id: ProductId,
quantity: u32,
},
PayOrder {
order_id: OrderId,
payment_method: PaymentMethod,
},
CancelOrder {
order_id: OrderId,
reason: String,
},
}
/// 查询:只读,返回 DTO
#[derive(Debug)]
pub enum OrderQuery {
GetOrder { order_id: OrderId },
ListOrdersByCustomer { customer_id: CustomerId },
ListOrdersByStatus { status: OrderStatus },
}
/// 查询返回的是扁平化的 DTO,不是领域对象
#[derive(Debug, Clone, serde::Serialize)]
pub struct OrderDto {
pub id: String,
pub customer_id: String,
pub status: String,
pub items: Vec<OrderItemDto>,
pub total_amount: i64,
pub total_currency: String,
pub created_at: String,
}命令走聚合根,用领域逻辑校验;查询直接 SQL,返回 DTO。 这两者在 Rust 里天然分开——命令返回 Result<Vec<OrderEvent>>,查询返回 Result<Vec<OrderDto>>,类型就不一样,不可能搞混。
事件溯源:如果你要做的不只是 CRUD
事件溯源(Event Sourcing)是 DDD 的高级玩法——不存状态,只存事件。想获取当前状态?把所有事件重放一遍。
Rust 做事件溯源其实挺合适的。社区里有几个框架:
| 框架 | 特点 | 适合场景 |
|---|---|---|
| cqrs-es | 轻量级,支持 Postgres/DynamoDB/内存 | 生产环境首选 |
| eventually-rs | 更 Rust 风格,trait 驱动 | 学习和中小型项目 |
用 cqrs-es 的话,聚合根需要实现一个 trait:
use cqrs_es::Aggregate;
impl Aggregate for Order {
const TYPE: &'static str = "order";
// 命令处理:接收命令,返回事件
fn handle(&self, command: Self::Command) -> Result<Vec<Self::Event>, Self::Error> {
match command {
OrderCommand::CreateOrder { customer_id, items } => {
// 业务校验
if items.is_empty() {
return Err(DomainError::EmptyOrder);
}
// 产生事件
Ok(vec![OrderEvent::Created {
order_id: OrderId::new(),
customer_id,
items: items.into_iter().map(|(pid, qty, price)| {
OrderItem { product_id: pid, quantity: qty, unit_price: price }
}).collect(),
created_at: chrono::Utc::now().naive_utc(),
}])
}
// ... 其他命令
}
}
// 事件应用:用事件更新状态
fn apply(&mut self, event: Self::Event) {
match event {
OrderEvent::Created { order_id, customer_id, items, created_at } => {
self.id = order_id;
self.customer_id = customer_id;
self.items = items;
self.status = OrderStatus::Created;
self.created_at = created_at;
}
OrderEvent::Paid { .. } => {
self.status = OrderStatus::Paid;
}
// ...
}
}
}这种模式在 Rust 里特别自然——handle 是纯函数(给定状态和命令,产生事件),apply 是状态转换。没有隐藏的副作用,没有 ORM 的魔法,一切都在你的掌控之中。
建议:别照搬 Java 那套
写了这么多 Rust + DDD 的代码之后,我的建议是:
第一,从值对象开始。 newtype pattern + derive 让值对象在 Rust 里几乎是零成本的,而且收益最大——类型安全、不可变性、构造时校验,全免费。
第二,聚合根别太大。 Java 的 DDD 教程里经常有巨型聚合根,十几个子实体,几十个方法。在 Rust 里这会让你的借用检查器痛苦不堪。把聚合拆小,每个聚合根管好自己的一小块一致性边界就够了。
第三,领域事件用 enum,别用 trait object。 我见过有人把 Java 的 Event Hierarchy 翻译成 Rust 的 trait object 体系——完全没必要。enum + 模式匹配更简洁,编译器帮你检查穷尽性,性能也更好。
第四,仓储直接写 SQL。 别指望 Diesel/SeaORM 自动映射聚合根,它们的设计思路跟 DDD 不太合。sqlx 的 query_as! 宏在编译期检查 SQL,配合手写的 FromRow 转换,反而更可靠。
第五,CQRS 是自然的选择。 命令返回事件,查询返回 DTO,类型天然分离。Rust 的类型系统让这两条路径不可能搞混。
结论
有人可能会问:Rust 做 DDD 值得吗?毕竟 Java/C# 有成熟的框架和十多年的社区经验。
我的看法是:如果你的产品对性能和可靠性有要求,Rust + DDD 是值得投入的。 DDD 帮你理清业务逻辑,Rust 帮你消除运行时错误。两者结合,你的领域模型不只是"看起来对",而是编译器就告诉你"它一定对"。
但如果你只是写个 CRUD 后台,用户量不大,业务逻辑简单——别折腾了,用 Java 或者 Go,开箱即用的 ORM 和 Web 框架能让你更快上线。
DDD 不是银弹,Rust 也不是。选工具要看你的问题是什么。
不管用什么语言,DDD 的核心思想——让代码反映业务——是通用的。Rust 的类型系统让这个思想落地的时候更严格、更安全,但学习曲线也更陡。如果你已经在用 Rust,试试把 newtype pattern 和 enum 用起来,你会发现很多 DDD 的好处其实已经内置在语言里了。