Beyond Thread Safety - Actor-Centered Domain Modeling for Robust iOS Apps
17 Feb 2026Swift 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:
- Invariants are not enforced at the boundary
- State transitions are implicit and fragile
- 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:
- An order cannot be paid twice
- A download cannot be completed before it starts
- A user cannot be logged in and logged out at the same time
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:
getX(),setX()- Public mutable properties
- Actors mirroring database tables or DTOs
Good signals:
- Methods named after actions:
start(),confirm(),cancel() - Internal state machines
- Errors that represent invalid domain transitions
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:
- No
isLoggedInBoolean - No writable state
- No âcheck-then-actâ race conditions
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:
- Actor owns mutable state
- Actor exposes immutable snapshots (structs or enums)
- 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:
- Deterministic rendering
- Easy diffing and testing
- No shared mutable state leaking into SwiftUI
Actors mutate. Views observe values.
Why This Improves Correctness (Dramatically)
Actor-based domain modeling delivers correctness guarantees that are difficult to achieve otherwise:
-
Illegal states are unrepresentable: By encoding valid transitions in actor methods, entire classes of bugs disappear.
-
Concurrency is implicit, not managed: You stop reasoning about queues, locks, and reentrancy. The actor model enforces isolation by default.
-
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:
- âWhere does this state live?â
- âWho is allowed to change it?â
- âWhat happens if this is called twice?â
by opening one file.
That is not true when business rules are split across:
- View models
- Services
- Reducers
- Utility functions
Actors concentrate complexity in a controlled, inspectable place.
Practical Guidelines for iOS Projects
To apply this effectively:
- Design the actor API first: Model actions, not properties
- Keep state private: If something must be mutable, it belongs inside the actor
- Expose snapshots, not references: UI and tests consume value types
- Prefer enums over booleans: State machines beat flags
- 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:
- Safer by construction
- Easier to reason about
- Better aligned with SwiftUI and modern concurrency
Actor-based domain modeling is not an abstraction tax, it is a clarity dividend.