Decodable That Survives Real APIs: Key Strategies, Key Paths, and Clean Domain Models

Decodable That Survives Real APIs: Key Strategies, Key Paths, and Clean Domain Models

How do we decode messy, inconsistent API payloads into clean domain models without turning every model into custom boilerplate?

Real APIs rarely look like your Swift types.

Keys drift (full_namefullNameFullName). Payloads wrap ({ "data": { … } }). Teams ship versioned responses with "legacy" fields still hanging around. And if you force your domain model to mirror the wire, your core types slowly become a landfill of optionals and edge-case logic.

This post is a practical decoding toolbelt that keeps your app clean:

  • Key strategies to reduce CodingKeys boilerplate when keys don't match.
  • Key-path decoding to pull what you care about out of nested wrapper responses.
  • A clean separation between DTOs (wire types) and domain models so your app doesn't inherit API mess.

The ideas build on work by my friend and former MartianCraft colleague Richard Turton, who wrote two excellent deep-dives on custom decoding: Implementing custom key strategy for coding types and Going deep with Decodable. This article synthesizes those strategies into a production-friendly approach that scales across teams.


Why This Matters: Three Wins

Before diving into implementation, let's be clear about what we're trying to achieve:

1. Skip the Wrappers

Most APIs wrap the data you care about in metadata:

{ "status": "ok", "data": { "user": { ... } } }

Without key-path decoding, you'd model every layer—APIResponseDataWrapper, etc.—just to reach the User. That's boilerplate that adds no value. Key-path decoding lets you say "decode User from data.user" and skip the ceremony.

2. Stop Writing CodingKeys

Every time you write this:

enum CodingKeys: String, CodingKey {
    case userID = "user_id"
    case fullName = "full_name"
    case createdAt = "created_at"
}

You're doing the same mechanical transformation: snake_case → camelCase. Multiply that across 30 DTOs and you've got hundreds of lines that all say the same thing in slightly different ways.

A key strategy centralizes that rule once. Now your DTOs are just the shape of the data—no ceremony. You only write CodingKeys when they add actual information (exceptions to the pattern), not as ritual.

3. Validate at the Door

This is the architectural insight that matters most long-term.

DTOs are permissive. They exist to accept whatever the wire sends, even if it's weird or incomplete. Their job is to not crash during decoding.

Domain models are strict. They represent what your app actually needs to function correctly. They enforce invariants—rules that must always be true.

The failable initializer (init?(dto:)) is the gate. Bad data gets rejected at the boundary, not three screens deep when something explodes. If you have a User, you know it has a valid ID and name. No defensive checks scattered everywhere.

The alternative—decoding directly into domain models—forces your core types to be permissive. You end up with optionals everywhere, or force-unwraps that crash, or validation logic scattered across view models and services. The boundary disappears, and API mess leaks into your whole app.


Quick Start

If you want the pattern without the philosophy:

// 1. Configure once
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase  // or .custom for complex cases

// 2. Decode through wrappers
let userDTO = try decoder.decode(UserDTO.self, from: data, at: "data.user")

// 3. Map to domain (validation happens here)
guard let user = User(dto: userDTO) else {
    // Handle invalid data at the boundary
    throw ValidationError.invalidUser
}

The rest of this article explains how to build the pieces.


The Architecture: DTOs Are the Boundary, Decoding Strategies Are the Plumbing

Let's clear up the common confusion up front:

  • DTO strategy is an architectural decision: keep wire-shaped types at the boundary, map into domain types.
  • Key strategies / key-path decoding are implementation techniques: reduce boilerplate and tame inconsistent JSON.

A simple mental model:

DTOs are the boundary; key strategies and key paths are the plumbing that makes the boundary affordable.

If you decode directly into domain models, you can get away with it early—until your API changes. Then domain types start accumulating "wire concerns," and you lose the whole point of having a domain.


Complete Example: Before and After

Let's ground this with a real scenario. Your API returns:

{
  "status": "ok",
  "data": {
    "user": {
      "user_id": "abc-123",
      "full_name": "  Ada Lovelace  ",
      "created_at": "2024-01-15T10:30:00Z",
      "account_type": "premium"
    }
  }
}

Before: The Painful Way

// You model every layer of the response
struct APIResponse: Decodable {
    let status: String
    let data: DataWrapper
}

struct DataWrapper: Decodable {
    let user: UserDTO
}

struct UserDTO: Decodable {
    let userID: String
    let fullName: String
    let createdAt: Date
    let accountType: String
    
    // Manual key mapping for every type
    enum CodingKeys: String, CodingKey {
        case userID = "user_id"
        case fullName = "full_name"
        case createdAt = "created_at"
        case accountType = "account_type"
    }
}

