别再满项目乱丢 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 顶着?评论区聊聊。

相关推荐
用户559822481222 分钟前
Docker Compose Down 导致容器数据误删——ext4 日志恢复全记录
后端
LiaCode3 分钟前
一天学完 redis 的爽翻版核心知识总结
前端·后端
大刚测试开发实战4 分钟前
如何内网穿透访问本地私有化部署的TestHub
前端·后端·github
xiaodaoluanzha23 分钟前
迄今為止,最簡單的編程語言 Nolang
前端·后端
Csvn24 分钟前
Docker 容器管理入门 — 从镜像到容器编排
后端
用户7623524259128 分钟前
ShardingJDBC
后端
行者全栈架构师29 分钟前
IDEA 中 Maven 项目的 15 个红色报错快速解决方法
java·后端
Colin草率地做慢慢地改33 分钟前
关于QuickStore这个项目的重构(2)- 数据库建表文件
后端·面试·架构
IT_陈寒1 小时前
SpringBoot自动配置这个坑,我踩进去又爬出来了
前端·人工智能·后端
copyer_xyf2 小时前
Agent 流程编排
后端·python·agent