返回文章列表

Rust 做 DDD,值对象是甜点,聚合根是硬骨头

717·6 分钟阅读
RustDDD领域驱动设计架构设计

后台有读者私信问:如果想用 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)
    }
}

注意几个细节:

  1. Money 是不可变的——没有 &mut self 的 setter。想改?创建一个新的。这跟 DDD 对值对象的要求完美契合。
  2. 构造时校验——new() 返回 Result,确保你不可能造出一个非法的 Money。Java 里你得靠 @Valid 注解或者手动校验,经常漏。
  3. 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.itemsself.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 的好处其实已经内置在语言里了。