# Clean Architecture in SwiftUI: Separating Domain, Data, and Presentation

Most SwiftUI apps start simple. A ViewModel fetches data from an API, maps it to a model, and drives a view. It works. But as the app grows, the ViewModel becomes a god object: it knows about URLs, JSON decoding, business rules, navigation, and UI state all at once.

When everything lives in one place, changing one thing breaks another. Testing requires mocking the entire world. And understanding any single piece of logic means reading through unrelated concerns.

Clean Architecture solves this by enforcing **a strict separation between three layers**: Domain, Data, and Presentation. Each layer has one job, depends only inward, and can be tested in complete isolation.

This article walks through a concrete feature — loading a list of feed items — and builds it layer by layer.

* * *

## The Layered Model

**Presentation ->** SwiftUI Views + ViewModels  
**Domain ->** Use Cases + Entities + Interfaces  
**Data ->** Network / Persistance / Mappers

**The Dependency Rule**: outer layers depend on inner layers. The **Domain** layer depends on nothing. The **Data** layer implements **Domain** interfaces. The **Presentation** layer calls **Domain** use cases.

* * *

## Step 1 — The Domain Layer

The Domain layer is the heart of your app. It contains:

*   **Entities**: pure data models with no framework imports.
    
*   **Use Case interfaces**: what the app *can do*, expressed as protocols.
    
*   **Repository interfaces**: what data the app *needs*, expressed as protocols.
    

```swift
// Domain/Models/FeedItem.swift
struct FeedItem: Equatable {
    let id: UUID
    let title: String
    let imageURL: URL
}
```

```swift
// Domain/Interfaces/FeedRepository.swift
protocol FeedRepository {
    func fetchFeed() async throws -> [FeedItem]
}
```

```swift
// Domain/UseCases/LoadFeedUseCase.swift
final class LoadFeedUseCase {
    private let repository: FeedRepository

    init(repository: FeedRepository) {
        self.repository = repository
    }

    func execute() async throws -> [FeedItem] {
        try await repository.fetchFeed()
    }
}
```

Notice: **zero imports of Foundation, UIKit, or SwiftUI** in these files. The Domain layer is a pure Swift module. It can run anywhere — on a server, in a CLI tool, in a test harness — without modification.

### Testing the Domain Layer

The use case is trivially testable:

```swift
final class LoadFeedUseCaseTests: XCTestCase {

    func test_execute_deliversItemsFromRepository() async throws {
        let expectedItems = [
            FeedItem(id: UUID(), title: "Article A", imageURL: anyURL()),
            FeedItem(id: UUID(), title: "Article B", imageURL: anyURL())
        ]
        let repository = FeedRepositoryStub(result: .success(expectedItems))
        let sut = LoadFeedUseCase(repository: repository)

        let items = try await sut.execute()

        XCTAssertEqual(items, expectedItems)
    }

    func test_execute_throwsOnRepositoryFailure() async {
        let repository = FeedRepositoryStub(result: .failure(anyError()))
        let sut = LoadFeedUseCase(repository: repository)

        do {
            _ = try await sut.execute()
            XCTFail("Expected error to be thrown")
        } catch {
            XCTAssertNotNil(error)
        }
    }

    // MARK: - Helpers

    private func anyURL() -> URL { URL(string: "https://any-url.com")! }
    private func anyError() -> Error { NSError(domain: "test", code: 0) }
}

private class FeedRepositoryStub: FeedRepository {
    private let result: Result<[FeedItem], Error>

    init(result: Result<[FeedItem], Error>) {
        self.result = result
    }

    func fetchFeed() async throws -> [FeedItem] {
        try result.get()
    }
}
```

No network. No UI. No SwiftUI. The Domain layer tests run in milliseconds.

* * *

## Step 2 — The Data Layer

The Data layer implements the Domain interfaces using real infrastructure: URLSession, CoreData, Keychain, etc. It also maps external representations (JSON) to Domain entities.

```swift
// Data/Remote/RemoteFeedItem.swift
private struct RemoteFeedItem: Decodable {
    let id: UUID
    let title: String
    let image_url: URL
}
```

```swift
// Data/Remote/RemoteFeedRepository.swift
final class RemoteFeedRepository: FeedRepository {
    private let url: URL
    private let session: URLSession

    init(url: URL, session: URLSession = .shared) {
        self.url = url
        self.session = session
    }

    func fetchFeed() async throws -> [FeedItem] {
        let (data, response) = try await session.data(from: url)

        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw RemoteFeedError.invalidResponse
        }

        let remoteItems = try JSONDecoder().decode([RemoteFeedItem].self, from: data)
        return remoteItems.map { FeedItem(id: $0.id, title: $0.title, imageURL: $0.image_url) }
    }

    enum RemoteFeedError: Error {
        case invalidResponse
    }
}
```

The mapping from `RemoteFeedItem` → `FeedItem` happens **inside the Data layer**. The Domain layer never sees the JSON structure. If the API changes its field names, only the mapper changes — the Domain and Presentation layers are untouched.

### Testing the Data Layer

Test with a controlled `URLSession` using `URLProtocol` stubs, or inject a mock session:

