跳至主要內容

EDD 副作用驱动设计-开发过程详解以及手记

Mr.Lexon大约 8 分钟EDD

Title

EDD 副作用驱动设计 开发过程详解以及手记

分层设计

层级内容说明
core/纯业务数据结构与逻辑不允许副作用,可100%测试
domain/行为语义接口定义(trait)(行为定义层)描述副作用语义,不绑定具体框架
app/usecase编排层(这个其实应该是Service层)组合 trait、协调行为、返回结果+事件
infra/具体实现,如 DB/API/Cache (这个是副作用实现层)所有副作用实现汇聚于此层
event/事件定义与事件调度器 (这是副作用触发层)用于解耦外部副作用与主流程
web/控制器/handler层 (这是纯的外部调用了)请求转译、状态注入、回传

core层

core在整体设计上面,只能描述实体entity的内容以及与实体关联的关系,比如说实体校验,实体操作,但是用到的东西必须是实体内部存在的,或者说是对实体本身存在需求的,使用实体属性的操作。但是实体与实体之间构成的实体关系在这一层上无法体现。所以这一层描述的是纯粹实体本身,以及一些衍生操作,所以在开发前面的程序设计阶段可以通过数据表关系图和序列图将实体以及实体衍生操作分离出来以供开发与测试。

以上,这一层的功能虽然说纯化(Pure)了,但是从描述上来说较为匮乏,而且测试的工作量较少,不过也有可能是因为我的项目太小了。

开发的过程中同时发现了一个事情,core返回的实体和错误是需要贯穿整个项目,并且,他是不能依赖其他层的定义,因为core是项目的核心,所以在设计上需要注意一点,如果每一层都需要同一个类型定义的话,那么,这个类型就应该放在core中。当然,core可以依赖第三方库的类型定义。

还有就是core层不应该定义工具函数,工具函数到另外一个包里面去。而且他的错误处理,理应在app层里面完成。就是core层的错误,传递到app层就结束了,由app去决定到底是用domain还app本身的错误返回

在DDD中,这里使用了entity和ValueObject的概念,但是Aggerate root我就删掉了,因为我觉得entity可以直接代替Aggerate root

值对象

值对象创建指南:

graph TD
    A[需要创建值对象吗?] --> B{是否有验证规则?}
    B -->|是| C{是否有关联行为?}
    B -->|否| D{是否易混淆?}
    C -->|是| E[✅ 创建值对象]
    C -->|否| F{是否跨层传递?}
    D -->|是| E
    D -->|否| G[❌ 使用原始类型]
    F -->|是| E
    F -->|否| H[🤔 使用验证库]

强制性值对象类型:

// core/value_objects/mandatory.rs

/// 标识类 - 防止混淆
pub struct UserId(pub Uuid);
pub struct OrderId(pub Uuid);
pub struct ProductId(pub Uuid);

/// 业务关键类 - 有复杂规则
pub struct Email(String);
pub struct Money { amount: i64, currency: Currency }
pub struct PhoneNumber { country: String, number: String }

/// 安全敏感类 - 需要封装
pub struct Password(String);  // 永不暴露原始值
pub struct Token(String);
pub struct ApiKey(String);

// 判断标准:
// 1. 是否会导致严重业务错误?(混淆 UserId 和 OrderId)
// 2. 是否涉及金钱计算?(Money 防止精度问题)
// 3. 是否有安全风险?(Password 需要特殊处理)

推荐性值对象类型:

// core/value_objects/recommended.rs

/// 有行为的业务概念
pub struct Age(u8);

impl Age {
    pub fn new(value: u8) -> Result<Self, ValidationError> {
        if value < 0 || value > 150 {
            return Err(ValidationError::InvalidAge);
        }
        Ok(Age(value))
    }
    
    pub fn is_adult(&self) -> bool {
        self.0 >= 18
    }
    
    pub fn can_retire(&self) -> bool {
        self.0 >= 65
    }
}

/// 有格式要求的
pub struct PostalCode(String);
pub struct Iban(String);
pub struct SocialSecurityNumber(String);

// 判断标准:
// 1. 是否有关联的业务方法?
// 2. 是否需要格式验证?
// 3. 是否在多处使用?

可选值对象:

// 这些可以只用验证库
#[derive(Validate)]
pub struct User {
    #[validate(length(min = 3, max = 50))]
    pub username: String,  // 简单长度验证
    
    #[validate(length(max = 500))]
    pub bio: String,  // 纯文本
    
    #[validate(url)]
    pub website: String,  // 标准格式,无特殊行为
}

// 判断标准:
// 1. 是否只是简单的格式/长度验证?
// 2. 是否没有关联行为?
// 3. 是否不会跨层传递?

禁止值对象:

