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
| Concern | Mechanism |
|---|---|
| Error modeling | Layered, typed errors per layer; translate deliberately at boundaries |
| Presentation | Centralized UserFacingError-driven presenter, not scattered alerts |
| Category handling | Recoverable (actionable UI) vs. unexpected (crash report) vs. silent (log only) |
| Unhandled fallback | Global catch-all boundary + crash reporting for anything that escapes typing |
| Recovery affordances | Tied to error type — retry vs. re-login vs. guide-user-to-fix, not generic |
| Observability | Structured logging with correlation IDs tying client errors to server logs |
| Concurrency | Error types kept Sendable since they cross actor/task boundaries routinely |
| Testing | Error 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.”