# Clean Architecture in iOS Projects with Swift
Table of Contents
Clean Architecture has become a cornerstone of modern software development, promoting separation of concerns and maintainable code. In this article, I’ll show you how to implement Clean Architecture in iOS projects using pure Swift features, avoiding external framework dependencies while maintaining flexibility and testability.
💡 Important note: Architecture isn’t religion. There are many valid approaches to organizing code: MVVM, VIPER, The Composable Architecture, MVC, each with their own strengths. The best architecture is the one your team understands and applies consistently. Clean Architecture with MVVM has worked well for many small to medium iOS projects because it’s pragmatic and approachable. But remember: good architecture should enable your team, not showcase academic credentials.
What is Clean Architecture?Link to heading
Clean Architecture, popularized by Uncle Bob (Robert C. Martin), organizes code into layers with clear dependency rules. The core principle is that dependencies should only point inward, toward the business logic, never outward toward implementation details.
The typical layers are:
- 📱 Presentation Layer: UI components, ViewModels
- 🏛️ Domain Layer: Business logic, Use Cases, Entities
- 💾 Data Layer: Repositories, Data Sources, Network clients
The Problem with Framework DependenciesLink to heading
Many iOS developers reach for external frameworks like Swinject, Resolver, or The Composable Architecture. While these are excellent tools, they come with trade offs:
- 🔧 Maintenance burden: Framework updates and breaking changes
- 📚 Learning curve: Team members must learn framework specific patterns
- 🔒 Vendor lock in: Architecture becomes tightly coupled to the framework
- ⚙️ Over engineering: Simple problems get complex solutions
Frameworks like The Composable Architecture promise to solve state management, composition, and testing - but at the cost of learning an entirely new ecosystem and creating deep dependency on external code. As Uncle Bob warns: frameworks want to be used and often become an end in themselves, pulling your architecture into their world rather than serving your business needs.
Uncle Bob’s advice resonates here: “Don’t build your architecture on top of frameworks.”
MVVM and Use Cases ArchitectureLink to heading
Instead of complex frameworks, let’s use a simple MVVM and Use Cases pattern that leverages Swift’s native features. This architecture organizes code into three distinct layers:
- 📱 UI Layer: Views and ViewModels handle presentation and user interaction
- 🏛️ Domain Layer: Business logic, Use Cases, and repository protocols define the core functionality
- 💾 Data Layer: Repository implementations manage data access from databases and external APIs
Each layer has a specific purpose and depends only on inner layers, never on outer ones. This creates a stable foundation where business logic remains independent of frameworks and external dependencies.
Here’s how you can organize your project structure:
YourApp/├── UI/│ ├── UserList/│ │ ├── UserListView.swift│ │ └── UserListViewModel.swift│ └── UserDetail/│ ├── UserDetailView.swift│ └── UserDetailViewModel.swift├── Domain/│ ├── Models/│ │ └── User.swift│ ├── Protocols/│ │ ├── PUserRepository.swift│ │ └── PNetworkClient.swift│ ├── UseCases/│ │ ├── PFetchUsersUseCase.swift│ │ └── FetchUsersUseCase.swift│ └── Errors/│ └── UserError.swift└── Data/ ├── Repository/ │ └── UserRepository.swift ├── API/ │ ├── NetworkClient.swift │ └── UserAPI.swift ├── CoreData/ │ ├── CoreDataStack.swift │ └── UserEntity.swift └── Mappers/ └── UserMapper.swift
Note: The “P” prefix for protocols (e.g.,
PUserRepository
) is a naming convention to distinguish protocols from their implementations. This is a matter of personal preference - you could also use suffixes likeUserRepositoryProtocol
or no prefix at all.
Layer Separation in PracticeLink to heading
Let’s examine how these layers work together in practice. The following diagrams illustrate the interactions between layers:
UI ↔ Domain InteractionLink to heading
The presentation layer communicates with business logic through clean interfaces. ViewModels call Use Cases and work with domain models, keeping UI concerns separate from business rules.
Domain ↔ Data InteractionLink to heading
The domain layer defines abstractions through protocols, while the data layer implements concrete access to external systems. Notice how UserRepository: PUserRepository
shows the Swift syntax for protocol conformance - the domain defines the interface, the data layer provides the implementation. Raw data from databases and APIs gets transformed into clean domain models.
Notice the different data types flowing through the system:
- CoreData returns
UserEntity
(database specific format) - API Client returns
UserDTO
(network transfer format) - Repository converts both to
User
(clean domain model)
This transformation is crucial to Clean Architecture.
As Uncle Bob states: “The entities of the enterprise business rules layer are the business objects of the application. They encapsulate the most general and high-level rules.”
Why This Matters:
- 🔓 Framework Independence: Domain logic never sees database or network formats
- 🏛️ Stable Abstractions: The
User
model represents business meaning, not storage details - 🧪 Testability: Tests use simple domain objects, not complex external formats
This boundary crossing enables the core benefit of Clean Architecture: business logic that’s independent of external concerns.
Layer Implementation ExamplesLink to heading
Now let’s see how each layer maintains its responsibilities:
Domain Layer (Business Rules)Link to heading
The domain layer contains your core business entities and rules, independent of any external frameworks or UI concerns.
// Domain Model: User.swift in domain/modelsstruct User { let id: String let name: String let email: String let isActive: Bool}
// Use Cases: FetchUsersUseCase.swift in domain/usecasesprotocol PFetchUsersUseCase { func execute() async throws -> [User]}
class FetchUsersUseCase: PFetchUsersUseCase { private let userRepository: PUserRepository
init(userRepository: PUserRepository) { self.userRepository = userRepository }
func execute() async throws -> [User] { let users = try await userRepository.fetchUsers() return users.filter { $0.isActive } }}
Presentation Layer (UI Logic)Link to heading
The presentation layer manages UI state and coordinates user interactions with the business logic through use cases.
// ViewModels handle presentation logic@Observableclass UserListViewModel { var users: [User] = [] var isLoading = false var errorMessage: String?
private let fetchUsersUseCase: PFetchUsersUseCase private let validateUserUseCase: PValidateUserUseCase
init(factory: AppFactory = AppFactory.shared) { self.fetchUsersUseCase = factory.fetchUsersUseCase self.validateUserUseCase = factory.validateUserUseCase }
func loadUsers() async { do { let fetchedUsers = try await fetchUsersUseCase.execute() users = fetchedUsers } catch { errorMessage = error.localizedDescription } }}
Data Layer (External Concerns)Link to heading
The data layer handles external dependencies like network calls, databases, and file systems while implementing the repository interfaces defined in the domain layer.
// Repository implementationclass UserRepository: PUserRepository { private let networkClient: PNetworkClient
init(networkClient: PNetworkClient) { self.networkClient = networkClient }
func fetchUsers() async throws -> [User] { let response: UserResponse = try await networkClient.get("/users") return response.users.map { dto in User( id: dto.id, name: dto.name, email: dto.email, isActive: dto.status == "active" ) } }}
// Network protocolprotocol PNetworkClient { func get<T: Codable>(_ endpoint: String) async throws -> T}
// Simple network clientclass URLSessionNetworkClient: PNetworkClient { func get<T: Codable>(_ endpoint: String) async throws -> T { let url = URL(string: "https://api.example.com\(endpoint)")! let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode(T.self, from: data) }}
Protocol Based Dependency InjectionLink to heading
Swift’s protocol system provides a lightweight alternative to heavy dependency injection frameworks. A simple factory pattern handles object creation and wiring without external dependencies.
For a detailed guide on implementing dependency injection patterns in Swift, see our Simple Dependency Injection in Swift article.
// Simple factory for dependency creationclass AppFactory { static let shared = AppFactory()
private lazy var networkClient: PNetworkClient = { URLSessionNetworkClient() }()
private lazy var userRepository: PUserRepository = { UserRepository(networkClient: networkClient) }()
lazy var fetchUsersUseCase: PFetchUsersUseCase = { DefaultFetchUsersUseCase(userRepository: userRepository) }()
lazy var validateUserUseCase: PValidateUserUseCase = { ValidateUserUseCase() }()}
SwiftUI IntegrationLink to heading
Modern SwiftUI works naturally with Clean Architecture. The @Observable macro and dependency injection through initializers create clean, testable views:
struct UserListView: View { @State private var viewModel = UserListViewModel()
var body: some View { NavigationStack { if viewModel.isLoading { ProgressView("Loading users...") } else { List(viewModel.users, id: \.id) { user in Text(user.name) } } } .task { await viewModel.loadUsers() } }}
Alternative: Skip the ViewModelLink to heading
Not everyone likes ViewModels - and that’s perfectly fine. This architecture is flexible enough to work without them. You can call Use Cases directly from your SwiftUI Views, following the Model-View (MV) pattern instead of MVVM.
struct UserListView: View { @State private var users: [User] = [] @State private var isLoading = false
// Direct Use Case access private let fetchUsersUseCase: PFetchUsersUseCase
init(factory: AppFactory = AppFactory.shared) { self.fetchUsersUseCase = factory.fetchUsersUseCase }
var body: some View { NavigationStack { Group { if isLoading { ProgressView("Loading users...") } else { List(users, id: \.id) { user in Text(user.name) } } } .navigationTitle("Users") } .task { await loadUsers() } }
private func loadUsers() async { isLoading = true do { users = try await fetchUsersUseCase.execute() } catch { // Handle error } isLoading = false }}
This approach eliminates the ViewModel layer entirely while keeping your business logic clean and testable. For a detailed exploration of the Model-View pattern in SwiftUI, stay tuned for our upcoming article on MV architecture.
Testing the ArchitectureLink to heading
Protocols enable easy mocking and isolated unit testing. Each layer can be tested independently with simple mock implementations.
Testable Views and ViewModelsLink to heading
To make your Views and ViewModels easily testable, inject the factory through initializers with default parameters:
// Testable ViewModel@Observableclass UserListViewModel { let fetchUsersUseCase: PFetchUsersUseCase let validateUserUseCase: PValidateUserUseCase
init(factory: AppFactory = AppFactory.shared) { self.fetchUsersUseCase = factory.fetchUsersUseCase self.validateUserUseCase = factory.validateUserUseCase }}
This pattern allows you to use the default factory in production while injecting mock factories for testing:
// Production usagelet view = UserListView() // Uses AppFactory.shared
// Testing usagelet view = UserListView(factory: MockAppFactory())
Unit Testing with MocksLink to heading
// Simple mock for testingclass MockUserRepository: PUserRepository { var mockUsers: [User] = []
func fetchUsers() async throws -> [User] { return mockUsers }}
// Unit test exampleclass UserListViewModelTests: XCTestCase { func testLoadUsers() async { // Given let mockRepo = MockUserRepository() mockRepo.mockUsers = [ User(id: "1", name: "John", email: "john@test.com", isActive: true) ]
let useCase = DefaultFetchUsersUseCase(userRepository: mockRepo) let viewModel = UserListViewModel( fetchUsersUseCase: useCase, validateUserUseCase: ValidateUserUseCase() )
// When await viewModel.loadUsers()
// Then XCTAssertEqual(viewModel.users.count, 1) XCTAssertFalse(viewModel.isLoading) }}
Benefits and Trade offsLink to heading
This architecture approach provides significant benefits while requiring some upfront investment.
AdvantagesLink to heading
- ✅ Zero external dependencies: Uses only Swift standard library
- 🧪 Highly testable: Easy mocking through protocols
- 🛠️ Maintainable: Clear separation of concerns
- 🔄 Flexible: Easy to modify or extend
- 🔓 Framework independence: Business logic survives technology changes
Trade offsLink to heading
- ⚡ Initial setup: More boilerplate code upfront
- 🔧 Manual wiring: No automatic dependency resolution
- 📖 Team education: Requires understanding of architecture principles
- 💪 Discipline required: Team must maintain layer boundaries consistently
ConclusionLink to heading
Clean Architecture in Swift doesn’t require external frameworks. By leveraging Swift’s protocol system, factory patterns, and clear layer separation, we can build maintainable, testable applications without additional dependencies.
Key principles to remember:
- ⬅️ Dependencies point inward toward business logic
- 🎭 Use protocols for abstraction and testability
- 🏭 Factory patterns provide simple dependency injection
- 🔬 Keep business logic pure and framework agnostic
- 🧪 Test each layer independently
Final ThoughtsLink to heading
This is my personal take on the topic:
Architecture isn’t about proving how many design patterns you know or showcasing academic knowledge. It’s about creating code that your team can understand, maintain, and evolve over time. Clean Architecture with MVVM is just one approach - and it’s worked well for many small to medium iOS projects because it strikes a balance between structure and pragmatism.
Other architectures like VIPER, The Composable Architecture, or even well structured MVC can be equally valid choices depending on your team’s experience, project complexity, and requirements. The key is consistency and team understanding.
Most importantly: Writing clean code matters more than perfect architecture. Clear naming, small functions, and good separation of concerns will take you further than any architectural framework. Choose what works for your team, apply it consistently, and remember that architecture should become invisible - enabling your team to focus on building great features rather than fighting the codebase.
As Vincent Garrigues shared in his UIKonf 2018 talk about SoundCloud’s technical debt, even successful apps (SoundCloud is top 20 in the App Store) make architectural mistakes. This reinforces that good architecture should enable your team to be productive, not demonstrate how sophisticated your technical choices are.
Your future self (and your team) will thank you for choosing pragmatism over perfection.
Component GlossaryLink to heading
Component | Responsibility |
---|---|
View | UI elements, presentation, user interaction |
ViewModel | Bridge between View & Domain, state management |
Use Case | Encapsulates business logic operations |
Repository Protocol | Interface between Domain & Data layer |
Repository Implementation | Concrete data access, format conversion |
Data Source / API | Access to external data sources |
Model/Entity | Core business data structures |
Dependency Factory | Creates and manages dependencies |