别再满项目乱丢 String:我开始给领域错误分层了

做 Pico-CRM 这类后台系统时,我越来越确定一件事:很多项目后面变难维护,不是因为业务太复杂,而是因为错误全是裸 String 一开始看着快,写着写着就会出现三种情况:同一个错误不同地方写法不一样、英文中文混着来、前端根本分不清这是参数错了、规则冲突了,还是少传字段了。

最近我在 Pico-CRM 里开始把"领域错误"单独拎出来,先做一个最小三层:

  • InvalidFormat
  • BusinessRule
  • MissingField

这篇就结合项目里的真实代码,聊聊我为什么要这么拆,以及这套东西现在落到了什么程度。

一、先说痛点:Result<T, String> 写久了真的会失控

Rust 里直接返回 String 很顺手,我自己前期也这么干过。

比如 Contactverify() 现在还是这种风格:

rust 复制代码
pub fn verify(&self) -> Result<(), String> {
    if self.name.trim().is_empty() {
        return Err("Name cannot be empty".to_string());
    }
    if self.phone.trim().is_empty() {
        return Err("Phone cannot be empty".to_string());
    }
    Ok(())
}

再看 User 领域模型,又是另一套:

rust 复制代码
if password.is_empty() {
    return Err("错误:密码不能为空".to_string());
}
if self.password.is_empty() {
    return Err("错误:存储的哈希字符串不能为空".to_string());
}

这就是典型的"能跑,但会慢慢烂"。

因为裸字符串有三个问题:

  1. 没有语义,调用方只能靠文本猜类型
  2. 文案不统一,有英文、有中文、有的还带"错误:"
  3. 不好复用,前端和应用层没法稳定分类处理

所以我后来换了一个思路:不是先追求一个大而全的错误系统,而是先把最常见的三类领域错误收口。

二、三层到底是哪三层

Pico-CRM 里这套类型定义放在 backend/src/domain/shared/errors/validation.rs

rust 复制代码
pub enum ValidationError {
    InvalidFormat { field: String, reason: String },
    BusinessRule { rule: String, details: String },
    MissingField(String),
}

我当时拆这三个,不是为了学术好看,而是因为它们刚好对应后台业务里最常见的三种失败。

1. MissingField

这个最直接,就是"该传的没传"。

举个例子,姓名、手机号、登录名这类字段为空,本质上不是规则冲突,也不是格式不合法,而是必填项缺失

它的展示文案也很简单:

rust 复制代码
Self::MissingField(field) => write!(f, "缺少必填字段: {}", field)

2. InvalidFormat

这个表示"字段有值,但长得不对"。

比如手机号筛选条件不是合法手机号:

rust 复制代码
return Err(ValidationError::invalid_format(
    "phone",
    "必须是有效的手机号码格式",
));

这里的关键点是:错误聚焦在字段格式,不牵扯业务规则。

3. BusinessRule

这个才是我最想单独拎出来的。

很多错误不是"你没填",也不是"你格式错",而是你虽然传了合法值,但违反了业务约束

比如联系人查询规约里:

rust 复制代码
return Err(ValidationError::business_rule(
    "tag",
    "标签筛选值不能为空且长度不能超过20个字符",
));

再比如:

rust 复制代码
return Err(ValidationError::business_rule(
    "follow_up_status",
    "跟进状态必须是预定义值",
));

这种错误如果也一律叫"参数错误",信息就太糊了。它本质上是在告诉调用方:字段本身有值,但不满足当前领域允许的规则。

三、为什么我觉得 BusinessRule 必须单独有名字

很多项目会把所有校验都塞进一个 ValidationError,然后里面只有一条 message: String

我后来不太满意这种做法。因为"验证失败"这个词太大了。

举个例子:

  • 手机号不是 11 位,这是格式问题
  • 排序字段重复了,这是规则问题
  • 标签为空,这是规则问题
  • 用户名没传,这是缺字段

它们都叫 validation,没错;但如果不再往下分,应用层最后还是只能继续做字符串匹配。

Pico-CRM 现在至少把 BusinessRule 拆出来了,后面不管是做日志统计、接口归类,还是前端想针对某类错误给更精确提示,都会比"全是 message"容易得多。

四、真实落地:ContactSpecification 是目前最完整的一块

这套三层错误,在项目里落得最完整的地方,是联系人查询规约 ContactSpecification

创建规约时先做校验:

rust 复制代码
pub fn new(
    filters: Option<ContactFilters>,
    sort: Option<Vec<SortOption>>,
) -> Result<Self, ValidationError> {
    let filters = filters.unwrap_or_default();
    let sort = sort.unwrap_or_default();

    Self::validate_filters(&filters)?;
    Self::validate_sort(&sort)?;

    Ok(Self { filters, sort })
}

