App Architecture
MVVM and Clean Architecture patterns for structuring scalable, testable iOS applications
You are an expert in application architecture for building iOS applications with Swift. ## Key Points - **Model** — Plain data types and business rules. - **View** — SwiftUI views that render state and forward user actions. - **ViewModel** — Transforms model data into view-ready state; handles user intents. - **Keep views dumb.** Views should read state from the view model and forward actions. No business logic in views. - **Define repository protocols in the domain layer.** Concrete implementations live in the data layer. This inverts the dependency so domain never depends on infrastructure. - **Use value types for models and DTOs.** Structs are simpler, thread-safe, and encourage immutability. - **Inject dependencies through initializers.** Avoid singletons for anything stateful. Constructor injection makes dependencies explicit and enables testing. - **One ViewModel per screen.** Shared state should live in a repository or service, not in a view model passed between screens. - **Name use cases as verbs.** `FetchProductsUseCase`, `PlaceOrderUseCase`, `ValidateEmailUseCase`. Each represents one specific action. - **Separate DTOs from domain models.** API response shapes change independently of your domain. Mapping between them at the data layer boundary isolates the app from backend changes. - **Over-engineering small apps.** A to-do app does not need Clean Architecture with use cases and repository protocols. Start with MVVM and add layers only when complexity justifies them. - **Massive ViewModels.** If a view model exceeds 200-300 lines, split it. Extract use cases, break the screen into child views with their own view models, or use helper types.
skilldb get ios-swift-skills/App ArchitectureFull skill: 431 linesApp Architecture — iOS/Swift
You are an expert in application architecture for building iOS applications with Swift.
Core Philosophy
Architecture in iOS development exists to serve one purpose: making it easy to change code without breaking other code. MVVM and Clean Architecture are not goals in themselves but tools for managing complexity as an app grows. The right architecture is the simplest one that keeps your codebase maintainable at its current scale. A two-screen app with Clean Architecture layers, use case protocols, and a DI container is over-engineered; a fifty-screen app without clear separation between UI and business logic is a maintenance disaster. Start lean and add structure when you feel real pain, not theoretical pain.
The dependency rule is the single most important principle: inner layers must never know about outer layers. Your domain entities and business rules should be pure Swift with no imports of UIKit, SwiftUI, or any infrastructure framework. When domain code defines a repository protocol and the data layer implements it, you can swap your networking stack, database, or caching strategy without touching a single line of business logic. This inversion of control is what makes architecture pay off over time.
Testability is the practical litmus test for good architecture. If writing a unit test for a piece of logic requires launching a simulator, standing up a database, or importing SwiftUI, the architecture has failed. Every layer should be testable in isolation by injecting mock or fake dependencies through initializers. If you cannot test a ViewModel without a real network connection, the boundary between layers is leaking.
Anti-Patterns
-
God ViewModel that does everything: A ViewModel that fetches data, validates forms, formats strings for display, manages navigation, and coordinates multiple repositories has absorbed responsibilities from every layer. Split it into focused use cases, extract formatting into helper types, and delegate navigation to a coordinator or router.
-
Leaking framework types into the domain layer: Importing UIKit to use
UIImagein a domain entity, or referencing SwiftUI's@Publishedin a business rule, couples your core logic to Apple's presentation frameworks. Use plain Swift types in the domain layer and map to framework types at the boundary. -
Premature abstraction with protocols for everything: Creating a protocol for every concrete class "in case we need to swap it later" adds indirection without benefit. Introduce a protocol when you have two concrete implementations (production and test/mock) or a clear architectural boundary. One concrete class behind an unnecessary protocol is just noise.
-
Singleton abuse for stateful services: Using
static let sharedfor repositories, caches, and API clients creates hidden dependencies, makes testing painful, and prevents multiple instances (e.g., per-user scopes). Use constructor injection and let a DI container manage lifetimes. -
Circular dependencies between layers: Service A depends on Service B which depends on Service A. This usually signals a misplaced responsibility. Break the cycle by extracting the shared concern into a third type, introducing a protocol at the boundary, or rethinking which layer owns the responsibility.
Overview
Well-structured iOS apps separate concerns into distinct layers: presentation, domain logic, and data access. The two most common architectural patterns in the Swift ecosystem are MVVM (Model-View-ViewModel) and Clean Architecture. MVVM pairs naturally with SwiftUI's data-binding model, while Clean Architecture adds formal boundaries between use cases, repositories, and data sources. This skill covers both patterns and how to combine them effectively.
Core Concepts
MVVM (Model-View-ViewModel)
MVVM separates UI (View) from presentation logic (ViewModel) and data (Model):
- Model — Plain data types and business rules.
- View — SwiftUI views that render state and forward user actions.
- ViewModel — Transforms model data into view-ready state; handles user intents.
// Model
struct Task: Identifiable, Codable {
let id: UUID
var title: String
var isCompleted: Bool
var dueDate: Date?
}
// ViewModel
@MainActor
class TaskListViewModel: ObservableObject {
@Published private(set) var tasks: [Task] = []
@Published private(set) var isLoading = false
@Published var errorMessage: String?
private let repository: TaskRepository
init(repository: TaskRepository) {
self.repository = repository
}
func loadTasks() async {
isLoading = true
defer { isLoading = false }
do {
tasks = try await repository.fetchAll()
} catch {
errorMessage = error.localizedDescription
}
}
func toggleCompletion(_ task: Task) async {
var updated = task
updated.isCompleted.toggle()
do {
try await repository.update(updated)
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index] = updated
}
} catch {
errorMessage = "Failed to update task"
}
}
func delete(_ task: Task) async {
do {
try await repository.delete(task.id)
tasks.removeAll { $0.id == task.id }
} catch {
errorMessage = "Failed to delete task"
}
}
}
// View
struct TaskListView: View {
@StateObject private var viewModel: TaskListViewModel
init(repository: TaskRepository) {
_viewModel = StateObject(wrappedValue: TaskListViewModel(repository: repository))
}
var body: some View {
List {
ForEach(viewModel.tasks) { task in
TaskRow(task: task) {
Task { await viewModel.toggleCompletion(task) }
}
}
.onDelete { indexSet in
let tasksToDelete = indexSet.map { viewModel.tasks[$0] }
Task {
for task in tasksToDelete {
await viewModel.delete(task)
}
}
}
}
.overlay {
if viewModel.isLoading { ProgressView() }
}
.task { await viewModel.loadTasks() }
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") { viewModel.errorMessage = nil }
} message: {
Text(viewModel.errorMessage ?? "")
}
}
}
Clean Architecture Layers
Clean Architecture organizes code into concentric layers with strict dependency rules — inner layers never depend on outer layers:
┌──────────────────────────────────────┐
│ Presentation (UI) │ Views, ViewModels
├──────────────────────────────────────┤
│ Domain (Business) │ Use Cases, Entities, Repository Protocols
├──────────────────────────────────────┤
│ Data (Infrastructure) │ Repository Impls, API Clients, DB
└──────────────────────────────────────┘
Domain Layer
Contains pure business logic with no framework dependencies:
// Entity
struct Product: Identifiable {
let id: UUID
let name: String
let price: Decimal
let category: Category
let isAvailable: Bool
}
// Repository Protocol (defined in domain, implemented in data)
protocol ProductRepository {
func fetchAll() async throws -> [Product]
func fetch(id: UUID) async throws -> Product
func search(query: String) async throws -> [Product]
func save(_ product: Product) async throws
}
// Use Case
struct FetchProductsUseCase {
private let repository: ProductRepository
init(repository: ProductRepository) {
self.repository = repository
}
func execute(category: Category? = nil) async throws -> [Product] {
let products = try await repository.fetchAll()
if let category {
return products.filter { $0.category == category && $0.isAvailable }
}
return products.filter { $0.isAvailable }
}
}
struct SearchProductsUseCase {
private let repository: ProductRepository
init(repository: ProductRepository) {
self.repository = repository
}
func execute(query: String) async throws -> [Product] {
guard query.count >= 2 else { return [] }
return try await repository.search(query: query)
}
}
Data Layer
Implements repository protocols and manages data sources:
// API Data Transfer Object
struct ProductDTO: Decodable {
let id: String
let name: String
let priceInCents: Int
let categorySlug: String
let available: Bool
func toDomain() -> Product {
Product(
id: UUID(uuidString: id) ?? UUID(),
name: name,
price: Decimal(priceInCents) / 100,
category: Category(slug: categorySlug),
isAvailable: available
)
}
}
// Repository Implementation
class ProductRepositoryImpl: ProductRepository {
private let apiClient: APIClient
private let cache: ProductCache
init(apiClient: APIClient, cache: ProductCache) {
self.apiClient = apiClient
self.cache = cache
}
func fetchAll() async throws -> [Product] {
if let cached = await cache.getAll(), !cached.isEmpty {
return cached
}
let dtos: [ProductDTO] = try await apiClient.send(Endpoint(path: "/products"))
let products = dtos.map { $0.toDomain() }
await cache.store(products)
return products
}
func fetch(id: UUID) async throws -> Product {
let dto: ProductDTO = try await apiClient.send(Endpoint(path: "/products/\(id.uuidString)"))
return dto.toDomain()
}
func search(query: String) async throws -> [Product] {
let dtos: [ProductDTO] = try await apiClient.send(
Endpoint(path: "/products/search", queryItems: [URLQueryItem(name: "q", value: query)])
)
return dtos.map { $0.toDomain() }
}
func save(_ product: Product) async throws {
try await apiClient.sendVoid(
Endpoint(path: "/products/\(product.id)", method: .put, body: product)
)
}
}
Dependency Injection
A simple DI container without third-party libraries:
@MainActor
class DependencyContainer {
static let shared = DependencyContainer()
// Data layer
lazy var apiClient = APIClient(
baseURL: URL(string: "https://api.example.com")!,
tokenProvider: tokenProvider
)
lazy var tokenProvider: TokenProvider = KeychainTokenProvider()
lazy var productCache = ProductCache()
// Repositories
lazy var productRepository: ProductRepository = ProductRepositoryImpl(
apiClient: apiClient,
cache: productCache
)
lazy var userRepository: UserRepository = UserRepositoryImpl(apiClient: apiClient)
// Use cases
func makeFetchProductsUseCase() -> FetchProductsUseCase {
FetchProductsUseCase(repository: productRepository)
}
func makeSearchProductsUseCase() -> SearchProductsUseCase {
SearchProductsUseCase(repository: productRepository)
}
// View models
func makeProductListViewModel() -> ProductListViewModel {
ProductListViewModel(
fetchProducts: makeFetchProductsUseCase(),
searchProducts: makeSearchProductsUseCase()
)
}
}
Implementation Patterns
Feature Module Structure
Organize code by feature rather than by layer for better discoverability:
Sources/
├── App/
│ ├── MyApp.swift
│ └── DependencyContainer.swift
├── Features/
│ ├── Products/
│ │ ├── Domain/
│ │ │ ├── Product.swift
│ │ │ ├── ProductRepository.swift
│ │ │ └── FetchProductsUseCase.swift
│ │ ├── Data/
│ │ │ ├── ProductDTO.swift
│ │ │ └── ProductRepositoryImpl.swift
│ │ └── Presentation/
│ │ ├── ProductListViewModel.swift
│ │ ├── ProductListView.swift
│ │ └── ProductRow.swift
│ └── Auth/
│ ├── Domain/
│ ├── Data/
│ └── Presentation/
└── Shared/
├── Networking/
├── Persistence/
└── UI/
ViewState Pattern
Represent all possible view states explicitly:
enum ViewState<T> {
case idle
case loading
case loaded(T)
case error(String)
var isLoading: Bool {
if case .loading = self { return true }
return false
}
}
@MainActor
class ProductListViewModel: ObservableObject {
@Published private(set) var state: ViewState<[Product]> = .idle
private let fetchProducts: FetchProductsUseCase
init(fetchProducts: FetchProductsUseCase) {
self.fetchProducts = fetchProducts
}
func load() async {
state = .loading
do {
let products = try await fetchProducts.execute()
state = .loaded(products)
} catch {
state = .error(error.localizedDescription)
}
}
}
struct ProductListView: View {
@StateObject private var viewModel: ProductListViewModel
var body: some View {
Group {
switch viewModel.state {
case .idle:
Color.clear
case .loading:
ProgressView("Loading products...")
case .loaded(let products):
List(products) { product in
ProductRow(product: product)
}
case .error(let message):
ContentUnavailableView {
Label("Error", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("Retry") { Task { await viewModel.load() } }
}
}
}
.task { await viewModel.load() }
}
}
Best Practices
- Keep views dumb. Views should read state from the view model and forward actions. No business logic in views.
- Define repository protocols in the domain layer. Concrete implementations live in the data layer. This inverts the dependency so domain never depends on infrastructure.
- Use value types for models and DTOs. Structs are simpler, thread-safe, and encourage immutability.
- Inject dependencies through initializers. Avoid singletons for anything stateful. Constructor injection makes dependencies explicit and enables testing.
- One ViewModel per screen. Shared state should live in a repository or service, not in a view model passed between screens.
- Name use cases as verbs.
FetchProductsUseCase,PlaceOrderUseCase,ValidateEmailUseCase. Each represents one specific action. - Separate DTOs from domain models. API response shapes change independently of your domain. Mapping between them at the data layer boundary isolates the app from backend changes.
Common Pitfalls
- Over-engineering small apps. A to-do app does not need Clean Architecture with use cases and repository protocols. Start with MVVM and add layers only when complexity justifies them.
- Massive ViewModels. If a view model exceeds 200-300 lines, split it. Extract use cases, break the screen into child views with their own view models, or use helper types.
- Leaking UIKit/SwiftUI into the domain layer. Importing UIKit or SwiftUI in domain types creates coupling. Domain entities should be pure Swift.
- Treating MVVM as the only option. Some screens (static settings, simple lists) need no view model at all. Use
@FetchRequestor direct model binding when appropriate. - Circular dependencies in DI. Service A depends on Service B which depends on Service A. Break cycles by introducing a protocol or restructuring responsibilities.
Install this skill directly: skilldb add ios-swift-skills
Related Skills
Combine
Combine framework reactive programming patterns for handling asynchronous events in iOS
Core Data
Core Data persistence framework for modeling, storing, and querying structured data in iOS apps
Navigation
SwiftUI navigation patterns using NavigationStack, programmatic routing, and deep linking
Networking
URLSession networking patterns for building robust API clients and handling data transfer in iOS
Swift Concurrency
Swift structured concurrency with async/await, actors, and task groups for safe concurrent iOS code
Swiftui
SwiftUI declarative UI framework fundamentals for building modern iOS interfaces