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
#

Most iOS developers first use Swift Concurrency through @MainActor. UIKit and SwiftUI require UI state on the main actor, so annotating view models and UI-facing state is correct.

That boundary is easy to overextend. A custom global actor is not for every piece of business logic. It is for a domain workflow whose correctness depends on several collaborators using one ordered consistency boundary.

As the app grows, @MainActor can start appearing in places that have nothing to do with UI correctness:

swift
@MainActor
final class PaymentService { }

@MainActor
final class OrderRepository { }

@MainActor
final class InventoryManager { }

@MainActor
final class WalletService { }

These annotations remove cross-actor warnings, but they also make the UI actor responsible for domain workflow ordering.

The code compiles, but it names the wrong boundary. MainActor says, “this code belongs to the UI execution context.” It does not say, “these domain operations must be ordered because they protect the same domain invariant.”

Those are different boundaries.

Actors Are Ownership Boundaries
#

Actors are excellent when one object owns mutable state and that state needs serialized access.

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
    }
}

Use a plain actor when one type owns the mutable state.

Other bugs are about coordination, not one object’s state. Several thread-safe components can interleave in an order that violates a domain invariant.

Thread safety protects memory access. Domain consistency keeps related state transitions in the required order.

The Boundary Is Often Larger Than One Object
#

Consider a wallet transfer. It spans several steps:

text
Validate sender
      |
Verify balance
      |
Debit account
      |
Credit recipient
      |
Record ledger entry
      |
Send receipt

Even if each participating type is thread-safe, from the balance store to the ledger writer to the notification sender, the transfer can still be wrong.

For example, two transfers can observe the same available balance before either one records a debit. A refund can race with a hold release. A ledger entry can be written in an order that makes reconciliation ambiguous.

The issue is a domain invariant that spans multiple collaborators:

  • A balance check and debit must be based on the same decision.
  • Debit, credit, and ledger entry must be applied as one ordered transition.
  • User-facing effects, such as receipts, must not duplicate financial effects.
  • Observers should not see a partially applied transfer.

When the invariant crosses object boundaries, isolating only one object is not enough.

What @globalActor Gives You
#

Swift lets you define a custom global actor:

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

    private init() { }
}

You can then attach that actor to declarations that participate in the same domain boundary:

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

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

    func applyApprovedTransfer(
        amount: Decimal,
        from sender: AccountID,
        to recipient: AccountID
    ) throws {
        try balances.ensureAvailableBalance(amount, for: sender)
        try balances.debit(amount, from: sender)
        try balances.credit(amount, to: recipient)
        try ledger.recordTransfer(amount, from: sender, to: recipient)
    }
}

@WalletActor
final class LedgerRepository { }

@WalletActor
final class BalanceStore { }

These collaborators now share the same isolation domain. Calls from outside that domain must cross it explicitly, so the compiler exposes where domain serialization begins.

A custom global actor is not dependency injection, a singleton service, or a replacement for application architecture. It is a named concurrency boundary. It lets the type system say:

These declarations are part of the same consistency model.

That says more than “run this on the main thread” and less than “this is a transaction.”

Why More Actors Can Make the Model Worse
#

A common reaction is to make every service its own actor:

swift
actor WalletService { }
actor LedgerRepository { }
actor FraudDetector { }
actor AuditLogger { }

This is not inherently wrong. Separate actors are exactly right when the types own independent state and can make progress independently.

But if these types are all participating in the same serialized workflow, the model becomes fragmented. A transfer becomes a chain of actor hops:

swift
try await walletService.validateTransfer()
try await fraudDetector.approveTransfer()
try await ledgerRepository.recordTransfer()
try await auditLogger.logTransfer()

Actor hops are not the main concern. The larger issue is ownership: the invariant is now spread across several independently isolated execution contexts, so the caller must preserve the correct ordering.

Before adding another actor, ask a question:

Is this truly a separate ownership boundary, or is it another participant in the same consistency boundary?

If they participate in the same consistency boundary, one shared global actor expresses that boundary directly.

