This guide explains Clean Architecture patterns and principles using a practical iOS implementation. Clean Architecture, popularized by Robert C. Martin (Uncle Bob), organizes code into layers with clear dependencies and responsibilities, making applications more maintainable, testable, and scalable.
Clean Architecture is built upon the SOLID principles of object-oriented design:
Each class should have only one reason to change. In Clean Architecture, this means each layer and component has a single, well-defined responsibility.
Software entities should be open for extension but closed for modification. Use protocols and dependency injection to extend behavior without changing existing code.
Objects should be replaceable with instances of their subtypes. Any implementation of a protocol should be substitutable without breaking functionality.
Clients shouldn't be forced to depend on interfaces they don't use. Create focused, specific protocols rather than large, monolithic ones.
High-level modules shouldn't depend on low-level modules. Both should depend on abstractions. This is the foundation of the Dependency Rule in Clean Architecture.
Dependencies point inward. Outer layers depend on inner layers, never the reverse. This enforces the Dependency Inversion Principle at an architectural level.
Business rules don't depend on UI, databases, or external frameworks, achieved through proper abstraction and dependency inversion.
┌─────────────────────────────────────┐
│ Application Layer │ ← Composition Root
│ (Dependency Injection) │
├─────────────────────────────────────┤
│ Presentation Layer │ ← UI & ViewModels
│ (Views & ViewModels) │
├─────────────────────────────────────┤
│ Domain Layer │ ← Business Logic
│ (Use Cases & Entities) │ (Core)
├─────────────────────────────────────┤
│ Data Layer │ ← External Interfaces
│ (Repositories & Data Sources) │
└─────────────────────────────────────┘
The Domain Layer contains your business logic and is the heart of Clean Architecture. It has no dependencies on external frameworks, UI, or data persistence.
What they are: Core business objects that represent your domain concepts.
Example: User.swift
public struct User: Equatable, Sendable {
public let id: UUID
public let username: String
}
Why they matter:
- Encapsulate business rules and data
- Framework-independent
- Can be shared across different applications
What they are: Single-purpose classes that orchestrate business operations. Each use case represents one specific business action.
Example: UserLoginUseCase.swift
public protocol UserLoginUseCase {
func execute(username: String, password: String) async -> Result<Void, LoginError>
}
public struct DefaultUserLoginUseCase: UserLoginUseCase {
let userRepository: UserRepository
public func execute(username: String, password: String) async -> Result<Void, LoginError> {
// Business rule: Validate inputs
if username.isEmpty { return .failure(.usernameIsEmpty) }
if password.isEmpty { return .failure(.passwordIsEmpty) }
// Delegate data operations to repository
return await userRepository.login(username: username, password: password)
}
}
Key Patterns:
- Single Responsibility: Each use case does one thing
- Dependency Injection: Receives repositories through constructor
- Domain Errors: Returns business-specific error types
- Protocol-Based: Defined by interfaces, not implementations
Benefits:
- Business logic is isolated and testable
- Easy to understand what the system can do
- Can be reused across different UIs
What they are: Abstract interfaces that define how the domain layer accesses data, without knowing the implementation details.
Example: UserRepository.swift
public protocol UserRepository {
@MainActor
var loggedInPublisher: AnyPublisher<Bool, Never> { get }
@MainActor
func login(username: String, password: String) async -> Result<Void, LoginError>
@MainActor
func logout() async
}
Why they're important:
- Dependency Inversion: Domain defines what it needs, doesn't depend on implementation
- Testability: Easy to create mock implementations
- Flexibility: Can swap data sources without changing business logic
The Data Layer implements the repository contracts and handles all external data concerns.
What they are: Concrete implementations of repository contracts that coordinate between different data sources.
Example: DefaultUserRepository.swift
public final class DefaultUserRepository: UserRepository {
private let session: UserSession
private let authClient: AuthClient
public func login(username: String, password: String) async -> Result<Void, LoginError> {
let result = await authClient.login(username: username, password: password)
switch result {
case let .success((user, token)):
// Map DTO (AuthToken) to domain usage and store domain entity (User)
session.setUser(user, token: token)
return .success(())
case .failure(let error):
// Map data layer errors to domain errors
return .failure(mapAuthClientErrorToLoginError(error))
}
}
private func mapAuthClientErrorToLoginError(_ error: AuthClientError) -> LoginError {
switch error {
case .invalidCredentials: return .invalidCredentials
case .networkFailure, .unknown: return .unknown
}
}
}
Key Responsibilities:
- Coordination: Orchestrate between different data sources
- Data Mapping: Convert DTOs to domain entities and data layer errors to domain errors
- Business Rule Enforcement: Apply domain-specific validation and constraints
- Caching: Manage data consistency between local and remote sources
What they are: Specific implementations that handle data from a single source (API, database, cache).
Example: AuthClient.swift
public protocol AuthClient: Sendable {
func login(username: String, password: String) async -> Result<(User, AuthToken), AuthClientError>
}
public actor FakeAuthClient: AuthClient {
public func login(username: String, password: String) async -> Result<(User, AuthToken), AuthClientError> {
// Simulate network delay
try? await Task.sleep(nanoseconds: 500_000_000)
// Business validation
guard !username.isEmpty, !password.isEmpty else {
return .failure(.invalidCredentials)
}
// Return success with mock data
let token = AuthToken(value: UUID().uuidString, expiresAt: Date().addingTimeInterval(3600))
return .success((User(id: UUID(), username: username), token))
}
}
Types of Data Sources:
- API Clients: Handle HTTP requests
- Database Access: Local persistence (Core Data, SQLite)
- Cache Managers: In-memory or disk caching
- Session Storage: User session and authentication state
What they are: Objects that represent data structure from external sources, separate from domain entities.
Example: AuthToken.swift
public struct AuthToken: Equatable, Sendable {
public let value: String
public let expiresAt: Date
public var isExpired: Bool {
Date() >= expiresAt
}
}
Why separate from entities:
- External APIs might change their structure without affecting domain logic
- Domain entities represent business concepts, DTOs represent external data format
- Repositories handle the mapping between DTOs and domain entities
- Allows for data transformation, validation, and format conversion
The Presentation Layer handles user interface and user interactions using the MVVM pattern.
What they are: Observable classes that manage UI state and coordinate with use cases.
Example: LoginScreenViewModel.swift
@MainActor
public final class LoginScreenViewModel: ObservableObject {
// UI State
@Published var username: String = ""
@Published var password: String = ""
@Published var isLoading: Bool = false
@Published var error: String?
// Use Case Dependency
private let userLogin: UserLoginUseCase
func login() async {
isLoading = true
defer { isLoading = false }
// Call use case
let result = await userLogin.execute(username: username, password: password)
// Handle result and update UI state
switch result {
case .success:
error = nil
case .failure(let loginError):
error = mapLoginErrorToMessage(loginError)
}
}
}
Key Patterns:
- Observable: Uses
@Published
for reactive UI updates - State Management: Manages all UI-related state
- Use Case Coordination: Calls business logic, doesn't implement it
- Error Handling: Converts domain errors to user-friendly messages
What they are: Pure UI components that display data and delegate actions to ViewModels.
Example: LoginScreenView.swift
public struct LoginScreenView: View {
@ObservedObject var viewModel: LoginScreenViewModel
public var body: some View {
VStack(spacing: 20) {
TextField("Username", text: $viewModel.username)
SecureField("Password", text: $viewModel.password)
if viewModel.isLoading {
ProgressView()
} else {
Button("Login") {
Task { await viewModel.login() }
}
}
if let error = viewModel.error {
Text(error).foregroundColor(.red)
}
}
}
}
Principles:
- No Business Logic: Views only handle presentation
- Reactive: Automatically update when ViewModel state changes
- Declarative: Describe what the UI should look like
The Application Layer wires everything together and manages app-level concerns.
What it is: A central place where all dependencies are created and configured.
Example: Injector.swift
final class Injector {
static let shared = Injector()
// Domain Dependencies
private let userDI: UserDI
// Navigation
let appNavigator: Navigator
private let homeNavigation: DefaultHomeNavigation
private init() {
// Create domain layer
userDI = UserDI()
// Create navigation
appNavigator = Navigator()
homeNavigation = DefaultHomeNavigation(navigator: appNavigator)
// Create UI layers
loginUIDI = LoginUIDI(userDI: userDI)
homeUIDI = HomeUIDI(navigation: homeNavigation)
}
}
Benefits:
- Single Configuration Point: All dependencies wired in one place
- Dependency Control: Easy to swap implementations for testing
- Object Lifecycle: Manages singleton vs transient instances
What they are: Protocols that define navigation capabilities without coupling features together.
Example: HomeNavigation.swift
public protocol HomeNavigation: AnyObject {
func openHomeDetail(id: UUID)
func goToWishlistDetail(id: UUID)
}
// Implementation in App layer
final class DefaultHomeNavigation: HomeNavigation {
private unowned let navigator: Navigator
func openHomeDetail(id: UUID) {
navigator.push(HomeDestination.detail(id: id), tab: .home)
}
func goToWishlistDetail(id: UUID) {
navigator.push(WishlistDestination.detail(id: id), tab: .wishlist)
}
}
Key Benefits:
- Decoupling: Features don't know about other features directly
- Testability: Easy to mock navigation for testing
- Type Safety: Navigation parameters are compile-time checked
What it is: A centralized navigation coordinator that manages app-wide navigation state.
Example: Navigator.swift
final class Navigator: ObservableObject {
@Published var selectedTab: Tabs = .home
@Published var homePath = NavigationPath()
@Published var wishlistPath = NavigationPath()
// Type-safe destination registry
private var factories: [ObjectIdentifier: (Any) -> AnyView] = [:]
public func register<Route: Hashable>(factory: @escaping (Route) -> some View) {
let key = ObjectIdentifier(Route.self)
factories[key] = { any in
guard let route = any as? Route else { return AnyView(EmptyView()) }
return AnyView(factory(route))
}
}
func push(_ route: any Hashable, tab: Tabs? = nil) {
// Navigation logic...
}
}
Features:
- Type Safety: Routes are strongly typed enums
- State Management: Manages navigation stack for each tab
- Cross-Tab Navigation: Can navigate between different sections
- View Factory: Dynamically creates views for routes
Each feature is organized as an independent module with consistent structure:
FeatureUI/
├── Sources/
│ ├── UI/
│ │ ├── FeatureScreen/
│ │ ├── Navigation/
│ │ └── Details/
│ └── DI/
│ └── FeatureUIDI.swift
├── Tests/
└── Package.swift
What it is: A dependency injection container specific to one feature.
Example: HomeUIDI.swift
public struct HomeUIDI {
private let navigation: HomeNavigation
public init(navigation: HomeNavigation) {
self.navigation = navigation
}
@MainActor
public func mainView() -> some View {
let viewModel = HomeScreenViewModel()
return HomeScreenView(viewModel: viewModel, navigation: navigation)
}
@MainActor
public func makeView(for destination: HomeDestination) -> some View {
switch destination {
case .detail(let id):
HomeDetailScreenView(id: id)
}
}
}
Benefits:
- Feature Isolation: Each feature manages its own dependencies
- Independent Development: Features can be developed separately
- Consistent API: All features expose similar interfaces
// Test use cases with mock repositories
func testLoginWithEmptyUsername() async {
let mockRepository = MockUserRepository()
let useCase = DefaultUserLoginUseCase(userRepository: mockRepository)
let result = await useCase.execute(username: "", password: "password")
XCTAssertEqual(result, .failure(.usernameIsEmpty))
}
// Test repository implementations with mock data sources
func testLoginSuccess() async {
let mockAuthClient = MockAuthClient()
let mockSession = MockUserSession()
let repository = DefaultUserRepository(session: mockSession, authClient: mockAuthClient)
let result = await repository.login(username: "user", password: "pass")
XCTAssertEqual(result, .success(()))
}
// Test ViewModels with mock use cases
@MainActor
func testLoginLoading() async {
let mockUseCase = MockUserLoginUseCase()
let viewModel = LoginScreenViewModel(userLogin: mockUseCase)
await viewModel.login()
XCTAssertFalse(viewModel.isLoading)
}
- Clear boundaries between layers
- Easy to locate and modify code
- Changes in one layer don't ripple through others
- Each layer can be tested in isolation
- Mock implementations are straightforward
- Business logic is pure and predictable
- New features follow established patterns
- Dependencies are managed centrally
- Code reuse across features
- Different teams can work on different layers
- Clear contracts between components
- Consistent patterns across the codebase
- Business logic can be shared between platforms
- Easy to create different UIs for the same logic
- Framework changes don't affect core business rules