一、缘起:为什么 Rust 后端更需要分层

大家好,我是 Pico-CRM 的作者。

在做这个家政 CRM 项目的过程中,我发现一个现象:很多 Rust 后端项目要么不分层(一个 main.rs 打到尾),要么过度分层(把 Java 那套照搬过来,目录深到可怕)。

我的项目从一开始就定了三层:domainapplicationinfrastructure。到现在跑了几个月,改过数据库实现、换过文件存储后端、加了 N 个新功能——最让我意外的是,没有一次改动需要修改 domain 层

提前声明:本文分享的是个人在单项目 MVP 阶段的实践,不构成架构标准。分层方案跟团队规模、项目复杂度强相关。

二、三层架构总览

先看目录结构:

backend/src/
├── domain/                    # 领域层:纯 Rust,零外部依赖
│   ├── crm/contact/           #   聚合:model + repository trait + query trait
│   ├── identity/auth/         #   认证抽象:AuthProvider trait
│   └── shared/file/           #   文件存储抽象:FileStorageGateway trait
├── application/               # 应用层:编排用例,只依赖 domain
│   ├── commands/crm/          #   命令服务(写操作)
│   ├── queries/crm/           #   查询服务(读操作)
│   └── mappers/               #   DTO ↔ Domain 转换
└── infrastructure/            # 基础设施层:实现 domain trait
    ├── repositories/          #   SeaORM 仓储实现
    ├── queries/               #   查询实现
    ├── auth/                  #   JWT 认证实现
    └── gateways/              #   文件存储实现(S3)

依赖方向是严格的单向

domain ← application ← infrastructure

翻译成人话:

  • domain 不知道谁实现了它——只定义 trait,不引入任何框架
  • application 不知道 infrastructure 的存在——只依赖 domain trait
  • infrastructure 负责把 domain trait 用具体技术(SeaORM、S3、JWT)实现出来

没有循环引用,没有"infrastructure 突然被 domain import"的诡异情况。

三、Domain 层:trait 就是你的契约

Domain 层的 Cargo.toml 里没有 sea-orm,没有 axum,没有 jsonwebtoken。只有纯 Rust。

3.1 实体就是普通 struct

Contact 实体长这样——就是普通的 Rust struct,带行为方法:

// backend/src/domain/crm/contact/model.rs

#[derive(Debug, Clone)]
pub struct Contact {
    pub uuid: String,
    pub name: String,
    pub phone: String,
    pub address: Option<String>,
    pub tags: Vec<String>,
    pub follow_up_status: FollowUpStatus,
    pub inserted_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

impl Contact {
    pub fn verify(&self) -> Result<(), String> {
        if self.name.trim().is_empty() {
            return Err("姓名不能为空".to_string());
        }
        if self.phone.trim().is_empty() {
            return Err("电话不能为空".to_string());
        }
        Ok(())
    }
}

业务校验就放在实体自身的方法里,调用方不需要知道校验细节。

3.2 仓储用 trait 定义,不关心实现

举个例子,ContactRepository trait 只定义"能做什么",不管你用 SeaORM、Diesel 还是内存 HashMap:

// backend/src/domain/crm/contact/repository.rs

pub trait ContactRepository: Send + Sync {
    fn create_contact(
        &self, contact: Contact, creator_uuid: String,
    ) -> impl Future<Output = Result<Contact, String>> + Send;

    fn find_contact_by_phone_number(
        &self, phone_number: &str,
    ) -> impl Future<Output = Result<Option<Contact>, String>> + Send;

    fn update_contact(
        &self, contact: UpdateContact,
    ) -> impl Future<Output = Result<Contact, String>> + Send;

    fn delete_contact(
        &self, uuid: String,
    ) -> impl Future<Output = Result<(), String>> + Send;
}

注意 impl Future 做异步返回——这是 Rust 1.75 稳定的 RPITIT(Return Position Impl Trait In Traits),让异步 trait 不再需要 async_trait 宏。

3.3 外部服务也是 trait

文件存储也一样。domain 层定义 FileStorageGateway trait:

// backend/src/domain/shared/file/storage.rs

#[async_trait]
pub trait FileStorageGateway: Send + Sync {
    async fn upload_file(&self, request: FileUploadRequest)
        -> Result<FileUploadResponse, String>;
    async fn download_file(&self, request: FileDownloadRequest)
        -> Result<FileDownloadResponse, String>;
    async fn delete_file(&self, request: FileDeleteRequest)
        -> Result<(), String>;
    async fn list_files(&self, request: FileListRequest)
        -> Result<FileListResponse, String>;
}

Domain 层不关心文件是存 S3、阿里云 OSS 还是本地磁盘。它只管定义"需要什么能力"。

四、Application 层:泛型注入,告别 DI 容器

Application 层的核心技巧:用泛型 struct 接收 domain trait,构造器注入具体实现

// backend/src/application/commands/crm/contact_service.rs

pub struct ContactAppService<R: ContactRepository> {
    contact_repo: R,
}

impl<R: ContactRepository> ContactAppService<R> {
    pub fn new(contact_repo: R) -> Self {
        Self { contact_repo }
    }