Caveat: A Global Actor Is Not a Transaction
#

A custom global actor gives related declarations one isolation boundary. It does not make the workflow behave like a database transaction.

Swift actors are reentrant. If actor-isolated code suspends at an await, other work isolated to the same actor may run before the original function resumes. That makes this shape risky:

swift
@WalletActor
func transfer(
    amount: Decimal,
    from sender: AccountID,
    to recipient: AccountID
) async throws {
    try balances.ensureAvailableBalance(amount, for: sender)

    try await fraudClient.approveTransfer(amount, from: sender, to: recipient)

    try balances.debit(amount, from: sender)
    try balances.credit(amount, to: recipient)
    try ledger.recordTransfer(amount, from: sender, to: recipient)
}

The balance check happened before a suspension point. By the time execution resumes, that assumption may be stale.

Keep external I/O outside the critical domain mutation. Then enter the global actor for the short, ordered state transition:

swift
func transfer(
    amount: Decimal,
    from sender: AccountID,
    to recipient: AccountID
) async throws {
    let approval = try await fraudClient.approveTransfer(
        amount,
        from: sender,
        to: recipient
    )

    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 {
        try approval.requireApproved()
        try balances.ensureAvailableBalance(amount, for: sender)
        try balances.debit(amount, from: sender)
        try balances.credit(amount, to: recipient)
        try ledger.recordTransfer(amount, from: sender, to: recipient)
    }
}

The global actor makes the domain boundary visible to the compiler. You still have to model transactions, idempotency, retries, and partial failures where the workflow needs them.

When a Custom Global Actor Is a Good Fit
#

A custom global actor is a good fit when:

  • Several concrete types participate in one domain workflow.
  • Correctness depends on ordered mutation across those types.
  • The boundary is domain-specific and useful to show in the type system.
  • The isolated work is short and does not wait on external systems.

Good candidates include wallet mutation, checkout state, inventory reservation, local document editing, offline sync state, media library mutation, and coordinated persistence layers.

Name the actor after the invariant, not an implementation category. WalletActor, CheckoutActor, and DocumentModelActor are useful names. NetworkActor or ManagerActor usually signal that the boundary is not yet understood.

When It Is the Wrong Tool
#

Do not create a global actor just to silence Sendable warnings or avoid thinking about ownership. That hides the ownership problem instead of modeling it.

A custom global actor is also not the right answer when:

  • One object owns the mutable state. Use an actor.
  • The work must run on the UI execution context. Use @MainActor.
  • The invariant is stored in a database. Use the database’s transaction model.
  • Independent services should progress concurrently. Keep them independently isolated.
  • The code performs long-running I/O inside the isolated section. Move that work outside or split the workflow.

Global actors are broad annotations. Keep them rare, narrow, and domain-named.

How This Changes the Architecture
#

UI code no longer has to own domain serialization.

swift
@MainActor
final class WalletViewModel: ObservableObject {
    private let transferCoordinator: TransferCoordinator

    init(transferCoordinator: TransferCoordinator) {
        self.transferCoordinator = transferCoordinator
    }

    func sendTapped(amount: Decimal, recipient: AccountID) {
        Task {
            try await transferCoordinator.transfer(
                amount: amount,
                from: currentAccount,
                to: recipient
            )
        }
    }
}

The view model stays on MainActor because it owns UI state. The transfer workflow crosses into WalletActor because it owns wallet consistency.

That separation gives reviewers, maintainers, and the compiler two different checks:

  • UI state must be observed and mutated from the main actor.
  • Wallet mutations must be ordered with other wallet mutations.

With those boundaries separated, domain work can move off the main actor without losing serialization. The code also shows where concurrency boundaries are crossed and where parallelism is allowed.

Final Thought
#

Actors are not just a thread-safety feature. They also describe ownership.

@MainActor owns UI execution. A custom @globalActor owns a named domain consistency boundary.

The design question is not “How many actors should this app have?” It is “What must be serialized together, and what can run concurrently?”