// ❌ 不要这样做
pub struct FirstName(String);  // 过度:没有特殊行为
pub struct LoopIndex(usize);   // 过度:技术概念
pub struct IsActive(bool);     // 过度:布尔值不需要包装
pub struct Count(i32);         // 过度:除非有特殊含义

标准模板:

// core/value_objects/template.rs

use serde::{Deserialize, Serialize};
use std::fmt;

/// EDD 值对象标准模板
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]  // JSON 序列化时透明
pub struct ValueObjectName(String);

impl ValueObjectName {
    /// 构造函数 - 包含验证逻辑
    pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
        let value = value.into();
        
        // 验证规则
        if value.is_empty() {
            return Err(ValidationError::Empty);
        }
        
        if value.len() > 100 {
            return Err(ValidationError::TooLong);
        }
        
        Ok(Self(value))
    }
    
    /// 不安全构造 - 仅用于内部可信数据
    pub(crate) fn new_unchecked(value: String) -> Self {
        Self(value)
    }
    
    /// 获取内部值 - 谨慎暴露
    pub fn as_str(&self) -> &str {
        &self.0
    }
    
    /// 业务行为方法
    pub fn some_business_logic(&self) -> bool {
        // ...
    }
}

impl fmt::Display for ValueObjectName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

// 数据库转换
impl From<ValueObjectName> for String {
    fn from(vo: ValueObjectName) -> Self {
        vo.0
    }
}

impl TryFrom<String> for ValueObjectName {
    type Error = ValidationError;
    
    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

实践建议:

在早期不确定业务场景的时候(特别是MVP场景),可采用直接类型和验证库验证,当业务确认下来之后,应该及时重构,不过对于常见的值对象,可通过直接生成的形式加入。对于信任的数据来源转换可直接unwarp

domain层

domain则是行为定义层(契约层),只负责行为定义,就是行为产生的traitDTOs,不应该有其他实现的东西,同理,他的错误也是向下传递的,所有在包里面的错误,就是纯化层里面的错误,都要在这里定义。他的错误传递到副作用里面,和app是同级错误,但是他在定义上,无法决定哪一个错误需要构造和返回。

app层

这一层则是业务编排层,这里是将实体关系全部描述出来,但是返回的数据只能有以下类型:

  1. core定义的实体
  2. domain的DTO
  3. app本身定义的DTO

然后返回的数据类型都是:

struct Outcome<T> {
    pub data:T,
    pub from_case:AppUseCase,
    pub events:Vec<AppEvent>
}

首先from_case描述从那个用例来的,events是指接下来需要执行哪些事件。但是这个events需要保证这是一个非空数组(保证每个用例触发都会有一个日志提醒,但是这个由event层去触发)。而且在这里面。app层不应该有任何日志或者是额外不属于业务的操作(在做程序调试的时候允许,但是发布或者提交的时候应该删除)。当然这里需要表明一点。假如是使用了Repository等等这些抽象定义的化,如果注入的操作有这些是允许的。


Outcome<T>是否需要实现Clone

目前认为是必要的,因为出去的Outcome需要分发到两个地方,一个是event一个是web(任何处理app的模块),如果采用引用的话其实也可以,但是会有较大的理解问题,其实这里也变相强制所有UseCase都必须用一个DTO去返回数据,不能直接返回。

纯化层

开发了一段时间,我觉得以上三层可以统称为纯化层,这三层是尽可能的减少副作用的产生。以保证业务的正确性,都是抽象性的业务描述,通过依赖注入的方式使得app的业务可用。

在usecase里面,是否需要将getdata这些逻辑图化?

EDD UseCase设计原则

一个UseCase应该满足以下条件之一:

  1. 操作单一实体,处理该实体的所有条件性副作用
  2. 协调多个实体,但副作用在概念上属于同一类别

违反此原则的信号:

  • UseCase难以命名
  • 测试场景过于复杂
  • 事务边界不清晰
  • 团队对职责有分歧

副作用层

Web层

目前已经完全删除,融入进infra层

Event层

Event层主要的工作就是将异步任务剥离出去,包括异步执行链,那么提供的服务以trait的形式提供,但是这里有一个顾虑,是否将大部分服务做成抽象包,然后像log包一样提供服务,这样就可以自动加载插件了。

infra层

infra层主要工作就是实现所有domain层的抽象trait,并分模块管理副作用,目录架构如下:

infra/
├── Cargo.toml
└── src
    ├── api #原本的Web层
    ├── command #项目命令行
    ├── config #项目配置
    ├── config.rs #项目配置
    ├── infra_error.rs # infra的所有错误集合
    ├── lib.rs 
    ├── local_log # 日志
    ├── main.rs # 项目启动
    ├── repositroy # 存储具体实现
    └── service # domain的服务实现

这个例子作为一个基本服务,所有关于状态存储的副作用实现都应该放在repositroy

上次编辑于:
贡献者: lexon