// Usage: unwrap through every layer
let response = try decoder.decode(APIResponse.self, from: data)
let user = response.data.user

That's 32 lines just to get a user out of a wrapper. And you'll write similar wrappers for every endpoint.

After: The Clean Way

// One DTO, no CodingKeys (handled by key strategy)
struct UserDTO: Decodable {
    let userID: String
    let fullName: String
    let createdAt: Date
    let accountType: String
}

// Clean domain model with invariants
struct User: Equatable {
    let id: String
    let name: String
    let createdAt: Date
    let isPremium: Bool
    
    init?(dto: UserDTO) {
        guard !dto.userID.isEmpty else { return nil }
        
        self.id = dto.userID
        self.name = dto.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
        self.createdAt = dto.createdAt
        self.isPremium = dto.accountType == "premium"
    }
}

// Usage: decode directly to what you need
let dto = try decoder.decode(UserDTO.self, from: data, at: "data.user")
let user = User(dto: dto)

12 lines for the DTO, domain model with validation, and usage. The wrapper structs are gone. The CodingKeys are gone. The domain model is free to rename fields and enforce invariants.


Problem #1: Keys Don't Match Your Swift Properties

The Naive Solution: CodingKeys Everywhere

struct UserDTO: Decodable {
    let userID: String
    let fullName: String
    let createdAt: Date

    enum CodingKeys: String, CodingKey {
        case userID = "user_id"
        case fullName = "full_name"
        case createdAt = "created_at"
    }
}

This works, but it scales poorly. You end up writing CodingKeys for every type, and changes become repetitive.

A Better Solution: keyDecodingStrategy = .custom

If your API follows a consistent transform (snake_case → camelCase, or weird casing conventions), you can centralize that transformation.

1. A Dynamic Coding Key

/// A CodingKey that can represent any string or integer key.
/// Used when we need to decode keys dynamically at runtime.
struct DynamicKey: CodingKey {
    var stringValue: String
    var intValue: Int?

    init(_ string: String) {
        self.stringValue = string
        self.intValue = nil
    }

    init?(stringValue: String) {
        self.init(stringValue)
    }

    init?(intValue: Int) {
        self.stringValue = "\(intValue)"
        self.intValue = intValue
    }
}

2. A Robust Transformer

extension String {
    /// Converts snake_case to camelCase.
    /// Examples: "user_id" → "userId", "full_name" → "fullName"
    func snakeToCamel() -> String {
        guard contains("_") else { return self }
        
        return split(separator: "_", omittingEmptySubsequences: true)
            .enumerated()
            .map { $0.offset == 0 ? $0.element.lowercased() : $0.element.capitalized }
            .joined()
    }
}

3. Configure Your Decoder

extension JSONDecoder {
    /// A decoder configured to convert snake_case JSON keys to camelCase Swift properties.
    static var snakeCaseConverting: JSONDecoder {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .custom { codingPath in
            guard let lastKey = codingPath.last else {
                return DynamicKey("")
            }
            
            // Preserve array indices
            if lastKey.intValue != nil { 
                return lastKey 
            }
            
            return DynamicKey(lastKey.stringValue.snakeToCamel())
        }
        return decoder
    }
}

Now your DTOs are just the shape of the data—no ceremony:

struct UserDTO: Decodable {
    let userID: String
    let fullName: String
    let createdAt: Date
}

The custom strategy (.custom) matters when your API doesn't follow Apple's built-in snake_case conversion exactly. Maybe it has quirks like userID vs userId, or your backend uses a different convention entirely. You write the transform once, and every DTO benefits.

Tradeoff: This only works when the pattern is consistent. The moment you have exceptions (WeIrD_CaSiNg), you're back to CodingKeys for those specific types. And that's fine—the goal isn't zero CodingKeys, it's only writing them when they add information.

Problem #2: The Payload You Care About Is Nested/Wrapped

Many APIs wrap "the real thing" inside metadata:

{
  "status": "ok",
  "data": {
    "user": {
      "user_id": "123",
      "full_name": "Ada Lovelace"
    }
  }
}

You don't want every DTO to model the wrappers. You want a clean way to say:

"Decode UserDTO from data.user."

The path is hardcoded per endpoint—you know the structure, you just don't want to model every layer.

Key-Path Decoding: Decode What You Want, Where It Lives

Swift's Decodable doesn't include key-path decoding out of the box, but you can build it cleanly by:

  1. Passing the key path into the decoder via userInfo
  2. Having a small wrapper type that walks the containers

A Production-Ready NestedDecoder

Here's a wrapper you can reuse across projects:

The Core Types

/// Represents a path through nested JSON objects, e.g., "data.user.profile"
struct JSONPath {
    let segments: [String]
    
