§ 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 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: 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 + Component —
Sendableclasses. Construct napkins, satisfy DI requirements. Built once, frozen, passed across actor boundaries. - Interactor —
final actor. Business logic. Holds state. Owns lifecycle. Off the main actor. - Router —
@MainActorclass. Owns the subtree. Attaches/detaches children, calls the view controller. - Presenter / ViewController —
@MainActorclass. The view. AUIHostingControllerwrapping a SwiftUIView, or aUIViewController.
Three of these are @MainActor. One — the interactor — isn't. 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.
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:
- Interactor → Presenter (
actor → @MainActor):await presenter.update(...) - Interactor → Router (
actor → @MainActor):await router?.routeToProfile() - View → Interactor (
@MainActor sync → actor):dispatch { await listener?.didTap() } - Interactor → Listener (parent) (stays in the parent actor):
await listener?.fooDidFinish()
The first three are real isolation crossings. The fourth is a same-actor call — the parent's interactor is also an actor, but the boundary is procedural (child telling parent) not isolation-based.
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 handle cancellation if the view goes away mid-call.
Button("Add to cart") {
dispatch { [listener] in await listener?.didTapAddItem(item) }
}
napkin's dispatch { } helper is a thin wrapper around Task { } that captures the listener weakly and bails if the view is gone before the closure runs. More on it here. Use it instead of inline Task { } in SwiftUI button actions and 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 (a few hundred lines) and the tutorial walks it.