# 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 like UserRepositoryProtocol 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 Layer                 

                 UI Layer                 

user actions

calls

User

uses

User

  UserListView  

  SwiftUI  

  UserListViewModel  

  @Observable  

  FetchUsersUseCase: PFetchUsersUseCase  

  Business Logic  

  User  

  Domain Model  

  PUserRepository  

  Protocol  

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.

                 Data Layer                 

                 Domain Layer                 

calls

UserEntity

UserDTO

User

creates

  FetchUsersUseCase  

  Business Logic  

  PUserRepository  

  Protocol  

  User  

  Domain Model  

  UserRepository: PUserRepository  

  Implementation  

  CoreData  

  UserEntity  

  API Client  

  UserDTO  

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/models
struct User {
let id: String
let name: String
let email: String
let isActive: Bool
}
// Use Cases: FetchUsersUseCase.swift in domain/usecases
protocol 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
@Observable
class 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 implementation
class 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 protocol
protocol PNetworkClient {
func get<T: Codable>(_ endpoint: String) async throws -> T
}
// Simple network client
class 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 creation
class 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
@Observable
class 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 usage
let view = UserListView() // Uses AppFactory.shared
// Testing usage
let view = UserListView(factory: MockAppFactory())

Unit Testing with MocksLink to heading

// Simple mock for testing
class MockUserRepository: PUserRepository {
var mockUsers: [User] = []
func fetchUsers() async throws -> [User] {
return mockUsers
}
}
// Unit test example
class 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

ComponentResponsibility
ViewUI elements, presentation, user interaction
ViewModelBridge between View & Domain, state management
Use CaseEncapsulates business logic operations
Repository ProtocolInterface between Domain & Data layer
Repository ImplementationConcrete data access, format conversion
Data Source / APIAccess to external data sources
Model/EntityCore business data structures
Dependency FactoryCreates and manages dependencies
My avatar

Thanks for reading my blog post! Feel free to check out my other posts or contact me via the social links in the footer.


More Posts