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_name, fullName, FullName). 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
CodingKeysboilerplate 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—APIResponse, DataWrapper, 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 toCodingKeysfor those specific types. And that's fine—the goal isn't zeroCodingKeys, 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:
"DecodeUserDTOfromdata.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:
- Passing the key path into the decoder via
userInfo - 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
- Skip the wrappers — Use key-path decoding to reach nested data without modeling every layer.
- Stop writing CodingKeys — Centralize naming convention transforms in a key strategy. Only write
CodingKeyswhen they add actual information. - 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. - Treat the decoding layer as infrastructure — Test it, constrain it, and keep it honest.
Further Reading
- Implementing custom key strategy for coding types — Richard Turton, MartianCraft
- Going deep with Decodable — Richard Turton, MartianCraft