    init(_ dotSeparatedPath: String) {
        self.segments = dotSeparatedPath
            .split(separator: ".")
            .map(String.init)
    }
    
    var description: String { 
        segments.joined(separator: ".") 
    }
    
    var isEmpty: Bool { 
        segments.isEmpty 
    }
}

private extension CodingUserInfoKey {
    static let jsonPath = CodingUserInfoKey(rawValue: "jsonPath")!
}

The Nested Decoder

/// Decodes a value of type T from a nested location in JSON.
/// 
/// Instead of modeling wrapper objects, you specify the path:
/// ```
/// // JSON: { "data": { "user": { "name": "Ada" } } }
/// let user = try decoder.decode(User.self, from: json, at: "data.user")
/// ```
struct NestedDecoder<T: Decodable>: Decodable {
    let value: T

    init(from decoder: Decoder) throws {
        guard let path = decoder.userInfo[.jsonPath] as? JSONPath,
              !path.isEmpty else {
            self.value = try T(from: decoder)
            return
        }

        self.value = try Self.decode(T.self, from: decoder, following: path)
    }
    
    private static func decode(
        _ type: T.Type,
        from decoder: Decoder,
        following path: JSONPath
    ) throws -> T {
        var container = try decoder.container(keyedBy: DynamicKey.self)
        
        for (index, segment) in path.segments.enumerated() {
            let key = DynamicKey(segment)
            let isLastSegment = index == path.segments.count - 1
            
            if isLastSegment {
                return try container.decode(T.self, forKey: key)
            }
            
            do {
                container = try container.nestedContainer(keyedBy: DynamicKey.self, forKey: key)
            } catch {
                throw DecodingError.keyNotFound(key, DecodingError.Context(
                    codingPath: container.codingPath + [key],
                    debugDescription: "Path '\(path.description)' failed: no object at '\(segment)'"
                ))
            }
        }
        
        // The loop always returns on the last segment, so this is unreachable.
        // But Swift can't prove that, so we satisfy the compiler.
        fatalError("Loop must return on final segment")
    }
}

The Convenience API

extension JSONDecoder {
    /// Decodes a value from a nested path in the JSON.
    ///
    /// - Parameters:
    ///   - type: The type to decode.
    ///   - data: The JSON data.
    ///   - path: A dot-separated path to the nested value (e.g., "data.user").
    ///
    /// - Returns: The decoded value.
    ///
    /// Example:
    /// ```swift
    /// // JSON: { "response": { "users": [...] } }
    /// let users = try decoder.decode([User].self, from: data, at: "response.users")
    /// ```
    func decode<T: Decodable>(
        _ type: T.Type,
        from data: Data,
        at path: String
    ) throws -> T {
        let jsonPath = JSONPath(path)
        
        guard !jsonPath.isEmpty else {
            return try decode(T.self, from: data)
        }
        
        userInfo[.jsonPath] = jsonPath
        defer { userInfo.removeValue(forKey: .jsonPath) }
        
        return try decode(NestedDecoder<T>.self, from: data).value
    }
}

Now you can do:

let userDTO = try decoder.decode(UserDTO.self, from: data, at: "data.user")

Wrappers become a decode concern, not a modeling concern.


Problem #3: Wire Data Leaks Into Your Domain

This is where the DTO → Domain separation earns its keep.

DTOs Are Permissive

DTOs exist to accept whatever the wire sends, even if it's weird or incomplete. Their job is to not crash during decoding:

struct UserDTO: Decodable {
    let userID: String      // Could be empty
    let fullName: String    // Could be "   " (just whitespace)
    let email: String?      // Could be missing or garbage
    let accountType: String // Could be anything
}

Domain Models Are Strict

Domain models represent what your app actually needs to function correctly. They enforce invariants—rules that must always be true:

struct User: Equatable, Sendable {
    let id: String          // Must be non-empty
    let name: String        // Must be non-empty after trimming
    let email: String?      // Normalized, or nil
    let isPremium: Bool     // Derived from accountType
    
    /// Returns nil if the DTO contains invalid data.
    init?(dto: UserDTO) {
        // Reject empty IDs
        guard !dto.userID.isEmpty else { return nil }
        
        // Reject whitespace-only names
        let trimmedName = dto.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmedName.isEmpty else { return nil }
        
        self.id = dto.userID
        self.name = trimmedName
        self.email = dto.email?.trimmingCharacters(in: .whitespacesAndNewlines)
        self.isPremium = dto.accountType == "premium"
    }
}

The Gate

The failable initializer is the gate. Bad data gets rejected at the boundary, not three screens deep when something explodes.

