home / skills / kaakati / rails-enterprise-dev / clean-architecture-ios
This skill provides expert Clean Architecture decisions for iOS, guiding layer boundaries, data vs domain placement, and pragmatic trade-offs.
npx playbooks add skill kaakati/rails-enterprise-dev --skill clean-architecture-iosReview the files below or copy the command above to add this skill to your agents.
---
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 |
This skill provides pragmatic, expert guidance for applying Clean Architecture to iOS and tvOS projects. It helps decide when Clean Architecture adds value versus when it is overkill, where code belongs across Presentation, Domain, and Data layers, and which dependency-rule violations to catch. Use it to make clear trade-offs between purity and pragmatism during design and code review.
The skill inspects design questions and gives judgment calls based on project scope, data-source complexity, team size, and expected lifetime. It maps responsibilities to layers, recommends use case granularity, and flags common anti-patterns (frameworks in Domain, DTO leakage, business logic in repositories). It also offers pragmatic patterns for caching, DI, and testing strategy.
When is Clean Architecture overkill?
For short-lived prototypes, solo developer projects, or simple apps with a single API, a lightweight MVVM and service layer is usually enough.
Should Entities implement Codable?
No. Keep Entities pure and put Codable DTOs in the Data layer; mapping handles serialization formats and API shapes.