11-12.【错误处理】如何设计 Typed Error,使得依赖方可以明确判断错误类型而不破坏模块边界?

1 阅读3分钟

在模块化架构中设计 Typed Error 的最大挑战在于平衡:既要提供编译时的明确性,又要避免强耦合导致的修改困难(Fragility)。

如果不加克制地暴露底层的具体错误,任何内部逻辑的微调都可能导致所有依赖方编译失败。


1. 核心设计模式:抽象包装(The Wrapper Pattern)

不要直接抛出模块内部的原始错误,而是定义一个面向接口的 Error 枚举。这相当于在模块边界建立了一个“防腐层”。

设计策略:

  • 隐藏实现细节:将具体的底层错误(如 Alamofire.AFErrorCoreDataError)封装在模块内部,不暴露给依赖方。
  • 分类暴露:只暴露调用方有能力处理需要知道的错误。

Swift

// 模块 A 的 Public 边界
public enum NetworkModuleError: Error {
    case connectionLost
    case unauthorized
    case serverSide(code: Int)
    case internalInconsistency // 隐藏所有底层的解析错误、数据库崩溃等
}

public func fetchData() throws(NetworkModuleError) -> Data { ... }

2. 利用泛型错误(Generic Errors)保持灵活性

如果你希望模块更加通用,可以使用泛型来传递错误类型。这在处理插件或中间件时非常有用,且不会破坏边界。

Swift

public struct ModuleResult<E: Error> {
    public func execute<T>(_ action: () throws(E) -> T) throws(E) -> T {
        try action()
    }
}

3. 设计“不透明”但“可分类”的错误

如果你不想暴露完整的枚举分支,可以利用 Swift 的 Protocol 来设计错误。依赖方只需要判断错误所属的“类别”,而不需要识别具体类型。

Swift

public protocol RetriableError: Error {
    var shouldRetry: Bool { get }
}

// 依赖方只需要关心这个:
do {
    try module.performAction()
} catch let error as RetriableError where error.shouldRetry {
    // 自动重试逻辑,无需知道具体是哪个子模块崩了
} catch { ... }

4. 转换技巧:从 Internal 到 Public

在模块内部,你可以使用普通 throws 甚至更细粒度的 Typed Error。但在最终暴露给依赖方的函数中,使用 do-catch 进行统一映射。

Swift

func internalOperation() throws(InternalDBError) { ... }

// Public 接口
public func publicAPI() throws(PublicModuleError) {
    do {
        try internalOperation()
    } catch .diskFull {
        throw .storageIssue // 映射
    } catch {
        throw .unknown      // 屏蔽无关紧要的细节
    }
}

5. 什么时候该回避 Typed Error?

为了不破坏模块边界,在以下场景中,传统的 any Error 反而更优

  • 多插件系统:如果你的模块允许第三方注入代码,你无法预知他们会抛出什么错误。
  • 高度动态的应用层:业务需求变化极快时,强类型的错误契约会成为重构的枷锁。
  • 跨团队大型协作:如果每次增加一个 Error Case 都要让其他十个团队更新代码,那么 any Error 的解耦价值就体现出来了。

总结:防御式边界设计表

需求方案边界影响
极致性能/安全性具体的 Typed Error 枚举强耦合:修改枚举需重新编译所有依赖
业务逻辑分发抽象包装枚举 (Wrapper)中耦合:仅暴露业务相关的抽象分类
高度插件化/解耦any Error (传统 throws)零耦合:依赖方仅需处理 catch-all
可复用逻辑Protocol-based Error松耦合:依赖方根据行为(Behavior)而非类型判断