This gives you:

  • Decoding that doesn't crash — The DTO accepts the malformed response; you decide what to do with it.
  • Domain models you can trust — If you have a User, you know it has a valid ID and name. No defensive checks scattered everywhere.
  • A single place to normalize — Trimming, lowercasing, enum conversions—all happen in the init?(dto:), not sprinkled across your codebase.
  • Stability when APIs change — The backend adds a field? DTO gets it, domain model ignores it. Backend renames something? Fix the DTO, domain model stays the same.

What Happens Without the Gate

If you decode directly into domain models, your core types are forced to be permissive:

// This is what happens when you skip DTOs
struct User: Decodable {
    let id: String?         // Optional because API might send empty
    let name: String?       // Optional because... who knows
    let email: String?
    let accountType: String?
    
    // Now every usage site has to check:
    // if let id = user.id, !id.isEmpty { ... }
}

You end up with optionals everywhere, or force-unwraps that crash, or validation logic scattered across view models and services. The boundary disappears, and API mess leaks into your whole app.


Failure Modes and Guardrails

1. Don't Hide API Weirdness Under One Global Strategy

If only 80% of keys follow a pattern, don't force the remaining 20% into hacks. Use CodingKeys for exceptions. Let correctness win.

// This type has a weird key that doesn't follow snake_case
struct LegacyDTO: Decodable {
    let normalField: String
    let weirdField: String
    
    enum CodingKeys: String, CodingKey {
        case normalField  // Handled by key strategy
        case weirdField = "WeIrD_FiElD"  // Exception
    }
}

2. Key-Path Decoding Should Fail Loudly

If you're decoding data.user and the backend moves it to payload.user, you want a clear failure—not silent nil or a crash deep in your app.

The NestedDecoder implementation above provides contextual errors:

Path 'data.user' failed: no object at 'data'

Add logging around decode errors and surface them during integration testing.

3. Test the Decoder Configuration Like Business Logic

final class DecodingTests: XCTestCase {
    
    func testSnakeToCamel() {
        XCTAssertEqual("user_id".snakeToCamel(), "userId")
        XCTAssertEqual("full_name".snakeToCamel(), "fullName")
        XCTAssertEqual("id".snakeToCamel(), "id")  // No underscores
        XCTAssertEqual("user__id".snakeToCamel(), "userId")  // Consecutive
    }
    
    func testNestedDecoding() throws {
        let json = """
        {"data": {"user": {"user_id": "123", "full_name": "Ada"}}}
        """.data(using: .utf8)!
        
        let decoder = JSONDecoder.snakeCaseConverting
        let dto = try decoder.decode(UserDTO.self, from: json, at: "data.user")
        
        XCTAssertEqual(dto.userID, "123")
        XCTAssertEqual(dto.fullName, "Ada")
    }
    
    func testNestedDecodingFailsWithContext() {
        let json = """
        {"payload": {"user": {"user_id": "123"}}}
        """.data(using: .utf8)!
        
        XCTAssertThrowsError(
            try JSONDecoder().decode(UserDTO.self, from: json, at: "data.user")
        ) { error in
            let message = String(describing: error)
            XCTAssertTrue(message.contains("data.user"))
        }
    }
    
    func testDomainValidationRejectsEmptyID() {
        let dto = UserDTO(userID: "", fullName: "Ada", email: nil, accountType: "free")
        XCTAssertNil(User(dto: dto))
    }
    
    func testDomainValidationRejectsWhitespaceName() {
        let dto = UserDTO(userID: "123", fullName: "   ", email: nil, accountType: "free")
        XCTAssertNil(User(dto: dto))
    }
    
    func testDomainNormalizesWhitespace() {
        let dto = UserDTO(userID: "123", fullName: "  Ada Lovelace  ", email: nil, accountType: "premium")
        let user = User(dto: dto)
        
        XCTAssertEqual(user?.name, "Ada Lovelace")
        XCTAssertEqual(user?.isPremium, true)
    }
}

When Not to Do This

Don't introduce this complexity if:

  • Your API is already well-structured and stable
  • Your models are few and small
  • Your team is early-stage and needs speed more than purity
  • You're building a prototype that won't live long

But if you're dealing with:

  • Multiple backends with inconsistent conventions
  • Evolving schemas that change without warning
  • Wrapper-heavy responses ({ status, data, meta, pagination })
  • A long-lived codebase that needs to survive API churn

This approach saves you from death by CodingKeys and wrapper structs.


Summary

  1. Skip the wrappers — Use key-path decoding to reach nested data without modeling every layer.
  2. Stop writing CodingKeys — Centralize naming convention transforms in a key strategy. Only write CodingKeyswhen they add actual information.
  3. Validate at the door — DTOs accept whatever the wire sends; domain models enforce invariants. The failable init?(dto:) is your gate. Bad data dies at the boundary, not deep in your app.
  4. Treat the decoding layer as infrastructure — Test it, constrain it, and keep it honest.

Further Reading

Read more