领域驱动设计实践(1)-错误处理

本文讨论了领域驱动设计实践下的错误处理。

在软件开发过程中,错误处理一直是个比较麻烦的事情。对于错误处理,我们经常需要考虑的问题是:

  • 在哪些地方可能出现错误
  • 如何处理错误

上一篇文章中以六边形架构为例介绍了领域驱动设计的一些基本概念,这里会沿用六边形架构的理念,所以还是再贴出来一次:

领域驱动设计的六边形架构

我们先看第一个问题:在哪些地方可能出现错误

在哪些地方可能出现错误

理论上每个地方都可能出错,不过我们最好不要把所有地方的错误都当成同一种错误处理。原因是错误层级不同。

我认为至少有三种错误层级,从里到外分别是:

  • Domain Errors (领域错误)
  • Application Errors (应用错误)
  • Interface Errors (接口错误)

三种错误级别

对应到六边形架构的各个组件中就是:

  • Domain Errors => Entity & Value Object & Domain Service
  • Application Errors => Application Service (& Infrastructure?)
  • Interface Errors => Interface (Controller)

Infrastructure 部分的错误比较特殊,由于 Infrastructure 中的 Adapters 实现了 Application Service 中声明的 Ports,它的错误经常以某种形式被包装(比如一个 Result,这里有一个 npm package @badrap/result 提供了这种能力 ),并由 Application Service 处理。因此 Infrastructure 中的错误一般都可以被转化为某种 Application Error。

Value Object 也需要特别说明一下,由于 Value Object 一般自带数据验证逻辑,在使用不符合要求的数据初始化 Value Object 时会直接导致参数错误,这种错误一般会被直接抛出,这符合 Fail-fast 原则。

如何处理错误

在错误处理上,大致可以分为两种方式:

  1. try/catch
  2. 错误码

不同语言对这两种错误处理方式有不同的方式和偏好,比如 C 语言就没有 try/catch,只能使用错误码。Rust 则彻底抛弃 try/catch 语法,使用 Option<T>Result<T, E>。这两种方式各有利弊,这里并不会深入讨论。下面只说我个人比较推荐的做法。

我们刚才一直在说错误,其实广义上的错误有两种:

  1. 错误
  2. 异常

错误一般指的是开发者可以预料到的非正常情况,比如支付时当前账号余额不足,当前账号被锁定无法支付等。应用程序可以从这种情况中恢复。错误比较多指的是业务相关的非正常情况。

异常则是那些无法预料但又确实有可能发生的情况,比如内存不足、磁盘空间不足、网络断开、数组索引超出合法范围、调用外部 API 时对方返回 500等。这类情况是应用无法处理和恢复的。

Domain Errors 都是第一种错误,开发者有能力处理好它们。比如支付时当前账号余额不足时,返回 Insufficient balance error 给 Application Service,由后者决定如何进一步处理这个错误。Application Service 可能会将这个 Domain Error 分类到一个更大的 Application Error 类别中并将其返回给 Interface (Controller)。Controller 会判断这个 Application Error 对应到哪个 HTTP Error Code (这里假设是一个 Web Server)。

Value Object 属于领域层,但 Value Object 中的参数验证中发生的错误一般不会以错误码的形式返回给上一层,而是直接抛出错误,由顶层组件去捕获(Web Server 一般都有一个顶层方法用于捕获从下层抛出且在中途未被捕获的异常),此时可以对错误进行分类并对应到不同的 HTTP Error Code。

Application Errors 除了编排业务流还会和 Infrastructure 中的 Adapters 打交道,前者产生的错误是可预料的,后者则不一定,可以是可预料的也可以是不可预料的。比如有一个外部的 Account Service,我们在 Application Service 中需要向 Account Service 查询账号信息,然后才能继续执行。这个 Account Service 可能会返回 Account 相关的业务错误(比如未找到账号),也可能由于未知原因返回一个 500。对于开发者来说前一个错误是可以预料的,可以对应到一个 Domain Error。后一个错误则是不可预料的,但此时 Application Service 也要能够处理,比如可以返回一个 Unknown External Error 给 Controller,后者返回 500 给用户。Infrastructure 中的 Adapters 在内部可以使用 try/catch 处理具体实现,然后在 catch 块中对具体的出错信息进行判断,如果此错误会导致某个业务操作出错,就将它转化成某种业务错误并返回,否则可以返回一个 Unknown External Error。

由于 Interface (Controller) 起到一个接受外部请求和返回响应结果的中间角色,因此 Interface Errors 可以是根据从 Application Service 返回的错误映射到的相应的 HTTP Error Code,也可以是因为用户传入的 DTO 中的数据类型不正确直接返回 400 错误。如果是命令行程序,Interface Errors 还可以返回自定义的错误码。

这样从同心圆的内部到外部根据不同错误层级进行处理,我们就完成了错误处理的流程。

是否需要自定义状态码

HTTP Status Code 提供了应用层协议级别的状态码,常见的 200、302、400、404、500 都能告诉客户端本次请求的一些基本情况。但业务是复杂的,简单的 HTTP Status Code 往往不能反映业务在服务端处理的真实情况,这时候可能有必要引入业务状态代码来更进一步说明业务的处理状态。在将 API 提供给外部开发者使用时,一般都需要提供文档,业务状态码可以告诉开发者更具体的情况,也有助于客户端处理错误。

总结

本篇讨论了在领域驱动设计实践下一种可能的错误处理方式,将错误分为了 Domain Errors、Application Errors 和 Interface Errors 三种,每种错误都有自己合适的处理方式。