Agent Skills: Clean Architecture iOS — Expert Decisions

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

UncategorizedID: kaakati/rails-enterprise-dev/clean-architecture-ios

Install this agent skill to your local

pnpm dlx add-skill https://github.com/Kaakati/rails-enterprise-dev/tree/HEAD/plugins/reactree-ios-dev/skills/clean-architecture-ios

Skill Files

Browse the full folder contents for clean-architecture-ios.

Download Skill

Loading file tree…

plugins/reactree-ios-dev/skills/clean-architecture-ios/SKILL.md

Skill Metadata

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"

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 |