    pub async fn create_contact(
        &self, contact: Contact, creator_uuid: String,
    ) -> Result<(), String> {
        // 1. DTO → Domain 转换
        let domain_contact: DomainContact = contact.try_into()?;

        // 2. 领域校验
        domain_contact.verify()?;

        // 3. 跨聚合业务规则(手机号唯一)
        self.ensure_phone_number_available(&domain_contact.phone, None).await?;

        // 4. 委托仓储持久化
        self.contact_repo.create_contact(domain_contact, creator_uuid).await?;
        Ok(())
    }
}

没有 #[Inject]、没有 @Autowired、没有 DI 容器。 全靠 Rust 的泛型参数 + new() 构造器。

测试怎么办?手写一个 mock struct 实现 trait 就行:

#[derive(Default)]
struct FakeContactRepository {
    existing: Arc<Mutex<Option<DomainContact>>>,
    created: Arc<Mutex<Vec<DomainContact>>>,
}

impl ContactRepository for FakeContactRepository {
    fn create_contact(&self, contact: DomainContact, creator_uuid: String)
        -> impl Future<Output = Result<DomainContact, String>> + Send {
        let created = self.created.clone();
        async move {
            created.lock().expect("lock").push(contact.clone());
            Ok(contact)
        }
    }
    // ... 其他方法类似
}

#[tokio::test]
async fn create_contact_rejects_duplicate_phone_number() {
    let repo = FakeContactRepository {
        existing: Arc::new(Mutex::new(Some(/* 已存在的客户 */))),
        ..Default::default()
    };
    let service = ContactAppService::new(repo);

    let err = service.create_contact(/* 同手机号的新客户 */, creator_uuid)
        .await
        .expect_err("应该拒绝重复手机号");

    assert_eq!(err, "联系电话已存在");
}

不需要 Mockall、不需要 mock 框架——用 Rust 的 Arc<Mutex<>> 记录调用参数,手写实现 20 行搞定。

五、Infrastructure 层:把 trait 接上数据库

Infrastructure 层负责用具体技术实现 domain trait。

5.1 SeaORM 仓储实现

// backend/src/infrastructure/repositories/crm/contact_repository_impl.rs

pub struct SeaOrmContactRepository {
    db: DatabaseConnection,
    merchant_id: String,    // 多租户隔离
}

#[async_trait]
impl ContactRepository for SeaOrmContactRepository {
    fn create_contact(&self, contact: Contact, creator_uuid: String)
        -> impl Future<Output = Result<Contact, String>> + Send {
        let db = self.db.clone();
        let merchant_id = self.merchant_id.clone();
        async move {
            // 1. Domain → SeaORM ActiveModel
            let entity = ContactMapper::to_active_entity(contact, creator_uuid)?;
            // 2. 插入数据库
            let new_entity = entity.insert(&db).await
                .map_err(|e| format!("create contact error: {}", e))?;
            // 3. SeaORM Model → Domain
            Ok(ContactMapper::to_domain(new_entity))
        }
    }
}

5.2 两层 Mapper 的分工

你可能注意到上面有个 ContactMapper。这个项目里有两个 mapper 层,各司其职:

Mapper 层 转换方向 所在位置
infrastructure mapper SeaORM Entity ↔ Domain Entity infrastructure/mappers/
application mapper Domain Entity ↔ Shared DTO application/mappers/

为什么要拆两层?因为 DTO 的字段和 DB Entity 的字段可能不一样——DTO 可能合并了多个表的字段,或者对前端暴露的名称跟数据库列名不同。拆开之后,换 ORM 或改 API 格式都不会互相影响。

六、组装与替换成本

到了 Handler 层,组装就是几行代码:

// app/src/server/contact_handlers.rs

pub async fn fetch_contacts(params: ContactQuery) -> Result<ListResult<Contact>, ServerFnError> {
    let pool = expect_context::<Database>();
    let tenant = resolve_tenant_context().await?;

    // 组装:选具体实现 → 注入 Application Service
    let contact_query = SeaOrmContactQuery::new(pool.connection.clone(), tenant.merchant_id);
    let app_service = ContactQueryService::new(contact_query);

    app_service.fetch_contacts(params).await
}

这就是构造器注入的完整链路:

main.rs 初始化 Database → 注入 Leptos Context
  → Handler 取 Database → new 出 Infrastructure 实现
    → 注入 Application Service
      → Application Service 只认 Domain Trait

现在想换存储后端?把 SeaOrmContactQuery 换成 RedisContactQuery只改 handler 里的一行。Application 层一行不动。

七、不适用场景

这个分层不是银弹。以下场景不建议这么搞:

  • 纯 CRUD 系统:没有复杂业务规则,分层只增加文件数,不增加价值
  • 一个人写的微型项目:5 个 API 以内,一个 main.rs + schema.rs 完全够了
  • 团队对 Rust trait 不够熟悉:泛型约束 + RPITIT + impl Future 这套组合有学习曲线

但如果你满足以下条件,三层架构值得考虑:

  • 有明确的业务规则(校验、状态机、跨聚合约束)
  • 未来可能切换基础设施(数据库、存储、消息队列)
  • 需要单元测试覆盖核心逻辑,但不想 mock 数据库

八、总结

用 Rust 写 DDD 三层架构的核心心得以一句话概括:Rust 的 trait 系统天然就是依赖倒置的最佳载体

几个关键点:

  • Domain 层零依赖:trait 定义契约,不引入任何框架
  • 泛型注入替代 DI 容器ContactAppService<R: ContactRepository>,构造时传入具体实现
  • 测试不需要 Mockall:手写 struct 实现 trait,20 行一个 mock
  • 替换成本极低:换实现只需改 handler 里一行 new()
  • 依赖方向严格单向:domain ← application ← infrastructure,不会出现循环引用

完整的代码在 GitHub 仓库 Pico-CRM,Rust 全栈(Axum + Leptos + SeaORM),还在持续迭代中。

你写 Rust 后端会分层吗?用的是什么方案?欢迎在评论区聊聊。


上一篇讲了事件溯源在订单系统中的实战,上一篇拆解了多租户架构的真实取舍。如果你也在用 Rust 做后端,欢迎 Star 项目和交流。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