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.
// Domain/Models/FeedItem.swift
struct FeedItem: Equatable {
let id: UUID
let title: String
let imageURL: URL
}
// Domain/Interfaces/FeedRepository.swift
protocol FeedRepository {
func fetchFeed() async throws -> [FeedItem]
}
// 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:
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.
// Data/Remote/RemoteFeedItem.swift
private struct RemoteFeedItem: Decodable {
let id: UUID
let title: String
let image_url: URL
}
// 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:
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.
// 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
}
}
// 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:
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.
@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
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.





