§ 03 · 2026 · 05 · 12 · Concurrency

Actor isolation for iOS app architecture in Swift 6

Swift 6 makes you declare where every piece of code runs. That's an architecture decision in disguise — and most apps haven't made it explicitly before.

When the Swift 6 strict-concurrency warnings first lit up your codebase, the most common reaction was to silence them: scatter @MainActor annotations, wrap suspect blocks in Task { @MainActor in ... }, mark a few types @unchecked Sendable. The errors go away. The architecture quietly shifts.

That shift matters. Once @MainActor appears on a type, every isolated method on that type runs on UIKit's main scheduler. If the type is a "view model" with networking code, your networking code now blocks the UI thread until the next await. Tap a button, get a frame drop.

Most production iOS architectures (MVVM, MVC, VIPER) predate actors. They were designed when threading was an implementation detail. Swift 6 promotes it to an interface concern: in the Swift 6 language mode, the compiler refuses to compile code unless you've decided where each method runs. The right response isn't to silence the warnings — it's to design with isolation as a first-class layer.

The four rings

napkin uses a deliberate four-domain isolation map. Every type sits in exactly one of these, and crossings between them happen at named, explicit boundaries.

  • Builder + ComponentSendable classes. Construct napkins, satisfy DI requirements. Built once, frozen, passed across actor boundaries.
  • Interactorfinal actor. Business logic. Holds state. Owns lifecycle. Off the main actor.
  • Router@MainActor class. Owns the subtree. Attaches/detaches children, calls the view controller.
  • Presenter / ViewController@MainActor class. The view. A UIHostingController wrapping a SwiftUI View, or a UIViewController.

Two of these rings are @MainActor — the router and the view layer. The interactor isn't, and builders and components are nonisolated Sendable values that travel anywhere. That's the architectural lever.

Why business logic goes off the main actor

The interactor holds the unit's state and runs its decisions: "did the user complete onboarding," "is the cart valid," "what should happen when the network call returns." None of that needs to run on the UI thread. Putting it there is a category error.

An actor is the cheapest way to get state that's safe to access concurrently. The actor's executor serializes calls automatically; you don't write locks. Reading self.user from inside an actor is a normal property read. Reading it from outside requires await, which the compiler enforces.

CartInteractor.swift
final actor CartInteractor: PresentableInteractable {
    nonisolated let lifecycle = InteractorLifecycle()
    nonisolated let presenter: CartPresentable

    private var items: [CartItem] = []
    private var couponCode: String?

    func didTapAddItem(_ item: CartItem) async {
        items.append(item)
        await presenter.update(items: items)  // hops to @MainActor
    }
}

The state lives on the actor. The presenter call is an explicit await because the presenter is @MainActor. You can see the crossing in the source.

The four crossings

Once you accept that the interactor is one domain and the view/router are another, you have a small fixed set of crossing patterns:

  1. Interactor → Presenter (actor → @MainActor): await presenter.update(...)
  2. Interactor → Router (actor → @MainActor): await router?.routeToProfile()
  3. View → Interactor (@MainActor sync → actor): dispatch { await listener?.didTap() }
  4. Interactor → Listener (parent) (actor → parent's actor): await listener?.fooDidFinish()

All four are real isolation crossings — child and parent interactors are distinct actor instances. What distinguishes the fourth is direction: the child reporting up through its listener contract rather than driving the UI.

The full pattern reference is in the docs. The point is that there's a fixed, small number of these, and you can name them. It's easier to reason about an architecture where every cross-actor call falls into one of four shapes than one where each is invented per type.

Where SwiftUI fits

SwiftUI views are implicitly @MainActor. Button action closures are @MainActor synchronous. The interactor is actor. To call from one to the other you need a Task — and a way to make the call a harmless no-op if the interactor is gone by the time it runs.

CartView.swift
Button("Add to cart") {
    dispatch { [listener] in await listener?.didTapAddItem(item) }
}

napkin's dispatch { } helper is a thin @MainActor wrapper around Task { } — nothing more. The safety is in the [listener] capture: the view's listener is weak, so if the interactor is gone before the closure runs, the call is a no-op. More on it here. dispatch doesn't do the catching itself; it just makes that capture pattern the standard shape at every call site, which is how you avoid a whole class of "method called on deallocated interactor" bugs.

What changes for you

If you're moving an existing codebase to Swift 6, the most useful exercise is to sit with one feature and ask: which methods belong on the main actor, and which don't? Once you've named the boundary, the @MainActor annotations and awaits land where they should. Once you've named the actor, the state moves there too.

For new code, napkin offers a default answer: business logic in final actor interactors, view/routing in @MainActor, DI in Sendable classes. You can disagree with the specifics, but you do have to decide. Swift 6 doesn't let you not decide.

Want to see the rings in a working app? The Rib House example is end-to-end (about 1,700 lines) and the tutorial walks it.