Clean Architecture Setup for iOS App
When iOS app codebase grows to 50–70 screens, ViewController answers for both network requests and data transformation and navigation. Tests have nowhere to go — dependencies hardcoded. New developer opens ProfileViewController.swift at 1200 lines and closes laptop.
Clean Architecture solves this through separation into concentric layers with strict dependency direction: inner layers know nothing about outer ones.
How Clean Architecture works in practice in iOS project
In Bob Martin's classical interpretation three rings: Entities → Use Cases → Interface Adapters. In iOS this maps as follows.
Domain layer — core. Here Entity models: clean Swift structs without Foundation import, only business data. Nearby — UseCase protocols and implementations. For example, FetchUserProfileUseCase takes UserRepository via DI and returns AnyPublisher<UserProfile, DomainError>. No URLSession, no CoreData. This layer compiles and tests in isolation.
protocol UserRepository {
func fetchProfile(id: String) -> AnyPublisher<UserProfile, DomainError>
}
final class FetchUserProfileUseCase {
private let repository: UserRepository
init(repository: UserRepository) { self.repository = repository }
func execute(id: String) -> AnyPublisher<UserProfile, DomainError> {
repository.fetchProfile(id: id)
}
}
Data layer — repository implementations. UserRepositoryImpl works with URLSession or Alamofire, maps DTO → domain model, handles network errors. CoreDataUserCache implements same protocol for local cache. Data source choice — in UserRepositoryImpl via strategy or in DI container.
Presentation layer — here ViewModel/Presenter lives. With SwiftUI convenient ObservableObject-ViewModel: calls UseCase, transforms result into @Published state and publishes it. ViewController or SwiftUI View handles exclusively rendering.
Navigation: Coordinator or Router
Typical problem — ViewController creates next ViewController and does push. This violates Clean Architecture: presentation layer knows concrete types of other screens. Solution — Coordinator:
protocol ProfileCoordinator: AnyObject {
func showEditProfile(user: UserProfile)
func showOrders(userId: String)
}
ViewModel holds weak reference to ProfileCoordinator. Concrete ProfileCoordinatorImpl knows UINavigationController and next screens. ViewModel — no.
DI: pure injection without Service Locator
Service Locator (global DIContainer.shared.resolve()) — anti-pattern: hides dependencies and breaks tests. Use initializer injection in chain: SceneDelegate creates AppCoordinator, that — concrete repositories and UseCases, passes to ViewModel via init. Can integrate Swinject or Needle, but for most projects manual assembly in CompositionRoot sufficient.
Testability — main win
Domain layer tested via XCTest without UIKit dependency: create MockUserRepository, put in UseCase, check logic. No XCTestExpectation for network, no URLSession mocks.
final class FetchUserProfileUseCaseTests: XCTestCase {
func test_execute_returnsProfile() {
let mock = MockUserRepository(result: .success(.stub()))
let sut = FetchUserProfileUseCase(repository: mock)
var received: UserProfile?
_ = sut.execute(id: "123").sink(
receiveCompletion: { _ in },
receiveValue: { received = $0 }
)
XCTAssertEqual(received?.id, "123")
}
}
Domain layer test build time — seconds, not minutes. This changes team development culture.
Typical implementation mistakes
Too thin UseCases. GetUsernameUseCase that does return user.name — meaningless layer. UseCase justified when encapsulating non-trivial logic or orchestrating multiple repositories.
Domain models with Codable. Adding Codable to domain Entity means Data layer leak inside. DTO — in Data layer, mapping — there too.
ViewModel knows concrete repository. If ViewModel has let repo = UserRepositoryImpl(...) — no DI. Only protocol + initializer injection.
What's included in setup
Audit current architecture (if project exists): determine what goes to Domain, what stays in Presentation, which dependencies to invert.
Create base module structure: Domain, Data, Presentation — separate Swift Packages or targets in one Xcode project. Set up target dependencies: Data depends on Domain, Presentation depends on Domain, not Data.
Implement CompositionRoot / DI container. Set up first 2–3 feature modules as example for team.
Write base domain layer tests as example.
Timelines
Architecture setup from scratch on new project (structure + DI + first module): 3–5 days. Refactoring existing project migrating 10–15 modules: 2–4 weeks depending on volume. Cost calculated after analyzing current code and architecture.







