做 Pico-CRM 这类后台系统时,我越来越确定一件事:很多项目后面变难维护,不是因为业务太复杂,而是因为错误全是裸
String。 一开始看着快,写着写着就会出现三种情况:同一个错误不同地方写法不一样、英文中文混着来、前端根本分不清这是参数错了、规则冲突了,还是少传字段了。
最近我在 Pico-CRM 里开始把"领域错误"单独拎出来,先做一个最小三层:
InvalidFormatBusinessRuleMissingField
这篇就结合项目里的真实代码,聊聊我为什么要这么拆,以及这套东西现在落到了什么程度。
一、先说痛点:Result<T, String> 写久了真的会失控
Rust 里直接返回 String 很顺手,我自己前期也这么干过。
比如 Contact 的 verify() 现在还是这种风格:
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());
}
这就是典型的"能跑,但会慢慢烂"。
因为裸字符串有三个问题:
- 没有语义,调用方只能靠文本猜类型
- 文案不统一,有英文、有中文、有的还带"错误:"
- 不好复用,前端和应用层没法稳定分类处理
所以我后来换了一个思路:不是先追求一个大而全的错误系统,而是先把最常见的三类领域错误收口。
二、三层到底是哪三层
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 做中文文案,比到处手搓报错靠谱得多
我这次还有个很深的感受:错误类型和错误文案最好一起设计。
ValidationError 的 Display 现在就是:
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已经用了ValidationErrorPaginationError也已经有独立错误类型- 但很多
verify()仍然是Result<(), String> - 而且有些文案还是英文,比如
Name cannot be empty
这意味着项目目前处在一个很真实的过渡期:错误建模方向已经明确,但历史代码还没全部迁过去。
我反而觉得这比写一篇"我们有一套完美错误架构"更有参考价值。大多数项目升级工程质量,都是这么发生的:
- 先在一个高价值模块试点
- 验证类型设计是否够用
- 再逐步替换历史
String
如果一上来就要求全域统一,最后很可能只会得到一个没人真用的大而全错误基类。
七、我现在的判断标准:先分语义,再谈统一
这次做完之后,我给自己定了一个很简单的标准:
- 缺字段,用
MissingField - 格式错,用
InvalidFormat - 规则冲突,用
BusinessRule
剩下那些确实还说不清、或者暂时懒得建模的,先继续用 String,但优先在新代码里别再放大这个债。
对工程项目来说,这比"重构一切"更现实。
八、最后总结
Pico-CRM 这次做"领域错误三层体系",本质上不是在发明什么复杂框架,而是在解决一个很土但很痛的问题:别让错误消息继续散落成一地字符串。
目前项目里最明确的收获有两个:
- 调用方终于能区分"缺字段 / 格式错 / 业务规则冲突"
- 中文错误文案开始有统一出口,不再每层自己手搓
下一步我自己会继续把更多 verify() 从 Result<(), String> 迁到更明确的错误类型上,尤其是那些用户会直接看到的领域校验。
如果你在 Rust 项目里也碰到过"错误全是 String,后面越来越难收"的情况,你会先做错误枚举,还是继续让 message 顶着?评论区聊聊。