Skip to main content
  1. Blog/
  2. Swift/

Isolating Domain Workflows in Swift with @globalActor

Swift actors protect isolated state. Custom global actors let several collaborating types share one domain isolation boundary.

The @MainActor Convenience Trap
#

If you’ve used Swift Concurrency in an iOS app, you’ve used @MainActor. UIKit and SwiftUI need UI state touched on the main thread, so marking your view models with @MainActor is exactly right.

The trouble starts when the app grows and the compiler begins complaining about types that talk to those view models. The quickest way to silence the warnings is to keep spreading the annotation:

swift
@MainActor
final class PaymentService { }

@MainActor
final class OrderRepository { }

@MainActor
final class InventoryManager { }

The warnings disappear and the code compiles. But look at what this actually says: it says payment processing, order storage, and inventory tracking all belong to the UI thread. None of them have anything to do with the UI. We picked @MainActor not because it was the right home for this code, but because it was the annotation we already knew.

To find the right home, we need to back up and ask what actors are really protecting.

Step 1: A plain actor protects one object
#

The simplest case first. When one object owns some mutable state and multiple tasks might touch it, a plain actor is the answer:

swift
actor ImageCache {
    private var cache: [URL: UIImage] = [:]

    func image(for url: URL) -> UIImage? {
        cache[url]
    }

    func store(_ image: UIImage, for url: URL) {
        cache[url] = image
    }
}

The dictionary lives inside the actor, and the actor lets only one task in at a time. No data races, done. If your problem looks like this — one object, one box of state — a plain actor is all you need.

But some bugs don’t live inside one object. They live between objects.

Step 2: The bug that lives between objects
#

Say we’re building a wallet feature. Two small types, each responsible for one thing:

swift
final class BalanceStore {
    // Keeps track of how much money each account has
}

final class LedgerRepository {
    // Records every transfer, for history and auditing
}

A transfer touches both: check the balance, subtract the money, add it on the other side, write a ledger entry.

Now here’s the bug. Your account has $1,000, and two transfers of $800 start at almost the same moment:

text
Transfer A: check balance → sees $1,000 → OK, proceed
Transfer B: check balance → sees $1,000 → OK, proceed
Transfer A: subtract $800 → balance is $200
Transfer B: subtract $800 → balance is −$600 

Both transfers checked the balance before either one subtracted. Both saw enough money. Both went ahead. The account is now negative.

Notice what didn’t cause this bug. It’s not a data race — you could make BalanceStore a thread-safe actor and LedgerRepository a thread-safe actor, and the exact same interleaving is still possible. Each individual read and write was perfectly safe. What broke was the rule: “a balance check and the subtraction that follows it must happen together, with nothing sneaking in between.”

That rule doesn’t belong to BalanceStore alone or LedgerRepository alone. It belongs to the wallet as a whole. And a plain actor has no way to say that, because a plain actor only guards its own state.

Step 3: Sharing one actor across types with @globalActor
#

This is exactly what a custom global actor is for. Defining one takes a few lines:

swift
@globalActor
actor WalletActor {
    static let shared = WalletActor()

    private init() { }
}

That’s the whole definition. WalletActor doesn’t hold any wallet data itself — think of it as a named lock, or a room that only one task can be inside at a time.

The interesting part is what you do with it. You can now put several types in that room:

swift
@WalletActor
final class BalanceStore {
    private var balances: [AccountID: Decimal] = [:]

    func availableBalance(for account: AccountID) -> Decimal {
        balances[account] ?? 0
    }

    func debit(_ amount: Decimal, from account: AccountID) throws { /* ... */ }
    func credit(_ amount: Decimal, to account: AccountID) throws { /* ... */ }
}

@WalletActor
final class LedgerRepository {
    func recordTransfer(_ amount: Decimal, from: AccountID, to: AccountID) throws { /* ... */ }
}

@WalletActor
final class WalletService {
    private let balances: BalanceStore
    private let ledger: LedgerRepository

