Why Separating DTOs from Domain Models Matters in Swift App Architecture


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 aDate
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.
Subscribe to my newsletter
Read articles from Virginia Pujols directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
