Expert Clean Architecture decisions for iOS/tvOS: when Clean Architecture adds value vs overkill, layer boundary judgment calls, dependency rule violations to catch, and practical trade-offs between purity and pragmatism. Use when designing app architecture, debugging layer violations, or deciding what belongs where. Trigger keywords: Clean Architecture, layer, domain, data, presentation, use case, repository, dependency rule, entity, DTO, mapper
Scanned 2/12/2026
Install via CLI
openskills install majiayu000/claude-skill-registry---
name: clean-architecture-ios
description: "Expert Clean Architecture decisions for iOS/tvOS: when Clean Architecture adds value vs overkill, layer boundary judgment calls, dependency rule violations to catch, and practical trade-offs between purity and pragmatism. Use when designing app architecture, debugging layer violations, or deciding what belongs where. Trigger keywords: Clean Architecture, layer, domain, data, presentation, use case, repository, dependency rule, entity, DTO, mapper"
version: "3.0.0"
---
# 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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
```swift
// ✅ 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
```swift
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
```swift
// 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 |
```swift
// 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 |
No comments yet. Be the first to comment!