    init(balances: BalanceStore, ledger: LedgerRepository) {
        self.balances = balances
        self.ledger = ledger
    }

    func transfer(_ amount: Decimal, from sender: AccountID, to recipient: AccountID) throws {
        guard balances.availableBalance(for: sender) >= amount else {
            throw WalletError.insufficientFunds
        }
        try balances.debit(amount, from: sender)
        try balances.credit(amount, to: recipient)
        try ledger.recordTransfer(amount, from: sender, to: recipient)
    }
}

Two things just happened, and they’re worth pausing on.

First, look at transfer. It calls into BalanceStore and LedgerRepository with no await — plain synchronous calls. That’s because all three types live on the same actor. They’re in the same room, so they can talk to each other directly. The whole check-debit-credit-record sequence runs as one uninterrupted block. The double-spend bug from Step 2 is gone: a second transfer can’t even start until the first one has finished.

Second, any code outside WalletActor that wants to call in must use await. The compiler forces it. So the boundary of your wallet domain is no longer a comment or a convention — it’s visible in the code, and the compiler checks it on every call.

Compare that with what we had before. @MainActor on these types said “this runs on the UI thread” — true but meaningless for a wallet. @WalletActor says “these types change wallet state, and wallet state changes one at a time.” That’s the actual rule we were trying to protect.

Why not just make each type its own actor?
#

A fair question. This also compiles:

swift
actor BalanceStore { }
actor LedgerRepository { }
actor WalletService { }

But now each type is its own separate room, and a transfer has to walk between them:

swift
let balance = await balanceStore.availableBalance(for: sender)  // room 1
guard balance >= amount else { throw WalletError.insufficientFunds }
try await balanceStore.debit(amount, from: sender)              // room 1 again
try await balanceStore.credit(amount, to: recipient)
try await ledgerRepository.recordTransfer(amount, from: sender, to: recipient)  // room 2

Every one of those awaits is a doorway where another task can slip in. Between the balance check on line 1 and the debit on line 3, a second transfer can check the same balance — and we’re right back to the −$600 bug, except now everything is technically actor-isolated, so it’s harder to spot.

Separate actors are the right choice when types are genuinely independent — an image cache and an analytics logger don’t care about each other’s ordering, so let them run in parallel. The question to ask before adding another actor is:

Does this type stand alone, or is it part of a team that must change state together?

Independent → own actor. Part of a team → share the team’s global actor.

The one trap: await inside the actor
#

There’s an important limitation to understand, or the global actor will bite you the same way separate actors did.

Swift actors are reentrant: while a function is suspended at an await, the actor doesn’t sit idle — it lets other waiting work run. So if you put an await in the middle of your transfer, you’ve reopened the door:

swift
@WalletActor
func transfer(_ amount: Decimal, from sender: AccountID, to recipient: AccountID) async throws {
    guard balances.availableBalance(for: sender) >= amount else {
        throw WalletError.insufficientFunds
    }

    // Suspension point — other WalletActor work can run while we wait
    try await fraudChecker.approve(amount, from: sender, to: recipient)

    // By the time we get here, the balance we checked above may be stale
    try balances.debit(amount, from: sender)
}

The fraud check is a network call. While we wait for it, another transfer can run on WalletActor, check the same balance, and debit it. Our balance check is now based on old information.

The fix is a simple rule: do the slow waiting outside, then step into the actor only for the quick state change.

swift
// Runs anywhere — no wallet state touched yet
func performTransfer(_ amount: Decimal, from sender: AccountID, to recipient: AccountID) async throws {
    // Slow part first: talk to the fraud service
    let approval = try await fraudChecker.approve(amount, from: sender, to: recipient)

    // Then the fast part: one synchronous block on WalletActor
    try await walletService.applyApprovedTransfer(approval, amount: amount, from: sender, to: recipient)
}

