Why Separating DTOs from Domain Models Matters in Swift App Architecture

Virginia PujolsVirginia Pujols
4 min read

Let me be clear:
I hate having to write both DTOs and domain models. It feels like double work. It clutters the codebase. It adds yet another layer to think about when you're just trying to get an API to show some data on screen.

And yet... I do it because when I didn’t, it blew up in my face later.

This post breaks down what DTOs and domain models are, why separating them is important, and provides a few real-world examples of how skipping that step can backfire.


Let’s Talk About the Basics

DTO (Data Transfer Object)

A DTO is a data container used to exchange information across boundaries — usually between a remote API and your app.

Think: Backend response → DTO → App

It represents exactly what the backend gives you. No assumptions, no transformations, just raw data.

struct UserDTO: Codable {
    let id: Int
    let first_name: String
    let last_name: String
    let email_address: String
    let created_at: String
}

Domain Model

The domain model reflects the structure and language of your app’s business logic. It’s what the UI, business rules, and features should operate on.

struct User {
    let id: Int
    let fullName: String
    let email: String
    let joinDate: Date
}

Notice how this version:

  • Combines first_name + last_name

  • Converts created_at (a string) into a Date

  • Renames fields for clarity and Swift naming conventions

Why the Separation Matters

Reason #1: Domain Logic Shouldn’t Care About Backend Garbage

Sometimes APIs give you fields that don’t belong in your app logic at all:

  • Debug-only fields

  • Internal backend IDs

  • Raw numeric codes instead of enums

  • Deeply nested structures you don’t actually need

Let’s say you receive a JSON like this:

{
  "meta": {
    "request_id": "abc123",
    "timestamp": "2024-01-01T00:00:00Z"
  },
  "data": {
    "user": {
      "id": 42,
      "profile": {
        "first_name": "John",
        "last_name": "Appleseed",
        "email": "john@example.com"
      },
      "roles": [
        {
          "id": 1,
          "label": "admin",
          "permissions": ["READ", "WRITE", "DELETE"]
        }
      ],
      "audit": {
        "created_by": "system",
        "created_at": "2023-05-01T10:00:00Z",
        "updated_at": "2023-06-01T15:30:00Z"
      }
    }
  }
}

What should you do?

First, define a clean domain model:

struct User {
    let id: Int
    let fullName: String
    let email: String
    let role: String
}

Then, write a DTO and map what you need:

struct UserResponseDTO: Codable {
    let data: DataNode
    struct DataNode: Codable {
        let user: UserNode
    }
    ...
}

extension UserResponseDTO {
    func toDomain() -> User {
        let user = data.user
        return User(
            id: user.id,
            fullName: "\(user.profile.first_name) \(user.profile.last_name)",
            email: user.profile.email,
            role: user.roles.first?.label ?? "unknown"
        )
    }
}

Now your domain model is clean, shallow, and safe — and your app no longer lives at the mercy of backend nesting changes.

Reason #2: Backend Contracts Change

Ever had a backend team rename a field because they “standardized the naming convention”? One day it’s email_address. Next day? email. No warning. No version bump. Just… surprise!

If you were piping that directly into your SwiftUI view, you’ve just earned yourself a lovely nil crash and a bunch of angry users.

But if that chaos hit a DTO, you’d be fine (only one file change).

enum CodingKeys: String, CodingKey {
    case emailAddress = "email"
}

The UI wouldn’t even notice the explosion behind the scenes. That’s the point. DTOs act like airbags — ugly, but essential when things go wrong.

Reason #3: Different Sources, One Model

Suppose your app gets User data from:

  • An API

  • A local cache

  • A CoreData store

All of them return different formats, and some even lack fields.

Solution: Use DTOs per source and map them into a consistent domain model.

extension UserDTOFromAPI {
    func toDomain() -> User {
        User(
            id: id, 
            fullName: "\(first_name) \(last_name)", 
            email: email_address,
            joinDate: created_at.toDate()
        )
    }
}

extension UserDTOFromLocalCache {
    func toDomain() -> User {
        User(
            id: userId, 
            fullName: name, 
            email: email, 
            joinDate: Date(timeIntervalSince1970: timestamp)
        )
    }
}

Final Thoughts

Yes, writing both DTOs and domain models feels like extra work most of the time, but the truth is: if you skipped them, you’ll pay for it later — in broken UIs, fragile tests, and spaghetti logic tied directly to whatever the backend felt like returning that week. So now I treat them like seatbelts. You don’t put them on because you’re expecting a crash; you put them on because you know what happens when you don’t.

If you want your Swift app to scale, survive backend changes, and stay testable and sane, separate your DTOs and your domain logic.
Future you will thank you.

0
Subscribe to my newsletter

Read articles from Virginia Pujols directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Virginia Pujols
Virginia Pujols