Clean Architecture in Swift: Building Scalable iOS Applications
Building iOS applications that are easy to maintain and scale is a challenge many developers face. As your app grows, things can start to feel messy—code becomes harder to manage, bugs are harder to track, and testing can feel like a nightmare. This is where Clean Architecture comes in. By structuring your code with a clear separation of concerns, Clean Architecture helps you keep your app’s core logic isolated from the rest of the app, making everything easier to manage.
In this article, we’ll take a look at what Clean Architecture is, why it’s so useful, and how you can implement it in your Swift-based iOS projects.
What is Clean Architecture?
Clean Architecture is a software design approach introduced by Robert C. Martin (Uncle Bob), designed to keep your code flexible, scalable, and easy to test. The basic idea is to separate different parts of your application into layers, each with a specific responsibility, and make sure the core business logic remains independent of things like UI frameworks, databases, or third-party services. This way, your app can evolve without one change in the UI or infrastructure breaking the entire system.
Here are the key principles behind Clean Architecture:
Framework Independence: Your app’s core logic shouldn’t rely on external frameworks like SwiftUI or UIKit. These frameworks are just details—your core logic should stay unaffected even if you decide to swap out the UI framework down the road.
Testability: By decoupling your core logic from the UI and data sources, testing becomes a breeze. You can focus on testing business rules without worrying about UI interactions or database setup.
Separation of Concerns: Clean Architecture divides your app into clear layers, each with a specific purpose:
The UI handles presenting data to the user.
The business logic (use cases) deals with processing the data and executing app-specific rules.
The data layer manages fetching and storing data.
Inward Dependencies: The direction of dependencies should always point inward, toward the core of your application. This means high-level modules (like your business logic) don’t depend on lower-level details (like your UI or database). Instead, the lower layers depend on abstractions, like protocols.
Layered Structure: Clean Architecture organizes your code into layers:
Entities (Core/Domain Layer): The core business logic and rules.
Use Cases (Application Layer): These contain the specific application rules that interact with the domain logic.
Interface Adapters (Presentation Layer): Here you convert data into something the UI can present, like ViewModels.
Frameworks and Drivers (Infrastructure Layer): This includes external services, like APIs, databases, and UI frameworks.
This separation makes your app more maintainable. For example, you can change the UI from SwiftUI to UIKit without having to change the core business logic.
How Clean Architecture Looks in Swift
Now let’s break down the different layers of Clean Architecture and how you can implement them in a Swift-based iOS project.
1. Entities (Domain Layer)
The Entities represent your app’s core business logic. They’re completely isolated from other layers, meaning they don’t depend on any external frameworks. In Swift, they’re typically just structs or classes.
struct User {
let id: Int
let name: String
let email: String
}
2. Use Cases (Application Layer)
Use cases (also known as Interactors) handle the specific tasks your app needs to perform. They interact with the entities and business logic but don’t directly deal with the UI or data sources.
protocol FetchUserUseCase {
func execute(userId: Int) -> User?
}
class FetchUserUseCaseImpl: FetchUserUseCase {
private let userRepository: UserRepository
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
func execute(userId: Int) -> User? {
return userRepository.getUserById(userId)
}
}
3. Repositories (Abstraction for Data Layer)
Repositories act as an abstraction between the data layer and the use cases. This makes your app flexible enough to fetch data from different sources (like an API or a database) without the use case needing to know where the data is coming from.
protocol UserRepository {
func getUserById(_ id: Int) -> User?
}
4. ViewModels (Presentation Layer)
In the Presentation Layer, ViewModels (or Presenters) prepare the data for the UI. They take the results from the use cases and present them in a format that’s ready for the user interface.
class UserViewModel: ObservableObject {
private let fetchUserUseCase: FetchUserUseCase
@Published var userName: String = ""
init(fetchUserUseCase: FetchUserUseCase) {
self.fetchUserUseCase = fetchUserUseCase
}
func loadUser(userId: Int) {
if let user = fetchUserUseCase.execute(userId: userId) {
self.userName = user.name
} else {
self.userName = "User not found"
}
}
}
5. UI Layer (SwiftUI)
The UI Layer interacts with the ViewModel, which in turn interacts with the use case. In this example, we’ll use SwiftUI to build the user interface.
struct UserView: View {
@ObservedObject var viewModel: UserViewModel
var body: some View {
Text(viewModel.userName)
.onAppear {
viewModel.loadUser(userId: 1)
}
}
}
6. Infrastructure Layer (Data Sources and API)
The Infrastructure Layer contains the actual implementations of the repositories. This is where your app interacts with databases or APIs to fetch the data.
class UserRepositoryImpl: UserRepository {
func getUserById(_ id: Int) -> User? {
// Simulate fetching from an API or database
return User(id: id, name: "John Doe", email: "john@example.com")
}
}
Using Dependency Injection
In Clean Architecture, we use Dependency Injection to make our code flexible and testable. Instead of creating dependencies within the objects, we inject them.
let userRepository = UserRepositoryImpl()
let fetchUserUseCase = FetchUserUseCaseImpl(userRepository: userRepository)
let viewModel = UserViewModel(fetchUserUseCase: fetchUserUseCase)
let view = UserView(viewModel: viewModel)
Benefits of Clean Architecture
Separation of Concerns: Each layer has its own responsibility, making the code easier to manage.
Testability: Since the core business logic is separate from the UI and external frameworks, you can write tests for each piece independently.
Scalability: Adding new features or changing existing ones is much easier with Clean Architecture.
Maintainability: You can modify or replace parts of the app (like switching from SwiftUI to UIKit) without having to rewrite the core logic.
Testing Your Use Cases
Because Clean Architecture isolates the business logic, you can easily write unit tests to verify that your use cases work as expected.
class FetchUserUseCaseTests: XCTestCase {
func testFetchUserReturnsCorrectUser() {
let mockRepository = MockUserRepository()
let fetchUserUseCase = FetchUserUseCaseImpl(userRepository: mockRepository)
let user = fetchUserUseCase.execute(userId: 1)
XCTAssertEqual(user?.name, "John Doe")
}
}
Conclusion
Clean Architecture in Swift offers a powerful way to build scalable and maintainable iOS applications. By separating concerns and adhering to strong architectural principles, you ensure that your app’s core logic is isolated from external changes, making it easier to test, refactor, and grow.
Although it takes more time and planning to set up, Clean Architecture provides long-term benefits, especially as your app becomes more complex.
Subscribe to my newsletter
Read articles from Sushi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by