Rust 泛型注入:一个 Service 协调四个 DDD 聚合的实战复盘

业务背景:一个订单操作,四个聚合遭殃

我在写的 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 了 schedulesorders 两个投影表,过滤掉已取消的订单,只检查活跃订单的时间冲突。这是我深思熟虑后的妥协:涉及"一人同时段不能有两个任务"这类业务规则,不用两条表 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 的设计其实就三件事:

  1. 泛型注入替代 DI 容器。Rust 的 trait 约束 + 构造器注入已经够用,不需要 Spring 那一套
  2. 顺序执行替代分布式事务。操作按依赖顺序排列,前一步失败直接返回 Err,后面不会执行
  3. 纯函数映射保证最终一致性from_order_status() 这种无副作用的映射让聚合间不需要额外协调逻辑

依赖方向一如既往地干净:domain ← application ← infrastructure。Application 层不知道 infrastructure 里是 SeaORM、sqlx 还是内存 mock------改实现只需要改一行构造注入的代码。

如果你也在用 Rust 写 DDD,聚合间的协调是怎么处理的?欢迎在评论区聊聊你的做法。


项目开源在 GitHub,搜 Pico-CRM 就能找到完整代码。

相关推荐
木雷坞1 小时前
vLLM 服务上 K8s 前,我先把 GPU、探针和镜像过了一遍
后端
用户298698530141 小时前
用 Java 操作 Word 文档?试试添加内容控件
java·后端
珠海西格电力1 小时前
如何实现零碳园区管理系统“云-边-端”架构的协同
大数据·数据库·人工智能·架构·能源
golang学习记1 小时前
Go 里什么时候可以“panic”?
后端
donecoding2 小时前
Vue 的 `app.use()`、Figma 的快捷键、Vite 的插件——为什么它们底层是同一种架构?
架构·ai编程·前端工程化
jakeswang2 小时前
【AI面经】大模型半夜发短信骂客户?Agent 工具调用失控,你如何设计防护机制?
java·后端
神奇小汤圆2 小时前
如何设计实现一个 LLM Gateway ?
后端