```swift
final class RemoteFeedRepositoryTests: XCTestCase {

    func test_fetchFeed_deliversMappedItemsOn200Response() async throws {
        let expectedItems = makeItems()
        let (sut, session) = makeSUT()
        session.stub(url: feedURL, data: makeJSON(expectedItems), statusCode: 200)

        let items = try await sut.fetchFeed()

        XCTAssertEqual(items, expectedItems.map(\.domain))
    }

    func test_fetchFeed_throwsOnNon200Response() async {
        let (sut, session) = makeSUT()
        session.stub(url: feedURL, data: Data(), statusCode: 400)

        do {
            _ = try await sut.fetchFeed()
            XCTFail("Expected error")
        } catch {
            XCTAssertNotNil(error)
        }
    }

    // MARK: - Helpers

    private let feedURL = URL(string: "https://api.example.com/feed")!

    private func makeSUT() -> (RemoteFeedRepository, URLSessionSpy) {
        let session = URLSessionSpy()
        let sut = RemoteFeedRepository(url: feedURL, session: session)
        return (sut, session)
    }
}
```

The Data layer tests verify **mapping and HTTP handling** — not business rules.

* * *

## Step 3 — The Presentation Layer

The Presentation layer contains ViewModels and SwiftUI views. It depends on the Domain layer through use cases — never directly on the Data layer.

```swift
// Presentation/FeedViewModel.swift
@MainActor
final class FeedViewModel: ObservableObject {
    @Published private(set) var items: [FeedItem] = []
    @Published private(set) var isLoading = false
    @Published private(set) var errorMessage: String?

    private let loadFeed: LoadFeedUseCase

    init(loadFeed: LoadFeedUseCase) {
        self.loadFeed = loadFeed
    }

    func onAppear() async {
        isLoading = true
        errorMessage = nil

        do {
            items = try await loadFeed.execute()
        } catch {
            errorMessage = "Could not load feed. Please try again."
        }

        isLoading = false
    }
}
```

```swift
// Presentation/FeedView.swift
struct FeedView: View {
    @StateObject private var viewModel: FeedViewModel

    init(viewModel: FeedViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel)
    }

    var body: some View {
        Group {
            if viewModel.isLoading {
                ProgressView()
            } else if let error = viewModel.errorMessage {
                Text(error).foregroundStyle(.secondary)
            } else {
                List(viewModel.items, id: \.id) { item in
                    Text(item.title)
                }
            }
        }
        .task { await viewModel.onAppear() }
    }
}
```

### Testing the Presentation Layer

The ViewModel is tested against a `LoadFeedUseCase` backed by a stub repository — no real network, no SwiftUI rendering:

```swift
final class FeedViewModelTests: XCTestCase {

    func test_onAppear_showsLoadingThenItems() async {
        let items = [FeedItem(id: UUID(), title: "Hello", imageURL: anyURL())]
        let useCase = LoadFeedUseCase(repository: FeedRepositoryStub(result: .success(items)))
        let sut = await FeedViewModel(loadFeed: useCase)

        await sut.onAppear()

        let receivedItems = await sut.items
        XCTAssertEqual(receivedItems, items)

        let isLoading = await sut.isLoading
        XCTAssertFalse(isLoading)
    }

    func test_onAppear_showsErrorMessageOnFailure() async {
        let useCase = LoadFeedUseCase(repository: FeedRepositoryStub(result: .failure(anyError())))
        let sut = await FeedViewModel(loadFeed: useCase)

        await sut.onAppear()

        let errorMessage = await sut.errorMessage
        XCTAssertNotNil(errorMessage)
    }

    private func anyURL() -> URL { URL(string: "https://any-url.com")! }
    private func anyError() -> Error { NSError(domain: "test", code: 0) }
}
```

* * *

## Step 4 — The Composition Root

All three layers meet at exactly one place: the Composition Root in your `@main` struct. This is the **only place** allowed to know about concrete types from all layers.

```swift
@main
struct FeedApp: App {
    var body: some Scene {
        WindowGroup {
            FeedView(
                viewModel: FeedViewModel(
                    loadFeed: LoadFeedUseCase(
                        repository: RemoteFeedRepository(
                            url: URL(string: "https://api.example.com/feed")!
                        )
                    )
                )
            )
        }
    }
}
```

Everything is wired here and nowhere else. Changing the data source (remote → local cache → Core Data) means changing **one line** in this file.

* * *

## The Full Picture

```plaintext
FeedApp (@main)
  └─ FeedView
       └─ FeedViewModel          [Presentation]
            └─ LoadFeedUseCase   [Domain]
                 └─ FeedRepository (protocol)
                      └─ RemoteFeedRepository  [Data]
                           └─ URLSession
```

Each arrow points inward. Each layer is independently testable. Each layer has a single, clear responsibility.

* * *

## What You Gain

| Concern | Without Clean Architecture | With Clean Architecture |
| --- | --- | --- |
| Business logic | Buried in ViewModels or views | Isolated in the Domain layer |
| Changing the API | Ripples across the app | Only the Data layer changes |
| Unit testing | Requires mocking everything | Each layer tested independently |
| Onboarding | Hard to know where to look | Clear, predictable structure |
| Reusing logic | Copy-paste between targets | Use cases are framework-agnostic |

* * *

## Summary

Clean Architecture is not about complexity — it's about **clear boundaries**. Three layers, one rule: dependencies point inward.

*   **Domain**: entities, use cases, and repository interfaces. No framework imports.
    
*   **Data**: implements the interfaces. Maps external data to domain entities.
    
*   **Presentation**: drives the UI using use cases. Never touches URLSession or CoreData directly.
    
*   **Composition Root**: wires everything together. Appears once, at the top.
    

Once this boundary discipline is in place, every other best practice — TDD, the Decorator pattern, feature flags, caching — slots in naturally without disrupting the existing structure.
