MVVM with Clean Architecture in iOS: A Practical Guide
I have shipped apps where the “architecture” was a ViewController that fetched JSON, decoded it, formatted dates, wrote to a local cache, and pushed the next screen — all in one 900-line file. It worked. It shipped. And then the second feature request arrived, and every change took three times longer than it should have, because touching anything meant understanding everything.
The fix wasn’t a cleverer ViewController. It was a deliberate layering of responsibilities. In this guide, I’ll show you how to combine MVVM with Clean Architecture in Swift — the Domain, Data, and Presentation layers, how they connect, how to test them, and — just as importantly — when this much ceremony is actually worth it.
This is a practical walkthrough. We’ll build a user list screen end to end, with complete, compilable code, and I’ll point out the pitfalls I’ve watched teams fall into along the way.
1. The Big Picture: Three Layers, One Rule
Clean Architecture, in its practical iOS form, is about one thing: dependency direction. High-level business rules must not depend on low-level details like networking or UI. Details depend on abstractions; abstractions never depend on details.
Concretely, that gives you three layers:
| Layer | Responsibility | Typical contents |
|---|---|---|
| Domain | Business rules, pure logic | Models, use cases, repository protocols |
| Data | Fetching and persisting | DTOs, repository implementations, API clients, mappers |
| Presentation | UI and screen state | Views, ViewModels, coordinators |
The rule: Presentation depends on Domain. Data depends on Domain. Domain depends on nothing. No UIKit, no SwiftUI, no Combine imports in the Domain layer — I’ve seen import SwiftUI sneak into a Domain model and quietly couple business logic to a framework. The Domain layer should compile on its own and be testable in milliseconds.
MVVM slots into this as the Presentation-layer pattern: the View observes the ViewModel, the ViewModel talks to use cases, and the View never touches a repository. That’s the whole deal.
2. The Domain Layer: Models, Protocols, Use Cases
The Domain layer is where your app’s meaning lives. It holds the models that business rules actually reason about, the contracts for getting data, and the use cases that orchestrate both.
Models as Value Types
Domain models are structs. Identity is usually a stable id, not object identity, which makes them perfect value types. As I covered in Struct vs Class in Swift: Making the Right Choice, value semantics give you predictable copies, Equatable for free, and none of the shared-mutation footguns.
struct User: Equatable {
let id: UUID
let name: String
let email: String
let avatarURL: URL?
}
Notice there’s no Codable conformance here. That’s deliberate — decoding is a Data-layer concern. The Domain model doesn’t care whether its bytes come from JSON, SQLite, or a plist.
Repository Protocols: Contracts, Not Implementations
The repository pattern is the boundary between Domain and Data. The Domain declares what it needs; the Data layer provides how.
protocol UserRepository: Sendable {
func fetchUsers() async throws -> [User]
func fetchUser(id: UUID) async throws -> User
}
This is protocol-oriented design doing its best work: the Domain depends on an abstraction, and the Data layer conforms. If you want to understand why protocols are the right tool for this boundary rather than inheritance, Why Swift Is Protocol-Oriented (And Why That Matters) goes deep on exactly that.
Swift 6 note: mark the protocol as protocol UserRepository: Sendable — domain models are structs, so they’re implicitly Sendable — to satisfy strict concurrency checking when the protocol existential crosses actor boundaries (like our @MainActor ViewModel calling a nonisolated use case). If you do, any final class mock with mutable stored state needs @unchecked Sendable to opt into the contract (as in Section 6).
Use Cases: Named Business Operations
A use case (sometimes called an interactor) is a single, named business operation: “Fetch users”, “Refresh user list”, “Follow user”. It takes dependencies via initializer injection and exposes one or two methods.
struct FetchUsersUseCase {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func execute() async throws -> [User] {
try await repository.fetchUsers()
}
}
I make use cases structs, not classes — they carry no state, so value semantics are free correctness. They’re also trivially testable: inject a fake repository, call execute(), assert on the result.
Is this one a bit thin? Honestly, yes — it’s a pass-through. I include it because real use cases grow validation, retry logic, cache-first strategies, or coordination between multiple repositories, and that’s where the layer pays for itself — and Section 8 covers when that complexity is actually worth it.
3. The Data Layer: DTOs, Mappers, and Repository Implementations
The Data layer owns the ugly stuff: JSON keys, network error handling, caching. It keeps that ugliness away from the rest of the app by translating everything into Domain types.
DTOs Mirror the Wire Format
A DTO (Data Transfer Object) matches the API response exactly — snake_case keys, optional fields, whatever the server sends. It’s Decodable and nothing else.
struct UserDTO: Decodable {
let id: UUID
let full_name: String
let email: String
let avatar_url: String?
func toDomain() -> User {
User(
id: id,
name: full_name,
email: email,
avatarURL: avatar_url.flatMap(URL.init(string:))
)
}
}
The mapping logic lives on the DTO in a single toDomain() method. That keeps the translation in one place — when the API adds a field, you change the DTO and the mapper, and the rest of the app never notices. I’ve seen teams skip DTOs and decode straight into Domain models, which works until the API renames a field and your business logic suddenly speaks snake_case.
(You could instead set JSONDecoder().keyDecodingStrategy = .convertFromSnakeCase and name DTO properties in camelCase — that works for trivial mappings. I still prefer the explicit DTO because toDomain() handles non-trivial transforms like avatar_url: String? → URL?, which a naming strategy can’t.)
The Concrete Repository
final class RemoteUserRepository: UserRepository {
private let apiClient: APIClient
init(apiClient: APIClient) {
self.apiClient = apiClient
}
func fetchUsers() async throws -> [User] {
let dtos: [UserDTO] = try await apiClient.get("/users")
return dtos.map { $0.toDomain() }
}
func fetchUser(id: UUID) async throws -> User {
let dto: UserDTO = try await apiClient.get("/users/\(id.uuidString)")
return dto.toDomain()
}
}
One repository per aggregate, one implementation per data source. If you later add a local cache, you create CachedUserRepository that wraps the remote one — the Domain and Presentation layers don’t change at all. That’s the payoff of the pattern.
For completeness, APIClient is a thin URLSession wrapper — the JSON decoding is standard and out of scope here, and transport errors surface as URLError:
struct APIClient {
private let session: URLSession
init(session: URLSession = .shared) { self.session = session }
func get<T: Decodable>(_ path: String) async throws -> T {
// Demo base URL — hard-coded for illustration, use configuration in production
let url = URL(string: "https://api.example.com")!.appendingPathComponent(path)
let (data, _) = try await session.data(from: url)
return try JSONDecoder().decode(T.self, from: data)
}
}
4. The Presentation Layer: ViewModel + View
Here’s where MVVM actually shows up. The ViewModel is a class — it needs reference semantics so @Published can broadcast observation changes — exposing @Published properties that the View observes. Under Swift 6 strict concurrency, that class choice is codified as @MainActor isolation: the ViewModel stays a class because it owns UI state, while everything it moves around becomes a Sendable struct — the split I detail in Struct vs Class in Swift 6: Concurrency-Safe Choices.
import Combine
@MainActor
final class UserListViewModel: ObservableObject {
@Published private(set) var users: [User] = []
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
private let fetchUsers: FetchUsersUseCase
init(fetchUsers: FetchUsersUseCase) {
self.fetchUsers = fetchUsers
}
func loadUsers() async {
isLoading = true
errorMessage = nil // Clear stale error state before every attempt
defer { isLoading = false }
do {
users = try await fetchUsers.execute()
} catch {
errorMessage = error.localizedDescription
}
}
}
Notes on the details, because they matter:
@MainActoron the whole class. All published mutations happen on the main actor, which removes an entire class of “published from background thread” bugs.@Publisheddoes publish on whatever thread you mutate from, so this annotation isn’t decoration — it’s a correctness guarantee.private(set)onusersandisLoading. The View reads state; only the ViewModel writes it. This single line prevents the “View mutates model state directly” anti-pattern that slowly turns MVVM back into spaghetti.defer { isLoading = false }ensures the spinner stops even when the call throws. Small, but I’ve debugged stuck spinners caused by exactly this omission.- Clear error state at the start of a request, not only on failure.
errorMessage = nilbefore the call means a failed load followed by a successful retry actually shows the list. Without it, the error screen lingers forever — a bug I’ve shipped and then spent an embarrassing amount of time debugging.
The View Stays Dumb
The SwiftUI view observes and renders. Nothing else.
struct UserListView: View {
@StateObject private var viewModel: UserListViewModel
init(viewModel: UserListViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
Group {
if viewModel.isLoading {
ProgressView("Loading users…")
} else if let error = viewModel.errorMessage {
VStack(spacing: 12) {
Image(systemName: "wifi.exclamationmark")
.font(.largeTitle)
Text("Couldn't load users")
.font(.headline)
Text(error)
.font(.subheadline)
.foregroundColor(.secondary)
Button("Try Again") {
Task { await viewModel.loadUsers() }
}
}
} else {
List(viewModel.users, id: \.id) { user in
UserRow(user: user) // Trivial row view — avatar, name, email
}
}
}
.task { await viewModel.loadUsers() }
.navigationTitle("Users")
}
}
If you’re building more elaborate layouts, my guide on Mastering the SwiftUI Layout System covers the view-side craft. The point here is that the view contains zero business logic — you can delete it, rewrite it in UIKit, or snapshot-test it, and the app’s behavior doesn’t change.
Note: I used ObservableObject + @Published because it’s the common denominator (iOS 15+ for .task, 14+ for @StateObject alone) and maps 1:1 to UIKit’s Combine bindings. On iOS 17+ with pure SwiftUI, the @Observable macro is the modern replacement, and the architecture doesn’t care — the layers behind the ViewModel stay identical.
5. Dependency Injection: The Composition Root
Every layer depends on abstractions, but something has to decide which concrete implementations to use. That’s the composition root — a single place, typically at app startup, that wires everything together. No Swinject or Resolver needed; a simple factory struct does the job beautifully.
struct AppContainer {
private let apiClient = APIClient()
var userRepository: UserRepository {
RemoteUserRepository(apiClient: apiClient)
}
func makeUserListViewModel() -> UserListViewModel {
UserListViewModel(fetchUsers: FetchUsersUseCase(repository: userRepository))
}
}
// In App/Scene setup:
let container = AppContainer()
let viewModel = container.makeUserListViewModel()
If the whole app is composed in one place, swapping RemoteUserRepository for CachedUserRepository is a one-line change. Dependency injection frameworks are conveniences, not requirements — constructor injection with a manual composition root is simpler, more debuggable, and has zero magic. For a small app, that’s the right call.
6. Testing the ViewModel with a Mocked Repository
This is the moment all the abstraction pays off. Because the ViewModel depends on FetchUsersUseCase — which depends on the UserRepository protocol — we can build a fake repository and unit test the entire screen’s logic with no network, no simulator, no UI.
final class MockUserRepository: UserRepository, @unchecked Sendable {
var result: Result<[User], Error> = .success([])
func fetchUsers() async throws -> [User] {
try result.get()
}
func fetchUser(id: UUID) async throws -> User {
User(id: id, name: "Mock", email: "mock@example.com", avatarURL: nil)
}
}
@MainActor
final class UserListViewModelTests: XCTestCase {
func testLoadUsersOnSuccess() async {
let repository = MockUserRepository()
let ada = User(id: UUID(), name: "Ada Lovelace",
email: "ada@example.com", avatarURL: nil)
repository.result = .success([ada])
let viewModel = UserListViewModel(
fetchUsers: FetchUsersUseCase(repository: repository)
)
await viewModel.loadUsers()
XCTAssertEqual(viewModel.users, [ada])
XCTAssertFalse(viewModel.isLoading)
XCTAssertNil(viewModel.errorMessage)
}
func testLoadUsersOnFailure() async {
let repository = MockUserRepository()
repository.result = .failure(URLError(.notConnectedToInternet))
let viewModel = UserListViewModel(
fetchUsers: FetchUsersUseCase(repository: repository)
)
await viewModel.loadUsers()
XCTAssertTrue(viewModel.users.isEmpty)
XCTAssertFalse(viewModel.isLoading)
XCTAssertNotNil(viewModel.errorMessage)
}
}
These tests run in milliseconds, they’re deterministic, and they document the screen’s behavior better than any comment. When a new developer asks “what happens if the network fails?”, the answer is a test named testLoadUsersOnFailure. That’s the real ROI of this architecture: the business logic is testable without the UI existing at all.
7. Common Pitfalls (and How to Avoid Them)
I’ve seen every one of these in production. Let me save you the therapy bills.
The Massive ViewModel
The ViewModel is where logic goes to die — formatting, navigation, analytics, validation, debouncing, keyboard handling. Before long it’s a 600-line ObservableObject and you’ve recreated the Massive ViewController with extra steps.
The fix: the ViewModel should coordinate, not implement. Formatting belongs in dedicated formatters or the view. Navigation belongs in coordinators. Complex orchestration belongs in use cases. If your ViewModel is doing more than calling two or three use cases and exposing their results, split it.
Over-Engineering: Layers for Layers’ Sake
The flip side is real too. I’ve joined projects where a “hello world” screen had five protocols, a factory, and a decorator. Ask yourself: am I adding this layer because a test requires it, or because a blog post said so?
A good heuristic: the rule of three. Don’t introduce a use case, a mapper, or a protocol until the third time you need it, or until a test genuinely can’t be written without it. The architecture I’ve shown you is the ceiling, not the floor. Start with the repository protocol, add use cases when they grow real logic, add DTOs when your models outgrow the wire format.
Where State Should Live
This is the question I get asked most: should this be in the ViewModel, the repository, or a singleton?
The rule I use: state that only this screen cares about lives in this screen’s ViewModel (loading flags, selection, draft form input). State that multiple screens share — the current user, a shopping cart — lives in the Domain layer behind a protocol, injected where needed. Global mutable state in a UserDefaults-backed singleton that everyone imports is how you get bugs that only reproduce after the third login. If you find yourself writing static let shared, ask who owns that state and who’s allowed to change it. For a broader tour of which classic patterns still earn their keep in Swift, Design Patterns in Swift: A Practical Guide is worth a read.
Retain Cycles with Combine
This one bites in UIKit apps. A ViewModel that owns a Set<AnyCancellable> and a view that owns the ViewModel is fine — but if your ViewModel captures itself strongly in a sink closure, you’ve built a cycle the ARC can’t break. I covered the mechanics in iOS Memory Management: From ARC to Retain Cycles; the short version is [weak self] in any sink that outlives the call site, and prefer async/await (as in this guide) so you rarely need sink at all.
Leaking UI Types into Domain
import SwiftUI in a Domain model is the smell I refuse to debug twice. Domain files should compile on Linux. If a use case returns a Color or references UIScreen, your layers have fused — step back and move that dependency to Presentation.
8. When Is This Worth It? (Honest Answers)
Let’s be honest about the trade-off, because Clean Architecture has a real cost: more files, more indirection, more ceremony. It pays off when:
- Business logic is complex enough to need its own tests — validation pipelines, pricing rules, sync logic. If your app is mostly forms and lists, the payoff shrinks.
- The codebase will outlive its first release. The architecture earns its keep in year two, when new developers join and features compound.
- Data sources are expected to change — API → local cache → backend sync. The repository boundary makes that a one-file change.
- You’re on a team. Multiple people touching the same screens need clear boundaries to avoid stepping on each other.
For a small app with a short lifespan, vanilla MVVM with a single repository layer is the right architecture. You still get testable ViewModels, you just skip the use cases and DTOs until they earn their place. For tiny screens, honestly, even MVC is fine. The goal isn’t to maximize patterns — it’s to keep the cost of change low, and the cost of change is what this whole exercise optimizes.
Key Takeaways
- Clean Architecture is three layers — Domain (pure business logic), Data (network/persistence), Presentation (MVVM) — with dependencies pointing inward and Domain importing nothing.
- Repositories are protocols in Domain, implementations in Data; DTOs translate wire format to Domain models via a single mapper method.
- ViewModels are
@MainActorclasses exposingprivate(set) @Publishedstate, calling use cases and never touching repositories directly. - The composition root — a simple factory — wires concrete implementations behind protocol abstractions; no DI framework required.
- The architecture’s payoff is testability: mock the repository protocol and unit test the entire screen’s behavior in milliseconds.
- Avoid the pitfalls: keep ViewModels thin, apply the rule of three before adding layers, keep shared state behind protocols, and never import UI frameworks into Domain.
- Match the ceremony to the job. Small app, short lifespan: vanilla MVVM. Complex, long-lived, team-built: the full layering.