Swift Concurrency Architecture: Patterns and Best Practices
Published on Β· Updated Β· 29 min
Swift Concurrency profoundly transforms how we architect iOS applications. Classic patterns like MVVM, Clean Architecture, and Repository need to be rethought to leverage actors, isolation, and Sendable. This comprehensive guide covers fundamental principles, implementation patterns, pitfalls to avoid, and best practices for building robust and maintainable applications.
Fundamental Principles
Before diving into patterns specific to Swift Concurrency, let's review essential architectural principles.
Separation of Responsibilities
A healthy architecture clearly separates responsibilities:
| Layer | Responsibility | Examples |
|---|---|---|
Presentation | Display and user interactions | View, ViewController, ViewModel |
Domain | Business logic | UseCase, Interactor, Entity |
Data | Data access | Repository, DataSource, API Client |
This separation allows testing each layer independently and modifying one layer without impacting others.
Dependency Inversion Principle
Upper layers (domain) don't depend on lower layers (data). We use protocols to invert dependencies:
Unidirectional Data Flow
Data flow is unidirectional: actions flow down, state flows up.
Architectural Layers with Swift Concurrency
Presentation Layer: ViewModel
The ViewModel is the bridge between the View and business logic. With Swift Concurrency, it must be isolated to the MainActor:
Why @MainActor on the ViewModel?
The ViewModel receives signals from the View (isolated to MainActor) and publishes changes to the View. Isolating it to MainActor:
- Guarantees state updates are thread-safe
- Eliminates concurrency warnings
- Makes the ViewModel automatically Sendable
- Simplifies code by avoiding
await MainActor.run
- Simplifies code by avoiding
Domain Layer: UseCase / Interactor
UseCases encapsulate business logic. They are generally nonisolated (actor-agnostic) and Sendable:
Why struct and Sendable?
UseCases have no mutable state. Using a struct:
- Makes them automatically Sendable if all properties are
- Encourages immutability
- Facilitates testing (no shared state)
Data Layer: Repository
The Repository abstracts data access. It can be implemented as an actor if state is shared:
When to Use an Actor for Repository?
| Situation | Recommendation |
|---|---|
No shared state | struct or simple class |
In-memory cache | actor |
Concurrent database access | actor |
Simple API wrapper | Sendable struct |
DataSource: API Client
The API Client handles network calls. It generally doesn't need to be an actor:
The Reentrancy Problem
Reentrancy is the main pitfall of actors. Understanding this problem is crucial for robust architecture.
What is Reentrancy?
When an actor suspends its execution (await), other tasks can execute on that actor before the first one resumes:
Reentrancy Solutions
Solution 1: Validate State AFTER Suspension
Solution 2: Synchronous Mutations Only
Solution 3: Request Coalescing
To avoid duplicate network calls:
Advanced Architectural Patterns
Pattern: Authentication Service
Authentication is a classic case where reentrancy poses problems:
Pattern: Coordinator with Navigation
Pattern: Offline-First Repository
Pattern: State Machine with Actor
Dependency Injection
Dependency Container
Protocol Witness with Sendable
For a more functional and testable approach:
Testability
Testing a ViewModel
Testing an Actor
Risks and Pitfalls to Avoid
1. Deadlock with Cooperative Thread Pool
Never block a thread from the cooperative pool:
2. Forgetting @MainActor on ViewModel
3. Continuation Never Called
4. Ignoring Cancellation
5. Sendable Not Respected
Migrating from GCD/Combine
From GCD to async/await
From Combine to AsyncSequence
Best Practices Summary
Layer Isolation
| Layer | Recommended Isolation |
|---|---|
View | @MainActor (automatic with SwiftUI) |
ViewModel | @MainActor |
UseCase | nonisolated (Sendable struct) |
Repository | actor (if shared state) or Sendable struct |
DataSource | Sendable struct |
Architecture Checklist
ViewModel isolated to @MainActor
Stateless and Sendable UseCases
Repositories with actor if cache/shared state
Protocols for dependency injection
Reentrancy handling in actors
Cancellation checking in loops
No blocking work on the cooperative thread pool
Unit tests with Sendable mocks
Going Further
A well-designed architecture with Swift Concurrency offers:
- Safety: data race elimination at compile time
- Maintainability: clear code with predictable data flow
- Testability: isolated and mockable layers
- Performance: efficient parallelism without overhead