Networking, persistence and Codable
URLSession with async and await, JSONDecoder and CodingKeys, mapping errors, caching, UserDefaults, file storage and SwiftData essentials.
Requests with async and await
struct Article: Decodable, Identifiable {
let id: Int
let title: String
let body: String
let publishedAt: Date
enum CodingKeys: String, CodingKey {
case id, title, body
case publishedAt = "published_at"
}
}
enum APIError: Error, LocalizedError {
case status(Int)
case transport(Error)
case decoding(Error)
var errorDescription: String? {
switch self {
case .status(let code): return "Server returned \(code)."
case .transport: return "Could not reach the server."
case .decoding: return "Unexpected response format."
}
}
}
struct API {
static let shared = API()
private let session: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.waitsForConnectivity = true
config.requestCachePolicy = .returnCacheDataElseLoad
return URLSession(configuration: config)
}()
private let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
func article(_ id: Int) async throws -> Article {
var request = URLRequest(url: URL(string: "https://api.example.com/articles/\(id)")!)
request.setValue("application/json", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.status(0) }
guard (200..<300).contains(http.statusCode) else { throw APIError.status(http.statusCode) }
return try decoder.decode(Article.self, from: data)
} catch let error as DecodingError {
throw APIError.decoding(error)
} catch let error as APIError {
throw error
} catch {
throw APIError.transport(error)
}
}
}URLSessionConfigurationis where timeouts, caching and connectivity waiting live — set them once instead of per request.DecodingErrornames the exact key path that failed, so log it rather than swallowing it into a generic message.- Use one shared decoder, and set
dateDecodingStrategyto match the server exactly: ISO 8601 with and without fractional seconds are different keys. - A cancelled task throws
URLError.cancelled; treat it as normal and do not show an error.
UserDefaults, files and SwiftData
// UserDefaults: small, non-sensitive preferences only
enum Preferences {
private static let key = "onboarding_complete"
static var onboardingComplete: Bool {
get { UserDefaults.standard.bool(forKey: key) }
set { UserDefaults.standard.set(newValue, forKey: key) }
}
}
// files: anything larger, in the documents directory
struct FileStore {
let directory: URL
static func make() throws -> FileStore {
let base = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
return FileStore(directory: base.appendingPathComponent("cache", isDirectory: true))
}
func write(_ data: Data, name: String) throws {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: directory.appendingPathComponent(name), options: [.atomic, .completeFileProtection])
}
}
// SwiftData: a model graph with queries
import SwiftData
@Model
final class Task {
var title: String
var done: Bool
var due: Date?
init(title: String, done: Bool = false, due: Date? = nil) {
self.title = title
self.done = done
self.due = due
}
}
@Query(filter: #Predicate<Task> { !$0.done }, sort: \.title) private var open: [Task]| Store | Fits | Avoid |
|---|---|---|
UserDefaults | Flags, small preferences | Anything secret or large |
| Files | Documents, images, exports | Storing an index you must keep in sync |
| Keychain | Tokens, passwords | Bulk data |
| SwiftData | Relational model graphs | A schema that changes every release without migrations |
| Core Data | Mature apps with heavy customisation | New projects with no legacy constraint |
Caching and background refresh
actor ArticleCache {
private var entries: [Int: (article: Article, savedAt: Date)] = [:]
func article(_ id: Int, maxAge: TimeInterval = 300) -> Article? {
guard let entry = entries[id],
Date.now.timeIntervalSince(entry.savedAt) < maxAge else { return nil }
return entry.article
}
func store(_ article: Article) {
entries[article.id] = (article, .now)
}
}
func article(_ id: Int, cache: ArticleCache) async throws -> Article {
if let cached = await cache.article(id) { return cached }
let fresh = try await API.shared.article(id)
await cache.store(fresh)
return fresh
}⚠️
A background refresh has a hard time budget and no guarantee of being scheduled. Do the minimum useful work, complete deterministically, and never assume it ran — the next foreground launch must still fetch what it needs.
FAQ
When should I move from Core Data to SwiftData?
For a new app, start with SwiftData unless you need features it lacks, such as complex fetch request customisation or existing tooling. Migrating an app with a non-trivial Core Data model is a project, not a swap.
How do I handle a server that changes its date format?
Write a custom
dateDecodingStrategy closure with a list of formats to try, and log when the fallback is used. Relying on one exact format means a server deploy breaks every client at once.Related
SwiftUI: views, state and navigation Collections, strings and the standard library
Last refreshed 2026-09-18.