Modern iOS development requires clean, efficient networking code. Swift's async/await makes API calls readable and maintainable. Let me show you how to build a complete networking layer from scratch.
Why Async/Await?
Before Swift 5.5, we used completion handlers:
// Old way - callback hell
func fetchUser(completion: @escaping (Result<User, Error>) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
// Handle response...
completion(.success(user))
}.resume()
}
Now with async/await:
// Modern way - clean and readable
func fetchUser() async throws -> User {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
Building the Network Layer
Step 1: Define the API Endpoint
enum Endpoint {
case users
case user(id: Int)
case posts
case createPost(title: String, body: String)
var path: String {
switch self {
case .users: return "/users"
case .user(let id): return "/users/\(id)"
case .posts: return "/posts"
case .createPost: return "/posts"
}
}
var method: HTTPMethod {
switch self {
case .users, .user, .posts: return .get
case .createPost: return .post
}
}
var body: Data? {
switch self {
case .createPost(let title, let body):
let dict = ["title": title, "body": body]
return try? JSONSerialization.data(withJSONObject: dict)
default:
return nil
}
}
}
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
Step 2: Create Custom Errors
enum NetworkError: LocalizedError {
case invalidURL
case invalidResponse
case decodingError
case serverError(statusCode: Int)
case noData
case unauthorized
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL"
case .invalidResponse: return "Invalid server response"
case .decodingError: return "Failed to decode response"
case .serverError(let code): return "Server error: \(code)"
case .noData: return "No data received"
case .unauthorized: return "Unauthorized access"
}
}
}
Step 3: Build the Network Service
actor NetworkService {
static let shared = NetworkService()
private let baseURL = "https://jsonplaceholder.typicode.com"
private let decoder = JSONDecoder()
private let session: URLSession
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.waitsForConnectivity = true
self.session = URLSession(configuration: config)
}
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
let request = try buildRequest(for: endpoint)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
do {
return try decoder.decode(T.self, from: data)
} catch {
throw NetworkError.decodingError
}
case 401:
throw NetworkError.unauthorized
default:
throw NetworkError.serverError(statusCode: httpResponse.statusCode)
}
}
private func buildRequest(for endpoint: Endpoint) throws -> URLRequest {
guard let url = URL(string: baseURL + endpoint.path) else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = endpoint.body
return request
}
}
Step 4: Create Models
struct User: Codable, Identifiable {
let id: Int
let name: String
let email: String
let username: String
}
struct Post: Codable, Identifiable {
let id: Int
let userId: Int
let title: String
let body: String
}
Step 5: Use in SwiftUI
@Observable
class UsersViewModel {
var users: [User] = []
var isLoading = false
var errorMessage: String?
func loadUsers() async {
isLoading = true
errorMessage = nil
do {
users = try await NetworkService.shared.request(.users)
} catch let error as NetworkError {
errorMessage = error.errorDescription
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
struct UsersView: View {
@State private var viewModel = UsersViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.errorMessage {
ContentUnavailableView(
"Error",
systemImage: "wifi.slash",
description: Text(error)
)
} else {
List(viewModel.users) { user in
VStack(alignment: .leading) {
Text(user.name).font(.headline)
Text(user.email).font(.caption).foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Users")
.task {
await viewModel.loadUsers()
}
.refreshable {
await viewModel.loadUsers()
}
}
}
}
Advanced: Parallel Requests
Need multiple API calls at once? Use async let:
func loadDashboard() async throws -> Dashboard {
async let users: [User] = NetworkService.shared.request(.users)
async let posts: [Post] = NetworkService.shared.request(.posts)
return try await Dashboard(users: users, posts: posts)
}
Advanced: Request Retry
extension NetworkService {
func requestWithRetry<T: Decodable>(
_ endpoint: Endpoint,
maxRetries: Int = 3
) async throws -> T {
var lastError: Error?
for attempt in 1...maxRetries {
do {
return try await request(endpoint)
} catch {
lastError = error
if attempt < maxRetries {
try await Task.sleep(for: .seconds(Double(attempt)))
}
}
}
throw lastError ?? NetworkError.invalidResponse
}
}
Key Takeaways
-
Use
actorfor thread-safe network services - Create custom errors for better error handling
- Use generics for reusable request methods
-
Leverage
async letfor parallel requests - Add retry logic for unreliable connections
Want a production-ready networking layer?
Check out my SwiftUI Starter Kit Pro - it includes:
- Complete NetworkManager with async/await
- Error handling and retry logic
- Request caching
- Mock service for testing
- 5 screens ready to use
Start building your app with a solid foundation!
Top comments (0)