@WalletActor
final class WalletService {
    func applyApprovedTransfer(
        _ approval: TransferApproval,
        amount: Decimal,
        from sender: AccountID,
        to recipient: AccountID
    ) throws {
        // No awaits in here — this runs start to finish with nothing in between
        try approval.requireApproved()
        guard balances.availableBalance(for: sender) >= amount else {
            throw WalletError.insufficientFunds
        }
        try balances.debit(amount, from: sender)
        try balances.credit(amount, to: recipient)
        try ledger.recordTransfer(amount, from: sender, to: recipient)
    }
}

Inside applyApprovedTransfer there are no suspension points, so the check and the debit can’t be separated. The balance we verify is the balance we spend.

Worth saying plainly: a global actor is not a database transaction. It serializes access, but it won’t roll back a half-finished transfer if the credit throws after the debit succeeded. If you need rollback, retries, or idempotency, you still have to build them.

The architecture angle
#

Step back from the wallet for a moment, and you’ll notice the actors have been drawing an architecture diagram this whole time. Most apps already think in layers; global actors let the compiler see those layers:

text
UI layer              @MainActor      view models, UI state
Domain layer          @WalletActor    wallet rules and wallet state
Infrastructure layer  (no actor)      network clients, fraud API, disk

Each row earns its annotation for a different reason. The UI layer is on @MainActor because the frameworks demand it. The domain layer is on @WalletActor because its correctness demands it — that’s the rule from Step 2. And the infrastructure layer needs no actor at all: a network client doesn’t hold state worth guarding, it just does slow work. That’s the same division as the reentrancy fix earlier — slow, stateless work stays outside the actor by design, not by accident.

Here’s how the layers meet in code:

swift
@MainActor
final class WalletViewModel: ObservableObject {
    private let walletService: WalletService

    func sendTapped(amount: Decimal, recipient: AccountID) {
        Task {
            // Crossing from the UI layer into the wallet domain —
            // the compiler requires this await, and that's the point
            try await walletService.transfer(amount, from: currentAccount, to: recipient)
        }
    }
}

The await marks exactly where UI code hands off to the wallet domain. A reviewer reading this knows the transfer runs off the main thread, serialized with every other wallet operation, without reading any other file.

This is what makes global actors architecturally interesting: the boundary is enforced, not just documented. Layer rules usually live in a wiki page or a code review comment — “don’t touch wallet state from the UI layer” — and they erode because nothing checks them. Put the wallet types on @WalletActor and that rule becomes a compile error. If the wallet code later moves into its own module, say WalletKit, the annotation travels with its public API, so every call site in every feature is held to the same contract for free.

One warning before this goes too far. It’s tempting to look at that layer table and reach for a single AppActor that hosts all business logic. Don’t — that’s the @MainActor trap from the top of this post, rebuilt one level down. Unrelated features would queue behind each other: a wallet transfer waiting on a media import that has nothing to do with it. A global actor earns its existence by protecting one rule. The wallet gets WalletActor because transfers must not interleave; if checkout has its own must-happen-together rule, it gets its own CheckoutActor, and the two run freely in parallel. Most apps end up needing only one or two — and if you can’t name the rule an actor protects, that’s a sign it shouldn’t exist.

Quick guide: which tool when
#

  • One object owns the state → plain actor. (The image cache.)
  • The code updates UI@MainActor. (The view model.)
  • Several types must change state as a team, in order → a custom @globalActor. (The wallet.)
  • Types are independent and can run in parallel → separate actors. Don’t chain them into one workflow.

And one habit that keeps global actors honest: name them after the rule they protect — WalletActor, CheckoutActor, SyncActor. A name like NetworkActor or HelperActor usually means the actor was added to silence a Sendable warning, not to protect anything — which hides the ownership question instead of answering it.

The takeaway
#

Actors aren’t just a thread-safety tool — they’re how you tell the compiler who owns what.

@MainActor says “the UI owns this.” A plain actor says “this object owns its own state.” A custom @globalActor says “these types share one rule, and they change state one at a time to protect it.”

So the design question is never “how many actors should my app have?” It’s the question we asked about the wallet: what has to happen together, and what’s free to run on its own? Answer that, and the annotations write themselves.