How I Architect SwiftUI Apps for Swift Concurrency – So the Compiler Stays Quiet

Structuring a SwiftUI app has always been about the same goals for me: well-organized code, components that each do one job, and an app that stays maintainable as it grows. I want to find things quickly, change a feature without breaking something else, and use previews without depending on a server.

Swift concurrency added a new problem on top of that, and it’s painful. As soon as you add networking, caching, or authentication, the compiler starts complaining. Sendable errors, main actor isolation errors, data race warnings. You add @MainActor somewhere, and a new error shows up somewhere else. At some point you start wondering: does Swift concurrency even work with SwiftUI? And if it does, how are you supposed to structure your app so it fits?

It does work. In this post, I’ll show you how I structure my SwiftUI apps so that they stay organized and maintainable, and so that SwiftUI and Swift concurrency work together instead of against each other.

Layer architecture in SwiftUI with Swift Concurrency

Concurrency Is Part of Your Architecture

Swift concurrency is built into the type system. Isolation is part of a type, just like its properties and functions. That means every time you decide what goes into a type, you’re also making a concurrency decision, whether you realize it or not.

What decides the isolation a type needs is the state it holds. So when I separate my code into components, I look at their state. There are three kinds I care about: UI state, non-UI state, and no state at all. Each one leads to a different kind of component.

My View Model is for UI State, and Methods to change UI State

UI state is anything your views read to render themselves: the list of products, a loading flag, an error message. Apple gave us @Observable for exactly this. It tracks which properties a view reads and updates the view when they change.

Because SwiftUI renders on the main actor, that state belongs on the main actor too:

@MainActor
@Observable
final class ProductListViewModel {
    var products: [Product] = []
    var isLoading = false
    var errorMessage: String?

    private let service: ProductService

    init(service: ProductService) {
        self.service = service
    }

    func loadProducts() async {
        isLoading = true
        defer { isLoading = false }

        do {
            products = try await service.fetchProducts()
        } catch {
            errorMessage = "Could not load products."
        }
    }
}

Thinking about the view model this way made me rethink what I put into it. My view models used to collect everything: caches, tokens, helper state, anything that didn’t fit elsewhere. Now I follow a simple rule: the view model owns and mutates UI state. Functions that are called fromt the UI e.g. button press, call the view model methods that are @MainActor.

That keeps my view models small, and focused on a clear Separation of Concern.

My Services Are Stateless

A service fetches data, decodes it, maybe transforms it, and returns it. It doesn’t need to remember anything. And when a type has no state, there’s nothing to protect from data races.

That makes services the easiest layer to get right. A service is just a struct:

nonisolated struct RemoteProductService: ProductService {
    @concurrent
    func fetchProducts() async throws -> [Product] {
        let url = URL(string: "https://api.example.com/products")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([Product].self, from: data)
    }
}

It’s nonisolated because it doesn’t belong to any actor. This allows me to move it across actors. For the heavy work, like decoding a large response, I mark the function with @concurrent so it runs in the background instead of blocking the main actor.

The view model calls it with await, the work happens off the main actor, and the result comes back to the view model, which assigns it to its UI state. One clear crossing point, and the compiler checks it for me.

Heavy Work Inside a View Model

Sometimes part of a view model function does heavy work. Filtering and sorting a large list, for example, or processing an image. The view model is on the main actor, so that work would block the UI.

In that case, I pull the heavy part out into its own function and mark it @concurrent:

@MainActor
@Observable
final class ProductListViewModel {
    var allProducts: [Product] = []
    var filteredProducts: [Product] = []

    func search(for query: String) async {
        filteredProducts = await filter(allProducts, matching: query)
    }

    @concurrent
    private func filter(_ products: [Product], matching query: String) async -> [Product] {
        products
            .filter { $0.name.localizedCaseInsensitiveContains(query) }
            .sorted { $0.name < $1.name }
    }
}

The @concurrent function runs in the background, so it can’t touch the view model’s state directly. Everything it needs comes in as parameters, and the result goes back as a return value. The main actor function passes the data in, awaits the result, and assigns it to the UI state.

It’s the same pattern as with services: the state stays on the main actor, and only Sendable values cross over to the background and back.

If you notice that you’re writing a lot of these functions in one view model, that’s usually a sign the work belongs in a service instead.

Protocols and Sendable

Sendable errors are really annoying. They show up in places you don’t expect, and the messages don’t always tell you what’s actually wrong.

Most of my Sendable errors came from services that were classes with some state hidden inside: a cached response, a counter, a mutable configuration. The moment such a service was stored in a main actor view model and called across isolation boundaries, the compiler complained. And it was right, because that state really could be accessed from two places at once.

With a stateless struct, the problem disappears. A struct without mutable state is Sendable naturally, so I put the requirement right into the protocol:

nonisolated protocol ProductService: Sendable {
    func fetchProducts() async throws -> [Product]
}

Every service I inject is now guaranteed to be safe to pass around. And the protocol gives me something I use all the time: I can swap the real service for a mock in previews and tests.

nonisolated struct MockProductService: ProductService {
    func fetchProducts() async throws -> [Product] {
        [Product(id: UUID(), name: "Preview Product", price: 9.99)]
    }
}

#Preview {
    ProductListView(viewModel: ProductListViewModel(service: MockProductService()))
}

No server, no network, no Sendable errors. Just data in my preview.


Models: What Crosses From the Background to the Main Actor

Data travels from the service, which runs in the background, to the view model on the main actor. Whatever crosses that boundary has to be Sendable.

