Domain Models vs API Models in Swift
If you've ever found yourself writing if let chains to safely unwrap API responses, or adding computed properties to Codable structs that really shouldn't know about your UI, you've felt the pain of mixing network concerns with business logic.
The solution is simple: keep your API models and domain models separate. Let the API models handle the messy reality of what the server sends you, and let the domain models represent the clean, validated data your app actually uses.
You can find the complete example project on GitHub.
The Problem with Single-Layer Models
Here's a pattern I see constantly:
struct Landmark: Codable {
let id: String
let name: String?
let location: String?
let description: String?
let imageName: String?
let isFeatured: Bool?
let category: String? // "mountains", "lakes", etc.
// Business logic creeping in...
var displayTitle: String {
guard let name = name, let location = location else {
return "Unknown Landmark"
}
return "\(name), \(location)"
}
var categoryType: CategoryType? {
guard let category = category else { return nil }
return CategoryType(rawValue: category)
}
}
This looks fine at first. But as your app grows, problems emerge:
The server changes something. Maybe imageName becomes image_url, or category switches from a string to a nested object. Now your "business logic" computed properties are tangled up with decoding concerns.
You need to validate data. What if name comes back as an empty string instead of null? What if the server sends a category your app doesn't recognize? Your views end up doing validation that should have happened earlier.
Testing becomes awkward. Want to test how your UI handles a landmark with no image? You need to construct a full Landmark with all the Codable ceremony, even for properties you don't care about.
The model knows too much. That displayTitle computed property? It's view logic. That categoryType conversion? It should happen once, at the boundary, not every time you access it.
Two Layers, Clear Boundaries
The fix is to separate concerns into two distinct layers:
API Models live at the network boundary. They match exactly what the server sends, handle all the Codable edge cases, and exist purely to get data into your app safely.
Domain Models represent your app's truth. They're validated, typed correctly, and contain the business logic your app needs. Views and services work with these.
Between them sits a thin mapping layer that transforms one into the other.
Server Response → API Model → Mapping → Domain Model → Views
Defining the Protocols
Start with simple marker protocols that establish the contract:
public protocol ApiModel: Codable, Hashable, Sendable, Equatable {}
public protocol DomainModel: Codable, Equatable, Hashable, Sendable {}
These look nearly identical, and that's intentional. The difference isn't in the conformances - it's in how you use them. API models are internal implementation details. Domain models are your app's public interface.
API Models: Match the Server
API models should be a direct translation of your API schema. If the server sends snake_case, configure your decoder to handle it. If a field is sometimes null and sometimes missing entirely, make it optional. Don't fight the API - just represent it accurately.
public struct LandmarkApiModel: ApiModel, Identifiable {
public let id: String
public let name: String?
public let location: String?
public let description: String?
public let imageName: String?
public let isFeatured: Bool?
public let category: CategoryApiModel?
// Server might send extra metadata we don't use
public let createdAt: Date?
public let updatedAt: Date?
}
For enums, always include an unknown case. APIs evolve. New categories get added. Your app shouldn't crash because the server sent a value you didn't anticipate.
public enum CategoryApiModel: String, ApiModel {
case mountains
case lakes
case bridges
case unknown
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(String.self)
self = CategoryApiModel(rawValue: rawValue) ?? .unknown
}
}
The custom decoder ensures any unrecognized category becomes .unknown rather than throwing a decoding error. Handle unknowns gracefully at the API layer.
Domain Models: Your App's Truth
Domain models represent validated, ready-to-use data. No optionals unless something is genuinely optional in your domain. No stringly-typed enums - use proper Swift enums.
public struct Landmark: DomainModel, Identifiable {
public let id: UUID
public let name: String
public let location: String
public let description: String
public let imageName: String
public let isFeatured: Bool
public let category: Category
// Business logic belongs here
public var displayTitle: String {
"\(name), \(location)"
}
}
public enum Category: String, DomainModel, CaseIterable {
case mountains = "Mountains"
case lakes = "Lakes"
case bridges = "Bridges"
public var systemImage: String {
switch self {
case .mountains: "mountain.2"
case .lakes: "water.waves"
case .bridges: "road.lanes"
}
}
}
Notice what changed:
name,location,descriptionare non-optional (we'll filter incomplete records)idis a properUUID(parsed once, not every access)categoryhas nounknowncase (we filter those out)isFeatureddefaults sensibly (no optional)
This is the data your views work with. Clean, validated, type-safe.
The Mapping Layer
The mapping lives in extensions on your API models. This keeps the conversion logic close to the source and makes it easy to find.
extension CategoryApiModel {
var domainModel: Category? {
switch self {
case .mountains: .mountains
case .lakes: .lakes
case .bridges: .bridges
case .unknown: nil // Filter unknowns
}
}
}
extension LandmarkApiModel {
var domainModel: Landmark? {
// Validate required fields
guard let name = name?.trimmingCharacters(in: .whitespaces),
!name.isEmpty,
let location = location,
let description = description,
let imageName = imageName,
let apiCategory = category,
let category = apiCategory.domainModel else {
return nil
}
return Landmark(
id: UUID(uuidString: id) ?? UUID(),
name: name,
location: location,
description: description,
imageName: imageName,
isFeatured: isFeatured ?? false,
category: category
)
}
}
Returning nil for invalid records is intentional. When you fetch a list of landmarks, some might have incomplete data. Rather than crashing or showing broken UI, filter them out:
let landmarks = apiResponse.items.compactMap(\.domainModel)
The compactMap filters out any records that failed validation. Your views never see bad data.
Request Types for Writes
When you need to send data back to the server for POST/PUT requests, don't reuse your API model. API models represent what the server returns. Instead, define a small request type with only the fields the server needs:
struct UpdateLandmarkRequest: Encodable, Sendable {
let name: String
let location: String
let description: String
let imageName: String
let isFeatured: Bool
let category: String
}
This keeps the separation clean. API models are decoded from JSON responses. Request types are encoded for outbound writes. They often have different fields - the server might return createdAt and updatedAt, but you'd never send those in an update.
Why This Means You Don't Need MVVM
Here's something that might be controversial: with clean domain models, you often don't need view models at all.
The traditional MVVM pitch is that view models transform data for display. But if your domain models are already validated, properly typed, and contain the business logic your views need, what's left for the view model to do?
Consider a Landmark domain model with a displayTitle computed property. In MVVM, you'd create a LandmarkViewModel that exposes displayTitle to the view. But the domain model already has displayTitle. The view model becomes a pass-through layer that adds complexity without adding value.
// MVVM approach - unnecessary indirection
class LandmarkViewModel: ObservableObject {
@Published var displayTitle: String = ""
func load(landmark: Landmark) {
displayTitle = landmark.displayTitle // Just passing through
}
}
// Direct approach - simpler
struct LandmarkDetailView: View {
let landmark: Landmark
var body: some View {
Text(landmark.displayTitle) // Domain model has what we need
}
}
The key insight is that domain models are your view models when they're designed correctly. They represent exactly the data your views need, in exactly the form your views need it.
What about loading states and async operations? That's where services come in. A service can expose an observable store that your views watch directly. The view doesn't need a view model intermediary - it observes the service's store and reacts to changes.
I'll cover services and dependency injection in the next post. For now, the important point is this: clean domain models eliminate most of the work view models traditionally do.
Testing Benefits
With separated models, testing becomes much cleaner:
// Testing domain logic - no Codable ceremony needed
func testDisplayTitle() {
let landmark = Landmark(
id: UUID(),
name: "Golden Gate Bridge",
location: "San Francisco, CA",
description: "An iconic bridge.",
imageName: "bridge",
isFeatured: true,
category: .bridges
)
XCTAssertEqual(landmark.displayTitle, "Golden Gate Bridge, San Francisco, CA")
}
// Testing mapping - verify edge cases
func testMappingFiltersMissingName() {
let apiModel = LandmarkApiModel(
id: "1",
name: nil, // Missing name!
location: "Unknown",
description: nil,
imageName: nil,
isFeatured: nil,
category: nil,
createdAt: nil,
updatedAt: nil
)
XCTAssertNil(apiModel.domainModel)
}
// Testing mapping - verify unknown categories filtered
func testMappingFiltersUnknownCategory() {
let apiModel = LandmarkApiModel(
id: "1",
name: "Test Landmark",
location: "Test Location",
description: "A test.",
imageName: "test",
isFeatured: false,
category: .unknown,
createdAt: nil,
updatedAt: nil
)
XCTAssertNil(apiModel.domainModel)
}
Each layer can be tested in isolation. API model tests verify you handle the server's quirks. Mapping tests verify your validation logic. Domain model tests verify your business rules.
Wrapping Up
Separating API models from domain models adds a small amount of code, but the benefits compound as your app grows:
Clear boundaries. Network concerns stay at the network layer. Business logic stays in the domain.
Robust validation. Bad data gets filtered at the mapping layer, not scattered throughout your views.
Easier testing. Test each layer in isolation with appropriate fixtures.
Safer refactoring. API changes only affect the API layer. Domain changes only affect the domain layer.
Better types. Domain models use proper Swift types - UUID instead of String, strongly-typed enums instead of stringly-typed values.
The pattern takes a bit of discipline to maintain, but once it's in place, you'll wonder how you ever lived without it. Your views become simpler, your tests become clearer, and your confidence in your data increases dramatically.
In the next post, I'll show how to wire these domain models into services that your views can consume directly - no view models required. We'll look at closure-based dependency injection, observable stores, and how SwiftUI views can react to service state changes without the ceremony of traditional MVVM.