往下看会发现,它不是一股脑 Err("参数错误"),而是按语义分流:

rust 复制代码
if !Self::is_valid_phone(phone) {
    return Err(ValidationError::invalid_format(
        "phone",
        "必须是有效的手机号码格式",
    ));
}

if tag.trim().is_empty() || tag.chars().count() > 20 {
    return Err(ValidationError::business_rule(
        "tag",
        "标签筛选值不能为空且长度不能超过20个字符",
    ));
}

排序冲突也是规则错误:

rust 复制代码
return Err(ValidationError::business_rule(
    "sort",
    &format!("重复的排序字段: {}", field),
));

这套写法的好处是,看代码的人和接错误的人,都知道当前到底错在哪一层。

五、Display 做中文文案,比到处手搓报错靠谱得多

我这次还有个很深的感受:错误类型和错误文案最好一起设计。

ValidationErrorDisplay 现在就是:

rust 复制代码
match self {
    Self::InvalidFormat { field, reason } => {
        write!(f, "字段'{}'格式错误: {}", field, reason)
    }
    Self::BusinessRule { rule, details } => {
        write!(f, "违反业务规则[{}]: {}", rule, details)
    }
    Self::MissingField(field) => write!(f, "缺少必填字段: {}", field),
}

这件事看起来小,其实很值。

因为以前如果每个应用服务、每个 server function、每个页面都自己拼文案,最后一定会出现:

  • 同义句写出 5 个版本
  • 有的面向开发者,有的直接暴露内部术语
  • 一改文案就得全局搜字符串

现在至少这三类错误的中文出口是统一的。

六、但我得说实话:Pico-CRM 现在还没完全统一

这里不能吹过头,因为仓库现状就是"已经开始收口,但还没彻底做完"。

比如:

  • ContactSpecification 已经用了 ValidationError
  • PaginationError 也已经有独立错误类型
  • 但很多 verify() 仍然是 Result<(), String>
  • 而且有些文案还是英文,比如 Name cannot be empty

这意味着项目目前处在一个很真实的过渡期:错误建模方向已经明确,但历史代码还没全部迁过去。

我反而觉得这比写一篇"我们有一套完美错误架构"更有参考价值。大多数项目升级工程质量,都是这么发生的:

  1. 先在一个高价值模块试点
  2. 验证类型设计是否够用
  3. 再逐步替换历史 String

如果一上来就要求全域统一,最后很可能只会得到一个没人真用的大而全错误基类。

七、我现在的判断标准:先分语义,再谈统一

这次做完之后,我给自己定了一个很简单的标准:

  • 缺字段,用 MissingField
  • 格式错,用 InvalidFormat
  • 规则冲突,用 BusinessRule

剩下那些确实还说不清、或者暂时懒得建模的,先继续用 String,但优先在新代码里别再放大这个债。

对工程项目来说,这比"重构一切"更现实。

八、最后总结

Pico-CRM 这次做"领域错误三层体系",本质上不是在发明什么复杂框架,而是在解决一个很土但很痛的问题:别让错误消息继续散落成一地字符串。

目前项目里最明确的收获有两个:

  1. 调用方终于能区分"缺字段 / 格式错 / 业务规则冲突"
  2. 中文错误文案开始有统一出口,不再每层自己手搓

下一步我自己会继续把更多 verify()Result<(), String> 迁到更明确的错误类型上,尤其是那些用户会直接看到的领域校验。

如果你在 Rust 项目里也碰到过"错误全是 String,后面越来越难收"的情况,你会先做错误枚举,还是继续让 message 顶着?评论区聊聊。

相关推荐
卷无止境1 小时前
SimPy 监控与数据收集:完整指南
后端
卷无止境1 小时前
Event Latency:把"等待"这件事,交给电缆来负责
后端
武子康1 小时前
Java-10 深入浅出 MyBatis 一对多与多对多查询配置详解
java·后端
一 乐1 小时前
网上订餐系统|基于springboot的网上订餐系统设计与实现(源码+数据库+文档)
java·数据库·spring boot·后端·论文·毕设·网上订餐系统
XovH1 小时前
第14篇 Docker Compose 开发环境最佳实践:热重载与调试
后端
.Cnn2 小时前
SpringBoot 文件上传与阿里云 OSS 集成
java·spring boot·后端·阿里云
XovH2 小时前
Docker从0到1再到 Kubernetes 实战:第15篇Compose 中的服务依赖、健康检查与启动顺序
后端
XovH2 小时前
Docker 从 0 到 1 再到 Kubernetes 实战:第13篇 Compose 环境变量与配置管理
后端