iosNudge

Data and APIs

Chapter 5 of 8 4 min read

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 = .iso8601

Why 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

OptionGood forNot good for
UserDefaultsSmall settings and flagsBig data, secrets
KeychainTokens, passwords, keysBig data
FilesImages, downloads, JSON snapshotsSearching and sorting
URLCachePlain HTTP response cachingData you edit or query
SQLite (for example with GRDB)Large lists, fast queries, full controlVery small apps (setup cost)
Core DataObject graphs, iCloud sync, mature toolsTeams new to its threading rules
SwiftDataNew 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

MethodHow it worksUse it forCost
RESTRequest and response over HTTPMost reads and writesSimple, cacheable
GraphQLOne endpoint, the app picks the fieldsMany screens that need different dataHarder caching, more complex server
WebSocketA two-way connection that stays openChat, live prices, collaborationBattery, reconnect logic
Server-Sent EventsThe server streams text over HTTPLive scores, AI text streamingOne way only
PollingThe app asks again every N secondsRare updates, quick prototypesWastes battery and data
Push (APNs)Apple delivers a message to the deviceReach the user when the app is closedNot 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.
OffsetCursor
Jump to page 7YesNo
Stable when new items arriveNoYes
Speed on huge tablesSlow for big offsetsFast
Best forSearch results, admin tablesFeeds, 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. Codable already 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."