A comprehensive guide to implementing Clean Architecture in Swift for live coding interviews, featuring real API integration and comprehensive testing.
This project demonstrates Clean Architecture principles with clear separation of concerns across four distinct layers:
┌─────────────────────────────────────────┐
│ Presentation │
│ (Views, ViewModels) │
├─────────────────────────────────────────┤
│ Domain │
│ (Entities, Use Cases) │
├─────────────────────────────────────────┤
│ Data │
│ (Network, Repositories, DTOs) │
├─────────────────────────────────────────┤
│ Dependency Injection │
│ (DI Container) │
└─────────────────────────────────────────┘
SwiftCleanCode/
├── Domain/
│ ├── Entities/
│ │ └── User.swift
│ ├── Repositories/
│ │ └── UserRepositoryProtocol.swift
│ └── UseCases/
│ └── FetchUsersUseCase.swift
├── Data/
│ ├── Network/
│ │ ├── NetworkService.swift
│ │ └── APIEndpoint.swift
│ ├── Models/
│ │ └── UserDTO.swift
│ └── Repositories/
│ └── UserRepository.swift
├── Presentation/
│ ├── ViewModels/
│ │ ├── UserListViewModel.swift
│ │ └── UserDetailViewModel.swift
│ └── Views/
│ ├── UserListView.swift
│ └── UserDetailView.swift
├── DI/
│ └── DIContainer.swift
└── Tests/
├── Domain/
├── Data/
└── Presentation/
Here's a visual representation of how components communicate in our clean architecture:
┌─────────────────────────────────────────────────────────────────┐
│ USER INTERACTION │
└────────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ ┌─────────────┐ ┌─────────────────────────────────────┐ │
│ │ View │────▶│ ViewModel │ │
│ │ (SwiftUI) │◀────│ (@Published properties) │ │
│ └─────────────┘ └──────────────┬──────────────────────┘ │
└─────────────────────────────────────┼───────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ DOMAIN LAYER │
│ ┌─────────────────────────────────────┐ │
│ │ Use Case │ │
│ │ (Business Logic) │ │
│ └──────────────┬──────────────────────┘ │
└─────────────────────────────┼───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ DATA LAYER │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ Repository │───────▶│ NetworkService │ │
│ │(DTO→Domain Map) │◀───────│ (API Communication) │ │
│ └─────────────────┘ └──────────────┬───────────────┘ │
└────────────────────────────────────────────┼────────────────────┘
│
▼
┌─────────────────┐
│ External API │
│(JSONPlaceholder)|
└─────────────────┘
The core business logic layer that's independent of any external frameworks.
Pure Swift models representing business objects:
struct User: Identifiable, Equatable {
let id: Int
let name: String
let username: String
let email: String
let phone: String
let website: String
let company: Company
let address: Address
}
struct Company: Equatable {
let name: String
let catchPhrase: String
let bs: String
}Define contracts for data operations:
protocol UserRepositoryProtocol {
func fetchUsers() async throws -> [User]
func fetchUser(by id: Int) async throws -> User
}
enum DomainError: Error, Equatable {
case networkError(String)
case decodingError(String)
case userNotFound
case unknown(String)
}Encapsulate specific business rules:
final class FetchUsersUseCase: FetchUsersUseCaseProtocol {
private let repository: UserRepositoryProtocol
init(repository: UserRepositoryProtocol) {
self.repository = repository
}
func execute() async throws -> [User] {
return try await repository.fetchUsers()
}
}Handles external data sources and implements repository protocols.
Protocol-based networking with proper error handling:
protocol URLSessionProtocol {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol {}
final class NetworkService: NetworkServiceProtocol {
private let session: URLSessionProtocol
func request<T: Decodable>(_ endpoint: APIEndpoint, responseType: T.Type) async throws -> T {
let request = try endpoint.asURLRequest()
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
throw NetworkError.serverError(httpResponse.statusCode)
}
return try JSONDecoder().decode(responseType, from: data)
}
}Type-safe endpoint configuration:
enum UserEndpoint: APIEndpoint {
case fetchUsers
case fetchUser(id: Int)
var baseURL: String { "https://jsonplaceholder.typicode.com" }
var path: String {
switch self {
case .fetchUsers: return "/users"
case .fetchUser(let id): return "/users/\(id)"
}
}
}Map API responses to domain models:
struct UserDTO: Codable {
let id: Int
let name: String
let username: String
let email: String
// ... other properties
}
extension UserDTO {
func toDomain() -> User {
return User(
id: id,
name: name,
username: username,
email: email,
// ... map other properties
)
}
}Concrete implementation of repository protocols:
final class UserRepository: UserRepositoryProtocol {
private let networkService: NetworkServiceProtocol
func fetchUsers() async throws -> [User] {
let userDTOs = try await networkService.request(
UserEndpoint.fetchUsers,
responseType: [UserDTO].self
)
return userDTOs.map { $0.toDomain() }
}
}Manages UI state and user interactions using MVVM pattern.
Handle UI logic and state management:
@MainActor
final class UserListViewModel: ObservableObject {
@Published var users: [User] = []
@Published var loadingState: LoadingState = .idle
@Published var searchText: String = ""
private let fetchUsersUseCase: FetchUsersUseCaseProtocol
var filteredUsers: [User] {
if searchText.isEmpty {
return users
}
return users.filter { user in
user.name.localizedCaseInsensitiveContains(searchText) ||
user.username.localizedCaseInsensitiveContains(searchText) ||
user.email.localizedCaseInsensitiveContains(searchText)
}
}
func fetchUsers() {
Task {
loadingState = .loading
do {
users = try await fetchUsersUseCase.execute()
loadingState = .loaded
} catch {
loadingState = .error(error.localizedDescription)
}
}
}
}SwiftUI views that observe ViewModels:
struct UserListView: View {
@StateObject private var viewModel: UserListViewModel
var body: some View {
NavigationView {
VStack {
SearchBar(text: $viewModel.searchText)
switch viewModel.loadingState {
case .loading:
LoadingView()
case .loaded:
UserList(users: viewModel.filteredUsers)
case .error(let message):
ErrorView(message: message) {
viewModel.fetchUsers()
}
}
}
.navigationTitle("Users")
.onAppear {
viewModel.fetchUsers()
}
}
}
}Manages dependencies and provides proper abstraction:
final class DIContainer {
static let shared = DIContainer()
private lazy var networkService: NetworkServiceProtocol = {
NetworkService()
}()
private lazy var userRepository: UserRepositoryProtocol = {
UserRepository(networkService: networkService)
}()
func makeFetchUsersUseCase() -> FetchUsersUseCaseProtocol {
return FetchUsersUseCase(repository: userRepository)
}
@MainActor
func makeUserListViewModel() -> UserListViewModel {
return UserListViewModel(fetchUsersUseCase: makeFetchUsersUseCase())
}
}final class FetchUsersUseCaseTests: XCTestCase {
func testExecute_WhenRepositoryReturnsUsers_ShouldReturnUsers() async throws {
// Given
let mockRepository = MockUserRepository()
mockRepository.usersToReturn = [User.mockUser1, User.mockUser2]
let sut = FetchUsersUseCase(repository: mockRepository)
// When
let users = try await sut.execute()
// Then
XCTAssertEqual(users.count, 2)
XCTAssertTrue(mockRepository.fetchUsersCalled)
}
}final class NetworkServiceTests: XCTestCase {
func testRequest_WhenValidResponse_ShouldReturnDecodedData() async throws {
// Given
let mockSession = MockURLSession()
let expectedUser = UserDTO.mockUser1
let jsonData = try JSONEncoder().encode(expectedUser)
mockSession.dataToReturn = (jsonData, HTTPURLResponse(/* valid response */))
let sut = NetworkService(session: mockSession)
// When
let result = try await sut.request(UserEndpoint.fetchUser(id: 1), responseType: UserDTO.self)
// Then
XCTAssertEqual(result.id, expectedUser.id)
}
}@MainActor
final class UserListViewModelTests: XCTestCase {
func testFetchUsers_WhenUseCaseReturnsUsers_ShouldUpdateUsersAndState() async {
// Given
let mockUseCase = MockFetchUsersUseCase()
mockUseCase.usersToReturn = [User.mockUser1, User.mockUser2]
let sut = UserListViewModel(fetchUsersUseCase: mockUseCase)
// When
sut.fetchUsers()
// Give time for async operation
try? await Task.sleep(nanoseconds: 100_000_000)
// Then
XCTAssertEqual(sut.users.count, 2)
XCTAssertTrue(mockUseCase.executeCalled)
}
}- User Interaction → View receives user input (tap, search, etc.)
- View → ViewModel via method calls (
fetchUsers(),searchTextbinding) - ViewModel → Use Case via protocol methods (
execute()) - Use Case → Repository via protocol methods (
fetchUsers()) - Repository → NetworkService via request methods
- NetworkService → External API via HTTP requests
- Response Flow: API → NetworkService → Repository → Use Case → ViewModel → View
- UI Update: ViewModel publishes changes, View automatically updates
- Unidirectional Data Flow: Data flows down, events flow up
- Protocol-Based: Each layer communicates through abstractions
- Dependency Injection: DI Container assembles and provides dependencies
- Async/Await: Modern Swift concurrency for network operations
- Error Propagation: Errors are mapped and handled at each layer
Interviewer: "We need you to create an app that fetches users from an API and displays them in a list. How would you approach this?"
You: "I'd structure this using Clean Architecture to ensure maintainability and testability. Let me break down my approach:
First, I'd create the domain layer with a User entity and a UserRepositoryProtocol to define the contract for data operations. This keeps the business logic independent of external dependencies."
You: "I'd implement the data layer with three key components:
- NetworkService: A protocol-based service that handles HTTP requests
- APIEndpoint: Type-safe endpoint definitions using enums
- UserRepository: Concrete implementation that uses the NetworkService and maps DTOs to domain models
This separation allows me to easily mock the network layer for testing."
You: "I'd use MVVM in the presentation layer:
- UserListViewModel: Manages UI state with
@Publishedproperties for users, loading state, and search functionality - UserListView: SwiftUI view that observes the ViewModel and reacts to state changes
For loading states, I'd use an enum with cases like .idle, .loading, .loaded, and .error(String) to provide clear UI feedback."
You: "I'd create a DIContainer that manages the creation and injection of dependencies. This follows the dependency inversion principle where high-level modules don't depend on low-level modules, but both depend on abstractions."
You: "Each layer would have comprehensive unit tests using mocks:
- Domain layer tests verify business logic
- Data layer tests ensure proper API integration and error handling
- Presentation layer tests validate UI state management
I'd use protocols throughout to enable easy mocking and maintain high test coverage."
- Clone the repository
- Open
SwiftCleanCode.xcodeprojin Xcode - Build and run the project
- Run tests with
Cmd + U
- Testability: Each layer can be tested independently
- Maintainability: Clear separation of concerns makes code easier to modify
- Scalability: Easy to add new features without affecting existing code
- Platform Independence: Domain layer is pure Swift and can be reused across platforms
A: The Domain layer contains the core business logic, entities, and use cases. It's the innermost layer that doesn't depend on any external frameworks or libraries. It defines what the application does, not how it does it.
A: The Data layer handles all external data sources like APIs, databases, and local storage. It implements the repository protocols defined in the Domain layer and is responsible for data retrieval, caching, and persistence.
A: DTOs are simple objects that carry data between different layers or systems. They typically mirror the structure of API responses and are used to decouple the external API format from your internal domain models.
A: API models (DTOs) belong in the Data layer under a Models or DTOs folder. These models should have mapping functions to convert to domain entities.
A:
- DTOs: Reflect the API structure, include all API fields, used for serialization/deserialization
- Domain Models: Reflect business requirements, may combine multiple DTOs, contain business logic
A: Repository and use case protocols belong in the Domain layer. Network protocols belong in the Data layer. This ensures the Domain layer defines contracts without knowing about implementation details.
A: ✅ Repository Can Have:
- API calls and network requests
- Data caching logic
- Data persistence operations
- Error mapping from network to domain errors
- Data transformation (DTO to Domain mapping)
❌ Repository Should NOT Have:
- Business logic or rules
- UI-related code
- Direct access to ViewModels
- Complex data processing that belongs in Use Cases
A: Define domain-specific errors in the Domain layer and map external errors (network, parsing) to domain errors in the Data layer. This keeps error handling consistent throughout the app.
A: No, ViewModels should call Use Cases, which then interact with Repositories. This maintains proper separation of concerns and makes testing easier.
A: Use protocol-based dependency injection. Create a URLSessionProtocol that URLSession conforms to, then create mock implementations for testing. This allows you to test network logic without making actual HTTP requests.
A: Business validation belongs in the Domain layer (entities or use cases). UI validation (format checking) can be in ViewModels. Data validation (API response validation) belongs in the Data layer.
A: Use an enum to represent different states: .idle, .loading, .loaded, .error(String). This provides type safety and makes it easy to handle all possible states in the UI.
A: Mirror your main project structure in tests. Create separate test classes for each component and use the naming convention ComponentNameTests. Group tests by functionality and use descriptive test method names.
A: Even in small apps, Use Cases provide clear entry points for business operations, make testing easier, and prepare your app for future growth. They're especially valuable during interviews as they demonstrate understanding of separation of concerns.
Created by M. Afham - iOS/macOS Apps Developer