Beyond Thread Safety - Actor-Centered Domain Modeling for Robust iOS Apps

Swift actors are usually introduced to iOS developers as a concurrency primitive: a safer replacement for locks that protects mutable state. That framing is correct, but incomplete. In practice, treating actors merely as “thread-safe boxes” leaves most of their power untapped.

I argue for a stronger position: actors should be first-class domain units. When you move business invariants, state transitions, and decision-making inside actors, you get systems that are easier to reason about, harder to misuse, and more resilient to change. This approach, Actor-Based Domain Modeling, can dramatically improve correctness and reduce cognitive load in iOS projects.


Actors Are Usually Underused

In many codebases, actors look like this:

actor UserCache {
private var users: [UserID: User] = [:]
  func getUser(id: UserID) -> User? {
    users[id]
  }

  func setUser(_ user: User) {
    users[user.id] = user
  }
}

This is better than a DispatchQueue, but it is still lock-oriented thinking. The actor is just a synchronized dictionary. The domain rules live elsewhere, often scattered across view models, services, and coordinators.

That fragmentation causes three recurring problems:

  1. Invariants are not enforced at the boundary
  2. State transitions are implicit and fragile
  3. Developers must remember “how to use it correctly”

Actors can, and should, do more.


Model Business Invariants Inside Actors

A business invariant is a rule that must always hold true. Examples:

If these rules are enforced outside the actor, correctness depends on discipline. That does not scale.

Instead, embed invariants directly into the actor’s API.

Example: Purchase Flow

NaĂŻve approach

struct Purchase {
  var isPaid: Bool
}

func pay(purchase: inout Purchase) {
  guard !purchase.isPaid else { return }
  purchase.isPaid = true
}

This works until concurrency, retries, or UI duplication enter the picture.

Actor-based domain modeling

actor PurchaseSession {
  enum State {
    case pending
    case paid(receipt: Receipt)
  }

  private var state: State = .pending

  func pay() throws -> Receipt {
      switch state {
      case .pending:
        let receipt = Receipt()
        state = .paid(receipt: receipt)
        return receipt

      case .paid:
        throw PurchaseError.alreadyPaid
      }
  }

  func snapshot() -> State {
      state
  }
}

Here, it is impossible to pay twice. The invariant is not documented, it is enforced by construction. No caller can violate it, even accidentally.

This is the single biggest improvement actors bring to iOS domain design.


Treat Actors as Stateful Domain Units, Not Just Locks

When actors are domain units, their public API represents allowed business operations, not raw data access.

Bad signals:

Good signals:

Example: Authentication Session

actor AuthSession {
  enum State {
    case loggedOut
    case loggingIn
    case loggedIn(User)
  }

  private var state: State = .loggedOut

  func login(credentials: Credentials) async throws {
    guard case .loggedOut = state else {
      throw AuthError.invalidState
    }

    state = .loggingIn
    let user = try await authenticate(credentials)
    state = .loggedIn(user)
  }

  func logout() throws {
    guard case .loggedIn = state else {
      throw AuthError.invalidState
    }
    state = .loggedOut
  }

  func snapshot() -> State {
    state
  }
}

Notice what is missing:

The actor defines what is allowed, and nothing else.


Combine Actors with Value Types for Snapshot-Based UI Rendering

Actors are reference types with isolated mutable state. SwiftUI, on the other hand, thrives on immutable value types. The bridge between them is snapshots.

The pattern is simple:

  1. Actor owns mutable state
  2. Actor exposes immutable snapshots (structs or enums)
  3. UI renders only from snapshots

Example: View Model Integration

struct AuthViewState: Equatable {
  let isLoggedIn: Bool
  let username: String?
}

extension AuthSession {
  func viewState() -> AuthViewState {
    switch snapshot() {
    case .loggedOut:
      return .init(isLoggedIn: false, username: nil)

    case .loggingIn:
      return .init(isLoggedIn: false, username: nil)

    case .loggedIn(let user):
      return .init(isLoggedIn: true, username: user.name)
    }
  }
}

In the UI layer:

@MainActor
final class AuthViewModel: ObservableObject {
  @Published private(set) var state: AuthViewState
  private let session: AuthSession

  func refresh() async {
    state = await session.viewState()
  }
}

This gives you:

Actors mutate. Views observe values.


Why This Improves Correctness (Dramatically)

Actor-based domain modeling delivers correctness guarantees that are difficult to achieve otherwise:

  1. Illegal states are unrepresentable: By encoding valid transitions in actor methods, entire classes of bugs disappear.

  2. Concurrency is implicit, not managed: You stop reasoning about queues, locks, and reentrancy. The actor model enforces isolation by default.

  3. Call sites are simpler:

Instead of:

if !purchase.isPaid {
  purchase.pay()
}

You write:

try await purchaseSession.pay()

No preconditions. No defensive coding. No duplication.


A Simpler Mental Model for Teams

From experience, this is the most underrated benefit. When actors are domain units, developers can answer questions like:

by opening one file.

That is not true when business rules are split across:

Actors concentrate complexity in a controlled, inspectable place.


Practical Guidelines for iOS Projects

To apply this effectively:

  1. Design the actor API first: Model actions, not properties
  2. Keep state private: If something must be mutable, it belongs inside the actor
  3. Expose snapshots, not references: UI and tests consume value types
  4. Prefer enums over booleans: State machines beat flags
  5. Let errors represent invalid transitions: Avoid silent no-ops

If an actor starts looking like a thread-safe struct, redesign it.


Final Take

Using actors only for thread safety is a missed opportunity. In iOS projects, the real payoff comes when actors are treated as authoritative domain objects that own state, enforce invariants, and define legal behavior.

The result is code that is:

Actor-based domain modeling is not an abstraction tax, it is a clarity dividend.