SwiftQA Random

How do you design app-wide error handling?

1. Start with a layered error taxonomy, not one flat Error type

// Networking layer
enum APIError: Error {
    case noConnectivity, unauthorized, notFound
    case serverError(statusCode: Int)
    case decodingFailed(underlying: Error)
}

// Domain/Repository layer — translates APIError into domain-meaningful errors
enum ProfileError: Error {
    case userNotFound
    case sessionExpired
    case updateFailed(reason: String)
}

func fetchProfile() async throws(ProfileError) -> Profile {
    do {
        return try await apiClient.send(FetchProfileRequest())
    } catch APIError.notFound {
        throw ProfileError.userNotFound
    } catch APIError.unauthorized {
        throw ProfileError.sessionExpired
    } catch {
        throw ProfileError.updateFailed(reason: "\(error)")
    }
}

The ViewModel/UI layer never needs to know what an HTTP 404 is.

2. Distinguish error categories that need genuinely different handling

  • Recoverable/expected errors — no connectivity, validation failures, expired session — show a specific, actionable message.
  • Unexpected/programmer errors — should crash in debug and be reported via crash analytics in production, never silently swallowed.
  • Silent/non-critical errors — analytics send failure, non-essential background sync — log it, never interrupt the user.

3. A centralized error presentation mechanism, not ad hoc alerts scattered everywhere

protocol UserFacingError {
    var title: String { get }
    var message: String { get }
    var recoveryAction: RecoveryAction? { get }
}

extension ProfileError: UserFacingError {
    var title: String {
        switch self {
        case .sessionExpired: "Session Expired"
        case .userNotFound: "Profile Not Found"
        case .updateFailed: "Update Failed"
        }
    }
    var recoveryAction: RecoveryAction? {
        switch self {
        case .sessionExpired: .logInAgain
        default: nil
        }
    }
}

A shared presentation layer consumes anything conforming to UserFacingError uniformly.

4. A top-level catch-all for genuinely unexpected failures

  • A global uncaught-error boundary with a fallback UI.
  • Crash reporting integration (Crashlytics, Sentry) with full context.
  • fatalError/assertion discipline reserved genuinely for programmer errors.

5. Structured logging tied to errors, correlated across the stack

struct ErrorContext {
    let error: Error
    let screen: String
    let userAction: String?
    let timestamp: Date
    let requestID: String?  // ties back to server-side logs
}

Threading a request ID from the networking layer into error logging is what actually makes a production incident debuggable.

6. Retry and recovery affordances designed per error category, not generically

  • Transient failures — retry is correct, often automatic with backoff.
  • Auth failures — correct recovery is re-login, not a retry.
  • Validation failures — correct recovery is guiding the user to fix input.
  • Permanent/server-side failures — retry is actively wrong.

Baking the appropriate recovery action into the error type itself lets the presentation layer render the right affordance automatically.

7. Sendable and concurrency-safety for error types

Since errors frequently cross actor/task boundaries, error types should be Sendable — often trivially true for enum-based errors with Sendable associated values, but worth checking explicitly if a case wraps a non-Sendable third-party error.

8. Testing error paths as rigorously as happy paths

func testProfileFetch_whenUnauthorized_throwsSessionExpired() async {
    let mockClient = MockHTTPClient(stubbedError: APIError.unauthorized)
    let repository = ProfileRepository(client: mockClient)
    await assertThrowsError(try await repository.fetchProfile()) { error in
        XCTAssertEqual(error as? ProfileError, .sessionExpired)
    }
}

Testing that the right error translation happens at each boundary is what actually gives confidence the taxonomy holds together end to end.

The unifying principle

Each layer defines errors meaningful to itself, translates deliberately at boundaries, and a centralized (not scattered) presentation layer decides how to surface them — with a clear, separate policy for recoverable, unexpected, and silent categories.

Interview-ready summary

ConcernMechanism
Error modelingLayered, typed errors per layer; translate deliberately at boundaries
PresentationCentralized UserFacingError-driven presenter, not scattered alerts
Category handlingRecoverable (actionable UI) vs. unexpected (crash report) vs. silent (log only)
Unhandled fallbackGlobal catch-all boundary + crash reporting for anything that escapes typing
Recovery affordancesTied to error type — retry vs. re-login vs. guide-user-to-fix, not generic
ObservabilityStructured logging with correlation IDs tying client errors to server logs
ConcurrencyError types kept Sendable since they cross actor/task boundaries routinely
TestingError paths tested with the same rigor as happy paths, enabled by DI/mocking

One-liner if pressed: “I’d model errors per layer — networking, domain, presentation — translating deliberately at each boundary so UI code never has to interpret an HTTP status code, split handling into recoverable/user-facing, unexpected/crash-reportable, and silent/log-only categories since they need genuinely different treatment, centralize presentation through one error-to-UI mapping rather than scattered alerts, and back it with structured logging and a global catch-all for whatever inevitably escapes the typed error paths.”