2、领域驱动设计中的错误:领域错误与基础设施错误的分离、领域错误的语义化表达
在领域驱动设计(DDD)中,错误处理不是技术细节,而是领域概念的延伸。将错误按边界分离,并用领域语言表达,是保持模型完整性的关键。
2.1 错误的边界归属:领域 vs 基础设施
DDD 将系统划分为不同的层与边界。错误天然具有归属:它发生在哪里,就应由哪一层定义和抛出。混淆边界会导致领域逻辑被技术细节污染。
| 错误类型 | 归属层 | 典型来源 | 处理方式 |
|---|---|---|---|
| 领域错误 | 领域层(Entity / Value Object / Domain Service) | 业务规则校验、状态冲突、不变量违反 | 抛出领域异常或返回领域错误对象 |
| 基础设施错误 | 基础设施层(Repository / HTTP Client / DB) | 网络超时、数据库连接失败、文件不存在 | 在基础设施层捕获,转换为领域可理解的错误或由应用层编排 |
| 应用错误 | 应用层(Application Service) | 编排失败、事务回滚、权限不足 | 组合领域错误与基础设施错误,统一响应 |
核心原则:领域层绝不应直接引用
io.EOF、sql.ErrNoRows 或 HTTP 状态码。这些是基础设施的实现细节,一旦引入,领域模型就失去了独立演化的能力。
2.2 领域错误的语义化表达
领域错误不是简单的字符串,而是携带业务语义的值对象。在 Go 中,我们通过自定义错误类型来承载领域概念。
// 领域错误:表示账户余额不足
type InsufficientBalanceError struct {
AccountID string
Current Money
Required Money
}
func (e *InsufficientBalanceError) Error() string {
return fmt.Sprintf("账户 %s 余额不足:当前 %.2f,需要 %.2f",
e.AccountID, e.Current.Amount(), e.Required.Amount())
}
// 领域错误:表示订单状态不允许操作
type OrderStateConflictError struct {
OrderID string
CurrentState OrderState
ExpectedState OrderState
}
func (e *OrderStateConflictError) Error() string {
return fmt.Sprintf("订单 %s 当前状态为 %s,期望状态为 %s",
e.OrderID, e.CurrentState, e.ExpectedState)
}
语义化错误的价值在于:
- 可断言:调用方可以通过类型断言精确判断错误原因,而非解析字符串。
- 可携带上下文:错误对象包含业务关键数据(如账户ID、当前余额),便于日志和排查。
- 可测试:单元测试中可以直接构造领域错误对象,验证分支逻辑。
2.3 分离与转换:基础设施错误 → 领域错误
基础设施层产生的错误必须被隔离。应用层或基础设施适配器负责将技术错误转换为领域层可理解的语义。
// 基础设施层:仓储实现
type UserRepositoryImpl struct {
db *sql.DB
}
func (r *UserRepositoryImpl) FindByID(id UserID) (*User, error) {
row := r.db.QueryRow("SELECT ...", id.String())
var user User
err := row.Scan(&user.Name, &user.Email)
if errors.Is(err, sql.ErrNoRows) {
// 转换:将 sql.ErrNoRows 转换为领域错误
return nil, &UserNotFoundError{UserID: id}
}
if err != nil {
// 其他数据库错误:包装为基础设施错误,不暴露给领域层
return nil, fmt.Errorf("查询用户失败: %w", err)
}
return &user, nil
}
实践建议:在仓储接口的签名中,只返回领域错误类型。基础设施实现内部捕获技术异常,转换为领域错误后再返回。这样领域层永远不需要 import
database/sql 或 net/http。
2.4 领域错误的结构化设计
为了支持错误码断言和统一处理,领域错误应遵循一致的结构。推荐使用接口 + 具体类型的组合。
// 领域错误接口:所有领域错误必须实现
type DomainError interface {
error
Code() string // 错误码,如 "ACCOUNT_INSUFFICIENT_BALANCE"
Message() string // 面向用户的友好消息
Details() map[string]interface{} // 额外上下文
}
// 基础领域错误:提供默认实现
type BaseDomainError struct {
code string
message string
details map[string]interface{}
}
func (e *BaseDomainError) Error() string { return e.message }
func (e *BaseDomainError) Code() string { return e.code }
func (e *BaseDomainError) Message() string { return e.message }
func (e *BaseDomainError) Details() map[string]interface{} { return e.details }
// 具体领域错误:组合基础结构
type InsufficientBalanceError struct {
BaseDomainError
AccountID string
Current Money
Required Money
}
func NewInsufficientBalanceError(accountID string, current, required Money) *InsufficientBalanceError {
return &InsufficientBalanceError{
BaseDomainError: BaseDomainError{
code: "ACCOUNT_INSUFFICIENT_BALANCE",
message: "账户余额不足",
details: map[string]interface{}{
"account_id": accountID,
"current": current.Amount(),
"required": required.Amount(),
},
},
AccountID: accountID,
Current: current,
Required: required,
}
}
2.5 错误码断言:Go 中的实践模式
在 Go 中,领域错误的断言通常使用 errors.As 或类型断言。结合语义化错误类型,可以实现精确的错误码匹配。
// 应用层:处理转账
func (s *TransferService) Transfer(from, to AccountID, amount Money) error {
// 调用领域服务
err := s.domainService.Transfer(from, to, amount)
if err != nil {
// 断言领域错误类型
var insufficientErr *InsufficientBalanceError
if errors.As(err, &insufficientErr) {
// 记录业务日志,返回特定错误码
s.logger.Warn("转账失败:余额不足",
"from", insufficientErr.AccountID,
"current", insufficientErr.Current,
"required", insufficientErr.Required)
return NewAppError("INSUFFICIENT_BALANCE", insufficientErr.Message())
}
var stateConflictErr *OrderStateConflictError
if errors.As(err, &stateConflictErr) {
s.logger.Warn("转账失败:状态冲突",
"order", stateConflictErr.OrderID)
return NewAppError("ORDER_STATE_CONFLICT", stateConflictErr.Message())
}
// 未知领域错误
return NewAppError("INTERNAL_ERROR", "内部处理失败")
}
return nil
}
避坑指南:不要使用
errors.Is 断言领域错误,因为 Is 比较的是错误值,而领域错误通常是带上下文的指针类型,每个实例都不同。应始终使用 errors.As 进行类型断言。
2.6 错误码枚举与领域边界
将错误码定义为领域层的常量,与应用层或传输层的错误码解耦。领域错误码只表达“业务发生了什么”,不表达“HTTP 状态码是什么”。
// 领域层:错误码定义
package domain
const (
ErrCodeInsufficientBalance = "INSUFFICIENT_BALANCE"
ErrCodeOrderStateConflict = "ORDER_STATE_CONFLICT"
ErrCodeUserNotFound = "USER_NOT_FOUND"
ErrCodeProductOutOfStock = "PRODUCT_OUT_OF_STOCK"
)
// 应用层:错误码映射(仅在应用层)
var domainToHTTPStatus = map[string]int{
domain.ErrCodeInsufficientBalance: http.StatusConflict,
domain.ErrCodeOrderStateConflict: http.StatusConflict,
domain.ErrCodeUserNotFound: http.StatusNotFound,
domain.ErrCodeProductOutOfStock: http.StatusGone,
}
边界收益:当业务规则变化时,只需修改领域层的错误码和错误类型,应用层和传输层的映射逻辑无需变动。领域模型保持纯净,技术实现自由替换。
2.7 总结:领域错误设计的检查清单
- 语义化:错误类型名称直接反映业务概念(如
InsufficientBalanceError)。 - 携带上下文:错误对象包含业务关键字段,便于日志和调试。
- 边界隔离:领域层不引用任何基础设施类型或错误。
- 可断言:通过
errors.As精确匹配错误类型,而非字符串比较。 - 统一结构:所有领域错误实现相同的接口(
Code()、Message()、Details())。 - 错误码枚举:在领域层定义常量,与应用层/传输层解耦。