Data and APIs
Two kinds of models
Use two kinds of models and map between them in the data layer:
- DTO (data transfer object): matches the server JSON exactly.
- Domain model: what the rest of the app uses. Clean, safe types.
// Matches the JSON. Server names, server types, optional fields.
struct PostDTO: Decodable {
let id: String
let authorName: String
let imageUrl: String // "image_url" in JSON
let likeCount: Int?
let createdAt: Date
}
// What the app uses.
struct Post: Identifiable, Hashable, Codable, Sendable {
let id: String
let authorName: String
let imageURL: URL
var likeCount: Int
let createdAt: Date
}
extension Post {
init?(dto: PostDTO) {
guard let url = URL(string: dto.imageUrl) else { return nil } // drop broken items
self.init(id: dto.id, authorName: dto.authorName, imageURL: url,
likeCount: dto.likeCount ?? 0, createdAt: dto.createdAt)
}
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase // author_name -> authorName
decoder.dateDecodingStrategy = .iso8601Why bother? The server can change a field without breaking your views. Missing or bad data is
handled in one place. The trade-off is more code. For a tiny app, one Codable model is fine.
Say that.
Where to store data
| Option | Good for | Not good for |
|---|---|---|
UserDefaults | Small settings and flags | Big data, secrets |
| Keychain | Tokens, passwords, keys | Big data |
| Files | Images, downloads, JSON snapshots | Searching and sorting |
URLCache | Plain HTTP response caching | Data you edit or query |
| SQLite (for example with GRDB) | Large lists, fast queries, full control | Very small apps (setup cost) |
| Core Data | Object graphs, iCloud sync, mature tools | Teams new to its threading rules |
| SwiftData | New SwiftUI apps on iOS 17+ | Older iOS versions, complex migrations |
Pick from the requirements, and say why:
"A chat app has thousands of messages. We need queries like 'the last 50 messages of this conversation, by date'. So I'd use a database: SQLite or Core Data. Settings go in
UserDefaults. The auth token goes in the Keychain."
Save a token in the Keychain:
import Security
func saveToken(_ token: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "authToken",
]
SecItemDelete(query as CFDictionary) // remove the old value first
var attributes = query
attributes[kSecValueData as String] = Data(token.utf8)
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
}Always hide storage behind a protocol. Then you can start with a JSON file and move to a database later without touching the view models.
protocol FeedStore: Sendable {
func loadAll() async throws -> [Post]
func replaceAll(_ posts: [Post]) async throws
}
actor FileFeedStore: FeedStore {
private let fileURL: URL
init(directory: URL) {
fileURL = directory.appending(path: "feed.json")
}
func loadAll() throws -> [Post] {
guard FileManager.default.fileExists(atPath: fileURL.path()) else { return [] }
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode([Post].self, from: data)
}
func replaceAll(_ posts: [Post]) throws {
let data = try JSONEncoder().encode(posts)
try data.write(to: fileURL, options: [.atomic, .completeFileProtection])
}
}It is an actor, so two writes can never run at the same time. .atomic writes to a temporary
file first, so a crash never leaves half a file.
How the app talks to the server
| Method | How it works | Use it for | Cost |
|---|---|---|---|
| REST | Request and response over HTTP | Most reads and writes | Simple, cacheable |
| GraphQL | One endpoint, the app picks the fields | Many screens that need different data | Harder caching, more complex server |
| WebSocket | A two-way connection that stays open | Chat, live prices, collaboration | Battery, reconnect logic |
| Server-Sent Events | The server streams text over HTTP | Live scores, AI text streaming | One way only |
| Polling | The app asks again every N seconds | Rare updates, quick prototypes | Wastes battery and data |
| Push (APNs) | Apple delivers a message to the device | Reach the user when the app is closed | Not guaranteed, small payload (4 KB) |
A common, strong answer combines them:
"REST for reads and writes. A WebSocket while the app is open, for live events. Push notifications when the app is closed."
Describe a REST endpoint as a small type:
struct Endpoint<Response: Decodable> {
var method = "GET"
var path: String
var query: [URLQueryItem] = []
var body: Data? = nil
func makeRequest(baseURL: URL) -> URLRequest {
var components = URLComponents(url: baseURL.appending(path: path),
resolvingAgainstBaseURL: false)!
if !query.isEmpty { components.queryItems = query }
var request = URLRequest(url: components.url!)
request.httpMethod = method
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return request
}
}
extension Endpoint where Response == FeedPageDTO {
static func feed(cursor: String?, limit: Int = 20) -> Endpoint {
var query = [URLQueryItem(name: "limit", value: String(limit))]
if let cursor { query.append(URLQueryItem(name: "cursor", value: cursor)) }
return Endpoint(path: "/v1/feed", query: query)
}
}A WebSocket with URLSession:
func listen(to socket: URLSessionWebSocketTask) async {
socket.resume()
while !Task.isCancelled {
do {
let message = try await socket.receive()
if case .string(let text) = message {
handle(text)
}
} catch {
return // closed: reconnect with backoff (see Deep dives)
}
}
}Pagination: offset vs cursor
Pagination means loading a long list one page at a time. There are two main styles.
Offset: GET /feed?offset=20&limit=20 "skip 20 items, give me 20"
Page 1 = items 0-19. Then a new post arrives at the top.
Page 2 = items 20-39 of the NEW list. Old item 19 is now item 20.
The user sees it twice.
Cursor: GET /feed?cursor=p_812&limit=20 "give me 20 items after post p_812"
New posts at the top do not matter. No duplicates, no gaps.| Offset | Cursor | |
|---|---|---|
| Jump to page 7 | Yes | No |
| Stable when new items arrive | No | Yes |
| Speed on huge tables | Slow for big offsets | Fast |
| Best for | Search results, admin tables | Feeds, chat, timelines |
For feeds and chat, pick cursor. The cursor is an opaque string from the server. The app never builds it. It just sends it back.
A paginator in the view model:
@MainActor
@Observable
final class FeedPaginator {
private(set) var posts: [Post] = []
private var nextCursor: String?
private var isLoading = false
private var reachedEnd = false
private let repository: FeedRepository
init(repository: FeedRepository) {
self.repository = repository
}
/// Call it when a row appears. It loads more when the user is 5 rows from the end.
func rowAppeared(_ post: Post) async {
guard posts.suffix(5).contains(where: { $0.id == post.id }) else { return }
await loadNextPage()
}
func loadNextPage() async {
guard !isLoading, !reachedEnd else { return }
isLoading = true
defer { isLoading = false }
do {
let page = try await repository.fetchFeed(cursor: nextCursor)
let known = Set(posts.map(\.id))
posts += page.posts.filter { !known.contains($0.id) } // skip duplicates
nextCursor = page.nextCursor
reachedEnd = page.nextCursor == nil
} catch {
// Keep nextCursor as it is, so a retry asks for the same page.
}
}
}Three details to mention: the isLoading guard stops double requests, duplicates are filtered by
ID, and a failed page can be retried.
The API contract
The contract is what the app and the server agree on. Write the important parts:
GET /v1/feed?cursor={cursor}&limit={1..50}
200 {
"posts": [
{ "id": "p_1", "author_name": "Ana", "image_url": "https://...",
"like_count": 3, "created_at": "2026-01-02T10:00:00Z" }
],
"next_cursor": "p_1" // null when there are no more pages
}
POST /v1/messages
Headers: Authorization: Bearer <token>
Idempotency-Key: 6F9619FF-8B86-D011-B42D-00C04FC964FF
Body: { "conversation_id": "c_9", "text": "Hi", "client_id": "6F9619FF-..." }
201 { "id": "m_77", "client_id": "6F9619FF-...", "sent_at": "..." }
Errors, the same shape on every endpoint:
4xx/5xx { "error": { "code": "rate_limited", "message": "...", "retry_after": 30 } }The error shape as Swift:
struct APIErrorDTO: Decodable, Error {
struct Body: Decodable {
let code: String
let message: String
let retryAfter: Int?
}
let error: Body
}Idempotency key
The app may send a message, lose the network, and retry. Without protection the server saves the
message twice. With an Idempotency-Key header, the server sees the same key and returns the first
result. The client_id also lets the app match the server reply to the message it already shows
as "sending".
Versioning
Old app versions stay on phones for months. So the server must never break them.
- Put a version in the path:
/v1/. - Add fields. Do not remove or rename them.
- The app must ignore unknown fields.
Codablealready does this. - The app must survive unknown enum values:
enum MessageKind: Decodable {
case text, image, unknown
init(from decoder: any Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
switch raw {
case "text": self = .text
case "image": self = .image
default: self = .unknown // new server, old app: show a fallback, do not crash
}
}
}What to say
"I'll keep DTOs separate from domain models and map them in the repository. Posts are cached in a database for offline use, and the token lives in the Keychain. The API is REST with cursor pagination. Writes carry an idempotency key, so retries are safe. The app ignores unknown fields and enum values, so old versions keep working."