API Client Architecture
Why a Dedicated API Client?
A well-designed API client centralizes all networking logic. It provides a single place to handle base URLs, authentication headers, request formatting, response parsing, and error handling.
Protocol-Based Design
Define an API client protocol:
protocol APIClient {
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}
Concrete Implementation
final class LiveAPIClient: APIClient {
private let session: URLSession
private let decoder: JSONDecoder
private let baseURL: URL
init(baseURL: URL, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .iso8601
}
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
let request = try endpoint.urlRequest(base: baseURL)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw APIError.httpError(statusCode: httpResponse.statusCode, data: data)
}
return try decoder.decode(T.self, from: data)
}
}
Dependency Injection
Inject the API client into your services:
final class UserService {
private let apiClient: APIClient
init(apiClient: APIClient = LiveAPIClient(baseURL: URL(string: "https://api.example.com")!)) {
self.apiClient = apiClient
}
func getUsers() async throws -> [User] {
try await apiClient.request(.users)
}
}
This makes testing easy - you can inject a mock client.
Endpoint Design
Enum-Based Endpoints
Define all API endpoints as an enum for type safety:
enum Endpoint {
case users
case user(id: Int)
case posts(userId: Int)
case createUser(name: String, email: String)
case updateUser(id: Int, name: String)
case deleteUser(id: Int)
}
URL Construction
Build URLs from endpoints:
extension Endpoint {
func urlRequest(base: URL) throws -> URLRequest {
var components = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false)
if let queryItems = queryItems {
components?.queryItems = queryItems.map { URLQueryItem(name: $0.key, value: $0.value) }
}
guard let url = components?.url else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
if let body = body {
request.httpBody = try JSONEncoder().encode(body)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
}
request.addValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 30
return request
}
private var path: String {
switch self {
case .users: return "/users"
case .user(let id): return "/users/\(id)"
case .posts(let userId): return "/users/\(userId)/posts"
case .createUser: return "/users"
case .updateUser(let id, _): return "/users/\(id)"
case .deleteUser(let id): return "/users/\(id)"
}
}
private var method: HTTPMethod {
switch self {
case .users, .user, .posts: return .get
case .createUser: return .post
case .updateUser: return .put
case .deleteUser: return .delete
}
}
}
Request Bodies
For POST and PUT requests, create request body structs:
struct CreateUserBody: Encodable {
let name: String
let email: String
}
struct UpdateUserBody: Encodable {
let name: String
}
HTTP Method Enum
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
Error Handling and Retry
API Error Types
Define comprehensive error types:
enum APIError: Error, LocalizedError {
case invalidURL
case invalidResponse
case decodingError(Error)
case httpError(statusCode: Int, data: Data)
case networkError(Error)
case rateLimited(retryAfter: TimeInterval)
case unauthorized
case notFound
case serverError
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL"
case .invalidResponse: return "Invalid server response"
case .decodingError(let error): return "Decoding failed: \(error.localizedDescription)"
case .httpError(let code, _): return "HTTP error \(code)"
case .networkError(let error): return "Network error: \(error.localizedDescription)"
case .rateLimited(let retry): return "Rate limited. Retry after \(retry)s"
case .unauthorized: return "Unauthorized"
case .notFound: return "Resource not found"
case .serverError: return "Server error"
}
}
}
Retry with Exponential Backoff
func requestWithRetry<T: Decodable>(_ endpoint: Endpoint, maxRetries: Int = 3) async throws -> T {
var lastError: Error?
for attempt in 0..<maxRetries {
do {
return try await apiClient.request(endpoint)
} catch APIError.httpError(let statusCode, _) where (500...599).contains(statusCode) {
lastError = APIError.serverError
let delay = pow(2.0, Double(attempt))
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
} catch APIError.rateLimited(let retryAfter) {
lastError = APIError.rateLimited(retryAfter: retryAfter)
try await Task.sleep(nanoseconds: UInt64(retryAfter * 1_000_000_000))
} catch {
throw error
}
}
throw lastError ?? APIError.serverError
}
Result Type Handling
Use Result for callback-based error handling:
func fetchUsers() async -> Result<[User], APIError> {
do {
let users: [User] = try await apiClient.request(.users)
return .success(users)
} catch let error as APIError {
return .failure(error)
} catch {
return .failure(.networkError(error))
}
}
Network Monitor Integration
Check connectivity before making requests:
import Network
final class NetworkMonitor {
static let shared = NetworkMonitor()
private let monitor = NWPathMonitor()
private(set) var isConnected = true
func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
self?.isConnected = path.status == .satisfied
}
monitor.start(queue: DispatchQueue(label: "NetworkMonitor"))
}
}
// Usage in API client
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
guard NetworkMonitor.shared.isConnected else {
throw APIError.networkError(NSError(domain: "No connection", code: -1))
}
// make request
}
Quiz
1. What is the benefit of an enum-based Endpoint design?
2. What is exponential backoff?
3. Why use protocol-based API client design?
4. Which HTTP status codes typically trigger a retry?
Flashcards
Question
Why use an enum for API endpoints?
Click to reveal answer
Answer
Enum-based endpoints provide type-safe URL construction and exhaustive switch handling for all routes.
Question
What is the purpose of an API client protocol?
Click to reveal answer
Answer
It enables dependency injection, making it easy to swap real implementations with mocks for testing.
Question
When should you retry a failed request?
Click to reveal answer
Answer
Retry on 5xx server errors (transient) and rate limiting. Do not retry on 4xx client errors (permanent).
Question
What is exponential backoff?
Click to reveal answer
Answer
A retry strategy where the delay doubles each attempt (1s, 2s, 4s) to prevent server overload.
Revision Notes
Key Takeaways
- 1. Protocol-based API clients enable testing and dependency injection
- 2. Enum-based endpoints provide compile-time type safety for all routes
- 3. Exponential backoff prevents server overload during retries
- 4. Custom APIError enums provide structured, user-friendly error messages
Interview Tips
- • Explain the benefit of protocol-based API client design for testing
- • Know how to implement enum-based endpoint routing
- • Describe when to retry vs when to fail immediately
- • Discuss exponential backoff and rate limit handling
Cheat Sheet
APIClient protocol for dependency injection. Enum-based endpoints for type-safe routing. JSONDecoder with keyDecodingStrategy for snake_case. Retry with exponential backoff on 5xx errors. NetworkMonitor for connectivity checks. Result type for error handling without throwing.