clean-architecture-ios
Clean Architecture iOS — Expert Decisions
Expert decision frameworks for Clean Architecture choices. Claude knows the layers — this skill provides judgment calls for boundary decisions and pragmatic trade-offs.
Decision Trees
When Clean Architecture Is Worth It
Is this a side project or prototype?
├─ YES → Skip Clean Architecture (YAGNI)
│ └─ Simple MVVM with services is fine
│
└─ NO → How many data sources?
├─ 1 (just API) → Lightweight Clean Architecture
│ └─ Skip local data source, repository = API wrapper
│
└─ Multiple (API + cache + local DB)
└─ How long will codebase live?
├─ < 1 year → Consider simpler approach
└─ > 1 year → Full Clean Architecture
└─ Team size > 2? → Strongly recommended
Clean Architecture wins: Apps with complex business logic, multiple data sources, long maintenance lifetime, or teams > 3 developers.
Clean Architecture is overkill: Prototypes, simple apps with single API, short-lived projects, solo developers who know the whole codebase.
Where Does This Code Belong?
Does it know about UIKit/SwiftUI?
├─ YES → Presentation Layer
│ └─ Views, ViewModels, Coordinators
│
└─ NO → Does it know about network/database specifics?
├─ YES → Data Layer
│ └─ Repositories (impl), DataSources, DTOs, Mappers
│
└─ NO → Is it a business rule or core model?
├─ YES → Domain Layer
│ └─ Entities, UseCases, Repository protocols
│
└─ NO → Reconsider if it's needed
UseCase Granularity
Is this operation a single business action?
├─ YES → One UseCase per operation
│ Example: CreateOrderUseCase, GetUserUseCase
│
└─ NO → Does it combine multiple actions?
├─ YES → Can actions be reused independently?
│ ├─ YES → Separate UseCases, compose in ViewModel
│ └─ NO → Single UseCase with clear naming
│
└─ NO → Is it just CRUD?
├─ YES → Consider skipping UseCase
│ └─ ViewModel → Repository directly is OK for simple CRUD
│
└─ NO → Review the operation's purpose
The trap: Creating UseCases for every operation. If it's just repository.get(id:) pass-through, skip the UseCase.
NEVER Do
Dependency Rule Violations
NEVER import outer layers in inner layers:
// ❌ Domain importing Data layer
// Domain/UseCases/GetUserUseCase.swift
import Alamofire // Data layer framework!
import CoreData // Data layer framework!
// ❌ Domain importing Presentation layer
import SwiftUI // Presentation framework!
// ✅ Domain has NO framework imports (except Foundation)
import Foundation
NEVER let Domain know about DTOs:
// ❌ Repository protocol returns DTO
protocol UserRepositoryProtocol {
func getUser(id: String) async throws -> UserDTO // Data layer type!
}
// ✅ Repository protocol returns Entity
protocol UserRepositoryProtocol {
func getUser(id: String) async throws -> User // Domain type
}
NEVER put business logic in Repository:
// ❌ Business validation in Repository
final class UserRepository: UserRepositoryProtocol {
func updateUser(_ user: User) async throws -> User {
// Business rule leaked into Data layer!
guard user.email.contains("@") else {
throw ValidationError.invalidEmail
}
return try await remoteDataSource.update(user)
}
}
// ✅ Business logic in UseCase
final class UpdateUserUseCase {
func execute(user: User) async throws -> User {
guard user.email.contains("@") else {
throw DomainError.validation("Invalid email")
}
return try await repository.updateUser(user)
}
}
Entity Anti-Patterns
NEVER add framework dependencies to Entities:
// ❌ Entity with Codable for JSON
struct User: Codable { // Codable couples to serialization format
let id: String
let createdAt: Date // Will have JSON parsing issues
}
// ✅ Pure Entity, DTOs handle serialization
struct User: Identifiable, Equatable {
let id: String
let createdAt: Date
}
// Data layer handles Codable
struct UserDTO: Codable {
let id: String
let created_at: String // API format
}
NEVER put computed properties that need external data in Entities:
// ❌ Entity needs external service
struct Order {
let items: [OrderItem]
var totalWithTax: Decimal {
// Where does tax rate come from? External dependency!
total * TaxService.currentRate
}
}
// ✅ Calculation in UseCase
final class CalculateOrderTotalUseCase {
private let taxService: TaxServiceProtocol
func execute(order: Order) -> Decimal {
order.total * taxService.currentRate
}
}
Mapper Anti-Patterns
NEVER put Mappers in Domain layer:
// ❌ Domain knows about mapping
// Domain/Mappers/UserMapper.swift — WRONG LOCATION!
// ✅ Mappers live in Data layer
// Data/Mappers/UserMapper.swift
NEVER map in Repository if domain logic is needed:
// ❌ Silent default in mapper
enum ProductMapper {
static func toDomain(_ dto: ProductDTO) -> Product {
Product(
currency: Product.Currency(rawValue: dto.currency) ?? .usd // Silent default!
)
}
}
// ✅ Throw on invalid data, let UseCase handle
enum ProductMapper {
static func toDomain(_ dto: ProductDTO) throws -> Product {
guard let currency = Product.Currency(rawValue: dto.currency) else {
throw MappingError.invalidCurrency(dto.currency)
}
return Product(currency: currency)
}
}
Pragmatic Patterns
When to Skip the UseCase
// ✅ Simple CRUD — ViewModel → Repository is fine
@MainActor
final class UserListViewModel: ObservableObject {
private let repository: UserRepositoryProtocol
func loadUsers() async {
// Direct repository call for simple fetch
users = try? await repository.getUsers()
}
}
// ✅ UseCase needed — business logic involved
final class PlaceOrderUseCase {
func execute(cart: Cart) async throws -> Order {
// Validate stock
// Calculate totals
// Apply discounts
// Create order
// Notify inventory
// Return order
}
}
Rule: No business logic? Skip UseCase. Any validation, transformation, or orchestration? Create UseCase.
Repository Caching Strategy
final class UserRepository: UserRepositoryProtocol {
func getUser(id: String) async throws -> User {
// Strategy 1: Cache-first (offline-capable)
if let cached = try? await localDataSource.getUser(id: id) {
// Return cached, refresh in background
Task { try? await refreshUser(id: id) }
return UserMapper.toDomain(cached)
}
// Strategy 2: Network-first (always fresh)
let dto = try await remoteDataSource.fetchUser(id: id)
try? await localDataSource.save(dto) // Cache for offline
return UserMapper.toDomain(dto)
}
}
Minimal DI Container
// For small-medium apps, simple factory is enough
@MainActor
final class Container {
static let shared = Container()
// Lazy initialization — created on first use
lazy var networkClient = NetworkClient()
lazy var userRepository: UserRepositoryProtocol = UserRepository(
remote: UserRemoteDataSource(client: networkClient),
local: UserLocalDataSource()
)
// Factory methods for UseCases
func makeGetUserUseCase() -> GetUserUseCaseProtocol {
GetUserUseCase(repository: userRepository)
}
// Factory methods for ViewModels
func makeUserProfileViewModel() -> UserProfileViewModel {
UserProfileViewModel(getUser: makeGetUserUseCase())
}
}
Layer Reference
Dependency Direction
Presentation → Domain ← Data
✅ Presentation depends on Domain (imports UseCases, Entities)
✅ Data depends on Domain (implements Repository protocols)
❌ Domain depends on nothing (no imports from other layers)
What Goes Where
| Layer | Contains | Does NOT Contain |
|---|---|---|
| Domain | Entities, UseCases, Repository protocols, Domain errors | UIKit, SwiftUI, Codable DTOs, Network code |
| Data | Repository impl, DataSources, DTOs, Mappers, Network | UI code, Business rules, UseCases |
| Presentation | Views, ViewModels, Coordinators, UI components | Network code, Database code, DTOs |
Protocol Placement
| Protocol | Lives In | Implemented By |
|---|---|---|
UserRepositoryProtocol |
Domain | Data (UserRepository) |
UserRemoteDataSourceProtocol |
Data | Data (UserRemoteDataSource) |
GetUserUseCaseProtocol |
Domain | Domain (GetUserUseCase) |
Testing Strategy
What to Test Where
| Layer | Test Focus | Mock |
|---|---|---|
| Domain (UseCases) | Business logic, validation, orchestration | Repository protocols |
| Data (Repositories) | Coordination, caching, error mapping | DataSource protocols |
| Presentation (ViewModels) | State changes, user actions | UseCase protocols |
// UseCase test — mock Repository
func test_createOrder_validatesStock() async throws {
mockProductRepo.stubbedProduct = Product(inStock: false)
await XCTAssertThrowsError(
try await sut.execute(items: [item])
) { error in
XCTAssertEqual(error as? DomainError, .businessRule("Out of stock"))
}
}
// ViewModel test — mock UseCase
func test_loadUser_updatesState() async {
mockGetUserUseCase.stubbedUser = User(name: "John")
await sut.loadUser(id: "123")
XCTAssertEqual(sut.user?.name, "John")
XCTAssertFalse(sut.isLoading)
}
Quick Reference
Clean Architecture Checklist
- Domain layer has zero framework imports (except Foundation)
- Entities are pure structs with no Codable
- Repository protocols live in Domain
- Repository implementations live in Data
- DTOs and Mappers live in Data
- UseCases contain business logic, not pass-through
- ViewModels depend on UseCase protocols, not concrete classes
- No circular dependencies between layers
Red Flags
| Smell | Problem | Fix |
|---|---|---|
import UIKit in Domain |
Layer violation | Move to Presentation |
UseCase just calls repo.get() |
Unnecessary abstraction | ViewModel → Repo directly |
| DTO in Domain | Layer violation | Keep DTOs in Data |
| Business logic in Repository | Wrong layer | Move to UseCase |
| ViewModel imports NetworkClient | Skipped layers | Use Repository |