The Simple Way: A Struct

In most cases, I just use a struct for my model:

nonisolated struct Product: Identifiable, Codable, Sendable {
    let id: String
    let name: String
    let price: Decimal
}

The service decodes it in the background and returns it, and the view model assigns it to its UI state. A struct with immutable properties is Sendable, so it crosses the boundary without complaints.

For many apps, this is all you need.

When the UI Modifies the Data: DTOs

Sometimes a struct isn’t enough. The user can rename a product or mark it as a favorite, and the views should update right away. For that, you want an @Observable class:

@MainActor
@Observable
final class Product: Identifiable {
    let id: String
    var name: String
    var isFavorite: Bool

    init(dto: ProductDTO) {
        id = dto.id
        name = dto.name
        isFavorite = dto.isFavorite
    }
}

This class is UI state, so it’s isolated to the main actor. That means the service can’t create it in the background. Instead, the service returns a DTO, a data transfer object, which is a simple Sendable struct:

nonisolated struct ProductDTO: Codable, Sendable {
    let id: String
    let name: String
    let isFavorite: Bool
}
nonisolated struct RemoteProductService: ProductService {
    @concurrent
    func fetchProducts() async throws -> [ProductDTO] {
        let url = URL(string: "https://api.example.com/products")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([ProductDTO].self, from: data)
    }
}

The DTO crosses the boundary, and the view model turns it into the observable model on the main actor:

func loadProducts() async {
    do {
        let dtos = try await service.fetchProducts()
        products = dtos.map(Product.init)
    } catch {
        errorMessage = "Could not load products."
    }
}

So the work is split by isolation. Fetching and decoding happen in the background and produce Sendable DTOs. Creating the observable models happens on the main actor, where they’re used as UI state. When you save changes, it goes the other way: the view model creates a DTO from the model and hands it to the service.

State the UI Never Sees Gets Its Own Actor

Some state isn’t UI state, but it’s still state. My favorite example is a token store. It holds the authentication token, refreshes it when it expires, and hands it to the network layer. No view ever displays the token.

So it doesn’t belong in the view model, and it doesn’t belong on the main actor. But it’s shared mutable state: several requests might ask for the token at the same time, and two of them might try to refresh it at once. That’s what actors are for:

actor TokenStore {
    private var token: String?
    private var refreshTask: Task<String, Error>?

    func validToken() async throws -> String {
        if let token { return token }

        if let refreshTask {
            return try await refreshTask.value
        }

        let task = Task { try await refreshToken() }
        refreshTask = task
        defer { refreshTask = nil }

        let newToken = try await task.value
        token = newToken
        return newToken
    }

    private func refreshToken() async throws -> String {
        // Call your auth endpoint here
        "new-token"
    }
}

The actor protects its state. If several requests ask for a token while a refresh is running, they all wait for the same refresh instead of starting their own. That kind of bug is really hard to find at runtime, and here the structure prevents it.

My Cheat Sheet

This is how it all comes together:

StateLayerIsolation
UI stateView, view model, coordinator@MainActor
Non-UI stateToken store, cacheIts own actor
No stateServiceNonisolated struct, @concurrent functions for heavy work
Data passed between layersModelSendable value types

When I create a new type, I check what state it holds. That tells me which layer it belongs to and how it’s isolated, so I don’t have to fix concurrency errors afterward.

A Note on Project Settings

The code in this post assumes a new Xcode 26 project, where default main actor isolation is turned on. In that setup, every type is on the main actor unless you opt out, which is why the services, protocols, and models are marked nonisolated. The view model is main actor either way, but I keep the explicit @MainActor so the intent is clear.

If your project doesn’t use default main actor isolation, it’s the other way around. You don’t need the nonisolated markers, but you do need to add @MainActor to your view models yourself. @Observable doesn’t do that for you.

My Advice

If you’re fighting Swift concurrency right now, here’s what I’d recommend:

  • Before you create a type, look at its state. UI state, non-UI state, or no state. That tells you where it belongs.
  • Keep your view models for UI state. If the UI doesn’t read it, move it out.
  • Make services stateless whenever you can. A stateless struct is the easiest thing to make Sendable.
  • Use protocols that require Sendable. You get safe injection and easy mocks for previews and tests.
  • Make your models value types. They cross every boundary without complaints.
  • Give shared non-UI state its own actor. Don’t hide it in a view model or a singleton.
  • When you hit a Sendable error, check where your state lives before adding annotations. Most of the time, the error is pointing at your structure.

The compiler is showing you exactly where your state is in the wrong place. Once your app is structured around state, it mostly stays quiet.


Further Reading

If you want to go deeper into how to separate your SwiftUI code, from views and view models to services and modules, check out my course.

Apple’s WWDC25 sessions

  • Explore concurrency in SwiftUI. A good starting point if you’re wondering how SwiftUI and Swift concurrency fit together. It shows how SwiftUI runs on the main actor by default and hands work off to other actors.
  • Embracing Swift concurrency. The best overview of the core concepts. It walks through taking an app from single-threaded to concurrent, with chapters on value types, actor-isolated types, and actors that match the layers in this post.
  • Code-along: Elevate an app with Swift concurrency. A hands-on session that starts with a main-actor app, moves work to the background, and works through fixing data-race safety errors. It also covers the approachable concurrency settings.

Swift documentation

Swift Evolution proposals

For the details behind the code in this post:

Leave a Comment

Subscribe to My Newsletter

Want the latest iOS development trends and insights delivered to your inbox? Subscribe to our newsletter now!

Newsletter Form