Swift Concurrency Architecture: Patterns and Best Practices

Published on Β· Updated Β· 29 min

Wlad
Wlad
Founder & Swift Tech Lead

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:

LayerResponsibilityExamples

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

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?

SituationRecommendation

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

LayerRecommended 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

Official Resources