业务背景:一个订单操作,四个聚合遭殃
我在写的 Pico-CRM 是一个家政行业的 SaaS 系统,核心业务线是:客户需求 → 生成订单 → 排班分配 → 完工结算。听起来很常规的 CRUD 对吧?但当我用 DDD + 事件溯源来建模时,一个"从需求创建订单"的操作,要同时动四个聚合:
- ServiceRequest (服务需求):客户提的需求,状态走
new → confirmed → converted - Order (订单):从需求转换来的正式订单,状态走
pending → confirmed → dispatching → in_service → completed - Schedule(排班):哪个服务人员在什么时间段执行这个订单
- ServiceCatalog(服务目录):定价参考,比如"深度保洁 299 元/次"
这四个东西在 DDD 里分属不同的聚合,各自有独立的事件流、独立的状态机。那问题来了------跨聚合的操作谁来协调?怎么协调?
为什么常规方案不够
一般遇到跨聚合操作,常见的方案是:
方案一:微服务 Saga 编排 。用一个中心化的 Saga 协调器,每个步骤有对应的补偿操作,失败时逆序回滚。但 Pico-CRM 是单体应用,把一个进程内的调用拆成 Saga 纯属过度设计,增加复杂度不说,调试的时候得在多个步骤间跳来跳去排查。
方案二:把逻辑塞进 Handler 层。在 Axum 的 handler 里把四个聚合的操作串起来。技术上能跑,但 handler 很快就变成了几百行的大函数。
方案三:用领域服务。DDD 经典做法,在 domain 层定义一个 DomainService 来处理跨聚合逻辑。但 Rust 里 domain 层要求零依赖,domain service 拿不到数据库连接,还是得有人把东西传进去。
所以我需要一个更务实的设计------把跨聚合编排逻辑收敛到一个 Application Service 里,用 Rust 泛型注入所有依赖,编译期保证类型安全。
设计目标
- 一个入口:所有跨聚合的订单操作通过 OrderAppService 统一进出
- 编译期安全:依赖关系在编译期检查,不依赖运行时 DI 容器
- 可测试:传 mock 实现就能测,不需要连真实数据库
- 简单:不加中间件、不加框架、不引入新概念
分点展开
1. Struct 设计:五个泛型,四个聚合
先看结构:
rust
pub struct OrderAppService<R, Q, SR, S, C>
where
R: OrderRepository,
Q: ServiceRequestQueryTrait<Result = ServiceRequest>,
SR: ServiceRequestRepository,
S: ScheduleRepository,
C: ServiceCatalogQueryTrait<Result = ServiceCatalog>,
{
order_repo: R, // 订单 ES 仓库
request_query: Q, // 服务需求读模型查询
request_repo: SR, // 服务需求 ES 仓库
schedule_repo: S, // 排班仓库(ES + SQL 混合)
service_catalog_query: C, // 服务目录读模型查询
}
五个泛型,四个聚合,构造时一把注入:
rust
let service = OrderAppService::new(
order_repo,
request_query,
request_repo,
schedule_repo,
service_catalog_query,
);
这里有个细节:有读有写。request_query 查读模型,request_repo 写事件------因为创建订单时需要确认需求状态(合理读缓存),但改需求状态需要走事件溯源(必须写事件)。如果全用读模型或全用事件存储,要么数据不一致,要么性能浪费。两套一起用,各取所需。
Rust 的 trait 约束在泛型参数上就写死了------调用 new() 的时候编译器会检查每个参数是否实现了对应的 trait,没实现对不上。不需要运行时注入框架,不需要反射。
2. 创建订单:读两次,写两次,顺序执行
rust
pub async fn create_from_request(
&self,
payload: CreateOrderFromRequest,
operator_uuid: Option<String>,
) -> Result<SharedOrder, String> {
// ① 查需求读模型,确认状态
let request = self.request_query
.get_request(payload.request_id.clone())
.await?
.ok_or_else(|| "service request not found".to_string())?;
if request.status != "confirmed" {
return Err("service request must be confirmed before creating order".to_string());
}
// ② 从需求数据 + 服务目录定价构造 DomainOrder
let mut order = DomainOrder::new_from_request(
payload.request_id.clone(),
request.customer_uuid.clone(),
parse_datetime(request.appointment_start_at.as_deref()),
parse_datetime(request.appointment_end_at.as_deref()),
payload.notes,
);
if let Some(service_catalog_uuid) = request.service_catalog_uuid.clone() {
if let Some(service_catalog) = self.service_catalog_query
.get_service_catalog(service_catalog_uuid)
.await?
{
order.amount_cents = service_catalog.base_price_cents;
}
}
// ③ 校验不变量,写事件创建订单
order.verify()?;
let created = self.order_repo.create_order(order, operator_uuid).await?;
// ④ 把服务需求标为 "converted",不再是有效需求
let _ = self.request_repo
.update_service_request_status(payload.request_id, "converted".to_string())
.await?;
Ok(created.into())
}
执行顺序非常重要:先验证 → 再创建 Order → 最后改需求状态。如果第④步失败怎么办?需求还是 confirmed,下次还能再创建------不会丢数据,只是多了一个手动处理的情况。这种"允许偶发重复、不允许丢失"的设计取向,比花大力气上分布式事务划算得多。
3. 分配排班:最复杂的一个操作
update_assignment 是整个 Service 里逻辑最重的,六步走:
rust
pub async fn update_assignment(
&self,
uuid: String,
payload: UpdateOrderAssignment,
operator_uuid: Option<String>,
) -> Result<SharedOrder, String> {
// ① 参数校验:必传人员 + 时间窗口合法性
let assigned_user_uuid = payload.assigned_user_uuid.clone()
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| "assigned_user_uuid is required".to_string())?;
let start = parse_datetime(payload.scheduled_start_at.as_deref())
.ok_or_else(|| "scheduled_start_at is required".to_string())?;
let end = parse_datetime(payload.scheduled_end_at.as_deref())
.ok_or_else(|| "scheduled_end_at is required".to_string())?;
validate_time_window(start, end)?;
// ② 查订单当前状态,确认允许分配
let order = self.order_repo.find_order(uuid.clone()).await?
.ok_or_else(|| format!("order {} not found", uuid))?;
let current_schedule = ScheduleStatus::from_order_status(&order.status);
if !current_schedule.allows_assignment_update() {
return Err("schedule assignment can only be updated in planned status".to_string());
}
// ③ 跨聚合时间冲突检测:join schedules + orders 投影表
if let Some(conflict) = self.schedule_repo
.find_conflict(assigned_user_uuid.clone(), start, end, Some(uuid.clone()))
.await?
{
return Err(format!(
"schedule time overlaps with existing assignment {}",
conflict.order_uuid
));
}
// ④ Upsert Schedule:有则更新,无则创建
match self.schedule_repo.find_by_order(uuid.clone()).await? {
Some(_) => {
self.schedule_repo.update_assignment(
uuid.clone(), assigned_user_uuid.clone(),
start, end, payload.dispatch_note.clone(),
).await?;
}
None => {
let assignment = ScheduleAssignment {
uuid: String::new(),
order_uuid: uuid.clone(),
assigned_user_uuid: assigned_user_uuid.clone(),
start_at: start, end_at: end,
status: ScheduleStatus::from_order_status(&order.status),
notes: payload.dispatch_note.clone(),
inserted_at: Utc::now(), updated_at: Utc::now(),
};
self.schedule_repo.create_assignment(assignment).await?;
}
}
// ⑤ 更新 Order 的排班字段
let updated = self.order_repo
.update_order_assignment(uuid, OrderAssignmentUpdate { ... }, operator_uuid.clone())
.await?;
// ⑥ 自动推进状态:Pending/Confirmed → Dispatching
let next_status = OrderStatus::next_after_schedule_assignment(order.status);
let updated = if next_status != updated.status {
self.order_repo.update_order_status(
updated.uuid.clone(),
next_status.as_str().to_string(),
operator_uuid,
).await?
} else { updated };
Ok(updated.into())
}
第③步的 find_conflict 是整个系统里唯一 的跨聚合查询------用 SeaORM join 了 schedules 和 orders 两个投影表,过滤掉已取消的订单,只检查活跃订单的时间冲突。这是我深思熟虑后的妥协:涉及"一人同时段不能有两个任务"这类业务规则,不用两条表 join 就写不出有效校验。
第④步的 Upsert 逻辑让它成为一个幂等操作------不管排班之前有没有,都能正确执行。第⑥步的自动推进是个体验优化:分配完人员后,订单自然从"待排班"进入"已派单",不需要用户额外操作。
4. 状态同步的秘密:from_order_status()
取消订单和改状态的操作相对简单,但有个关键设计:
rust
pub async fn cancel_order(&self, uuid: String, ...) -> Result<SharedOrder, String> {
let updated = self.order_repo
.cancel_order(uuid.clone(), payload.reason, operator_uuid.clone())
.await?;
// 级联取消排班
let _ = self.schedule_repo
.update_status(uuid, ScheduleStatus::Cancelled)
.await?;
Ok(updated.into())
}
pub async fn update_status(&self, uuid: String, ...) -> Result<SharedOrder, String> {
OrderStatus::parse(&payload.status)?;
let updated = self.order_repo
.update_order_status(uuid, payload.status, operator_uuid)
.await?;
// 从 Order 状态推导 Schedule 状态,同步更新
let schedule_status = ScheduleStatus::from_order_status(&updated.status);
let _ = self.schedule_repo
.update_status(updated.uuid.clone(), schedule_status)
.await?;
Ok(updated.into())
}
这里的核心是 ScheduleStatus::from_order_status():
rust
pub fn from_order_status(status: &OrderStatus) -> Self {
match status {
OrderStatus::Pending | OrderStatus::Confirmed | OrderStatus::Dispatching => {
ScheduleStatus::Planned
}
OrderStatus::InService => ScheduleStatus::InService,
OrderStatus::Completed => ScheduleStatus::Done,
OrderStatus::Cancelled => ScheduleStatus::Cancelled,
}
}
它是个纯函数:给定任一 Order 状态,输出唯一确定的 Schedule 状态。这就是"不需要 Saga 补偿"的关键------哪怕 Schedule 更新那步网络抖动暂时失败,投影层重试时读到的还是同一个 OrderStatus,推导出的 ScheduleStatus 不变,最终一定会收敛到正确值。
总结
回头复盘,这个 OrderAppService 的设计其实就三件事:
- 泛型注入替代 DI 容器。Rust 的 trait 约束 + 构造器注入已经够用,不需要 Spring 那一套
- 顺序执行替代分布式事务。操作按依赖顺序排列,前一步失败直接返回 Err,后面不会执行
- 纯函数映射保证最终一致性 。
from_order_status()这种无副作用的映射让聚合间不需要额外协调逻辑
依赖方向一如既往地干净:domain ← application ← infrastructure。Application 层不知道 infrastructure 里是 SeaORM、sqlx 还是内存 mock------改实现只需要改一行构造注入的代码。
如果你也在用 Rust 写 DDD,聚合间的协调是怎么处理的?欢迎在评论区聊聊你的做法。
项目开源在 GitHub,搜 Pico-CRM 就能找到完整代码。