Swift Actors for Thread-Safe Persistence
Patterns for building thread-safe data persistence layers using Swift actors. Combines in-memory caching with file-backed storage, leveraging the actor model to eliminate data races at compile time.
When to Activate
- Building a data persistence layer in Swift 5.5+
- Need thread-safe access to shared mutable state
- Want to eliminate manual synchronization (locks, DispatchQueues)
- Building offline-first apps with local storage
Core Pattern
Actor-Based Repository
The actor model guarantees serialized access — no data races, enforced by the compiler.
swift1public actor LocalRepository<T: Codable & Identifiable> where T.ID == String { 2 private var cache: [String: T] = [:] 3 private let fileURL: URL 4 5 public init(directory: URL = .documentsDirectory, filename: String = "data.json") { 6 self.fileURL = directory.appendingPathComponent(filename) 7 // Synchronous load during init (actor isolation not yet active) 8 self.cache = Self.loadSynchronously(from: fileURL) 9 } 10 11 // MARK: - Public API 12 13 public func save(_ item: T) throws { 14 cache[item.id] = item 15 try persistToFile() 16 } 17 18 public func delete(_ id: String) throws { 19 cache[id] = nil 20 try persistToFile() 21 } 22 23 public func find(by id: String) -> T? { 24 cache[id] 25 } 26 27 public func loadAll() -> [T] { 28 Array(cache.values) 29 } 30 31 // MARK: - Private 32 33 private func persistToFile() throws { 34 let data = try JSONEncoder().encode(Array(cache.values)) 35 try data.write(to: fileURL, options: .atomic) 36 } 37 38 private static func loadSynchronously(from url: URL) -> [String: T] { 39 guard let data = try? Data(contentsOf: url), 40 let items = try? JSONDecoder().decode([T].self, from: data) else { 41 return [:] 42 } 43 return Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) 44 } 45}
Usage
All calls are automatically async due to actor isolation:
swift1let repository = LocalRepository<Question>() 2 3// Read — fast O(1) lookup from in-memory cache 4let question = await repository.find(by: "q-001") 5let allQuestions = await repository.loadAll() 6 7// Write — updates cache and persists to file atomically 8try await repository.save(newQuestion) 9try await repository.delete("q-001")
Combining with @Observable ViewModel
swift1@Observable 2final class QuestionListViewModel { 3 private(set) var questions: [Question] = [] 4 private let repository: LocalRepository<Question> 5 6 init(repository: LocalRepository<Question> = LocalRepository()) { 7 self.repository = repository 8 } 9 10 func load() async { 11 questions = await repository.loadAll() 12 } 13 14 func add(_ question: Question) async throws { 15 try await repository.save(question) 16 questions = await repository.loadAll() 17 } 18}
Key Design Decisions
| Decision | Rationale |
|---|---|
| Actor (not class + lock) | Compiler-enforced thread safety, no manual synchronization |
| In-memory cache + file persistence | Fast reads from cache, durable writes to disk |
| Synchronous init loading | Avoids async initialization complexity |
| Dictionary keyed by ID | O(1) lookups by identifier |
Generic over Codable & Identifiable | Reusable across any model type |
Atomic file writes (.atomic) | Prevents partial writes on crash |
Best Practices
- Use
Sendabletypes for all data crossing actor boundaries - Keep the actor's public API minimal — only expose domain operations, not persistence details
- Use
.atomicwrites to prevent data corruption if the app crashes mid-write - Load synchronously in
init— async initializers add complexity with minimal benefit for local files - Combine with
@ObservableViewModels for reactive UI updates
Anti-Patterns to Avoid
- Using
DispatchQueueorNSLockinstead of actors for new Swift concurrency code - Exposing the internal cache dictionary to external callers
- Making the file URL configurable without validation
- Forgetting that all actor method calls are
await— callers must handle async context - Using
nonisolatedto bypass actor isolation (defeats the purpose)
When to Use
- Local data storage in iOS/macOS apps (user data, settings, cached content)
- Offline-first architectures that sync to a server later
- Any shared mutable state that multiple parts of the app access concurrently
- Replacing legacy
DispatchQueue-based thread safety with modern Swift concurrency