Layer Separation
What Is Clean Architecture?
Clean Architecture, proposed by Robert C. Martin, organizes code into concentric layers where dependencies point inward. The inner layers have no knowledge of outer layers.
Presentation Layer (Views, ViewModels)
|
Domain Layer (Entities, Use Cases, Repository Protocols)
|
Data Layer (Repository Implementations, API, Database)
The Dependency Rule
The most important rule: source code dependencies must point inward only. The Domain layer knows nothing about the Presentation or Data layers.
// Domain Layer - no imports from Data or Presentation
protocol UserRepository {
func fetchUsers() async throws -> [User]
func saveUser(_ user: User) async throws
}
// Data Layer - imports Domain
class ApiUserRepository: UserRepository {
func fetchUsers() async throws -> [User] {
let response: [UserDTO] = try await apiClient.request(.users)
return response.map { $0.toDomain() }
}
}
// Presentation Layer - imports Domain
@Observable
class UserListViewModel {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
}
Why Clean Architecture?
- Testability: Each layer can be tested independently with mocks
- Maintainability: Changes in one layer do not ripple to others
- Scalability: New features follow the same pattern
- Framework independence: Domain layer has no UIKit or SwiftUI imports
Layer Responsibilities
| Layer | Contains | Depends On |
|---|---|---|
| Presentation | Views, ViewModels, View state | Domain only |
| Domain | Entities, Use Cases, Repository protocols | Nothing (innermost) |
| Data | Repository implementations, API clients, DB | Domain only |
Domain Layer
Entities
Entities are the core business objects. They are pure Swift structs with no framework dependencies:
struct User: Equatable, Identifiable {
let id: UUID
let name: String
let email: String
let avatarURL: URL?
let createdAt: Date
}
struct Product: Equatable, Identifiable {
let id: UUID
let name: String
let price: Decimal
let category: Category
let imageURL: URL
}
enum Category: String, CaseIterable {
case electronics, clothing, food, books
}
Use Cases
Use cases encapsulate business logic. Each use case performs a single action:
protocol FetchUsersUseCaseProtocol {
func execute() async throws -> [User]
}
struct FetchUsersUseCase: FetchUsersUseCaseProtocol {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func execute() async throws -> [User] {
let users = try await repository.fetchUsers()
return users.sorted(by: { $0.name < $1.name })
}
}
Repository Protocols
Repositories define the contract for data access without specifying the implementation:
protocol UserRepository {
func fetchUsers() async throws -> [User]
func fetchUser(id: UUID) async throws -> User?
func saveUser(_ user: User) async throws
func deleteUser(id: UUID) async throws
}
protocol ProductRepository {
func fetchProducts(category: Category?) async throws -> [Product]
func searchProducts(query: String) async throws -> [Product]
}
Use Case Composition
Complex operations compose multiple use cases:
protocol RegisterUserUseCaseProtocol {
func execute(name: String, email: String, password: String) async throws -> User
}
struct RegisterUserUseCase: RegisterUserUseCaseProtocol {
private let authRepository: AuthRepository
private let userRepository: UserRepository
private let analyticsService: AnalyticsService
func execute(name: String, email: String, password: String) async throws -> User {
let authResult = try await authRepository.register(email: email, password: password)
let user = User(id: authResult.userId, name: name, email: email, avatarURL: nil, createdAt: Date())
try await userRepository.saveUser(user)
analyticsService.track(event: "user_registered", properties: ["userId": user.id.uuidString])
return user
}
}
The use case coordinates multiple services without the ViewModel knowing the implementation details.
Data Layer
Repository Implementations
The Data layer implements Domain repository protocols with actual data sources:
class ApiUserRepository: UserRepository {
private let apiClient: ApiClient
private let cache: UserCache
init(apiClient: ApiClient, cache: UserCache) {
self.apiClient = apiClient
self.cache = cache
}
func fetchUsers() async throws -> [User] {
// Try cache first
if let cached = try? await cache.getUsers(), !cached.isEmpty {
return cached
}
// Fetch from API
let dtos: [UserDTO] = try await apiClient.request(.users)
let users = dtos.map { $0.toDomain() }
// Update cache
try? await cache.saveUsers(users)
return users
}
func saveUser(_ user: User) async throws {
let dto = UserDTO.from(domain: user)
try await apiClient.request(.saveUser(dto))
try? await cache.saveUser(user)
}
}
Data Transfer Objects (DTOs)
DTOs handle the mapping between network/database format and domain entities:
struct UserDTO: Codable {
let id: String
let full_name: String
let email_address: String
let avatar: String?
let created_at: String
func toDomain() -> User {
User(
id: UUID(uuidString: id) ?? UUID(),
name: full_name,
email: email_address,
avatarURL: avatar.flatMap(URL.init(string:)),
createdAt: ISO8601DateFormatter().date(from: created_at) ?? Date()
)
}
static func from(domain user: User) -> UserDTO {
UserDTO(
id: user.id.uuidString,
full_name: user.name,
email_address: user.email,
avatar: user.avatarURL?.absoluteString,
created_at: ISO8601DateFormatter().string(from: user.createdAt)
)
}
}
Presentation Layer
The Presentation layer uses ViewModels that depend on Domain use cases:
@Observable
class UserListViewModel {
var users: [User] = []
var isLoading = false
var error: String?
private let fetchUsers: FetchUsersUseCaseProtocol
init(fetchUsers: FetchUsersUseCaseProtocol) {
self.fetchUsers = fetchUsers
}
func loadUsers() async {
isLoading = true
defer { isLoading = false }
do {
users = try await fetchUsers.execute()
} catch {
self.error = error.localizedDescription
}
}
}
struct UserListView: View {
@State private var viewModel: UserListViewModel
init(repository: UserRepository) {
let useCase = FetchUsersUseCase(repository: repository)
_viewModel = State(initialValue: UserListViewModel(fetchUsers: useCase))
}
var body: some View {
List(viewModel.users) { user in
Text(user.name)
}
}
}
Dependency Graph
Wire everything together at the app level:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
let apiClient = ApiClient(baseURL: "https://api.example.com")
let cache = UserDefaultsUserCache()
let repository = ApiUserRepository(apiClient: apiClient, cache: cache)
let useCase = FetchUsersUseCase(repository: repository)
let viewModel = UserListViewModel(fetchUsers: useCase)
UserListView(viewModel: viewModel)
}
}
}
For production apps, use a dependency injection container to manage this graph.
Quiz
1. What is the dependency rule in Clean Architecture?
2. What lives in the Domain layer?
3. What is a DTO (Data Transfer Object)?
4. Why does the Domain layer have no framework imports?
5. What does a Use Case represent?
Flashcards
Question
What is the dependency rule?
Click to reveal answer
Answer
Source code dependencies point inward only. Inner layers (Domain) know nothing about outer layers (Data, Presentation).
Question
What are the three main layers?
Click to reveal answer
Answer
Domain (entities, use cases, protocols), Data (repository implementations, API), Presentation (views, viewmodels).
Question
What is a DTO?
Click to reveal answer
Answer
A Data Transfer Object that maps between external data formats and clean domain entities.
Question
What does a Use Case encapsulate?
Click to reveal answer
Answer
A single business action or operation, coordinating multiple services without the ViewModel knowing details.
Question
Why use repository protocols?
Click to reveal answer
Answer
They define the data access contract, allowing the Domain layer to depend on abstractions, not implementations.
Revision Notes
Key Takeaways
- 1. Clean Architecture separates Domain, Data, and Presentation layers
- 2. The dependency rule ensures inner layers have no knowledge of outer layers
- 3. Domain entities and use cases are pure Swift with no framework dependencies
- 4. DTOs map between external formats and domain entities
- 5. Use cases encapsulate single business actions for testability
Interview Tips
- • Explain the dependency rule and why it matters for testability
- • Walk through the three layers and what each contains
- • Discuss how DTOs bridge API responses to domain entities
- • Be ready to design a clean architecture for a given feature
Cheat Sheet
Clean Architecture: Domain <- Data <- Presentation.
Dependency Rule: dependencies point inward only.
Domain: entities, use cases, repository protocols.
Data: repository implementations, DTOs, API clients.
Presentation: ViewModels, Views.
Use Cases encapsulate single business actions.