swift-actor-persistence
affaan-m/ecc
Thread-safe data persistence in Swift using actors with in-memory cache and file-backed storage.
What is swift-actor-persistence?
A pattern for building thread-safe data persistence layers in Swift 5.5+ using the actor model. Combines in-memory caching with atomic file-backed storage, eliminating data races at compile time without manual synchronization.
- Generic actor-based repository for any Codable & Identifiable type
- O(1) in-memory cache lookups with automatic file persistence
- Atomic file writes to prevent data corruption on crashes
- Synchronous initialization to avoid async complexity
- Integration with @Observable ViewModels for reactive UI updates
- Compiler-enforced thread safety without locks or DispatchQueues
How to install swift-actor-persistence
npx skills add null --skill swift-actor-persistence- Swift 5.5 or later
- Models conforming to Codable and Identifiable protocols
- Basic understanding of Swift's actor model and async/await
How to use swift-actor-persistence
- 1.Define your data model conforming to Codable and Identifiable with String ID
- 2.Create a LocalRepository<YourModel> instance with optional custom directory and filename
- 3.Call await repository.save(item) to write and persist data
- 4.Call await repository.find(by:) or await repository.loadAll() to read from cache
- 5.Optionally wrap the repository in an @Observable ViewModel for reactive UI binding
Use cases
- Building offline-first iOS/macOS apps with local user data storage
- Implementing thread-safe settings or configuration persistence
- Caching server responses locally while maintaining data consistency
- Replacing legacy DispatchQueue-based synchronization in existing codebases
- Multi-threaded access to shared mutable state in concurrent Swift applications
- iOS/macOS developers using Swift 5.5+
- Teams building offline-first or local-first applications
- Developers migrating from manual synchronization to Swift concurrency
- App developers needing durable local storage with thread safety
swift-actor-persistence FAQ
Synchronous loading in init avoids the complexity of async initializers while being practical for local files, which are typically fast to read.
Yes, all public methods are async due to actor isolation. Callers must be in an async context or use Task to call them.
Atomic writes (.atomic option) ensure the file is either fully written or unchanged, preventing partial/corrupted data.
No, the generic constraint requires Codable & Identifiable. You would need to modify the pattern or add custom serialization logic.
The repository handles local persistence; add separate sync logic in your ViewModel or service layer to push/pull changes to a backend.
Full instructions (SKILL.md)
Source of truth, from affaan-m/ecc.
name: swift-actor-persistence description: Thread-safe data persistence in Swift using actors — in-memory cache with file-backed storage, eliminating data races by design. metadata: origin: ECC
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.
public actor LocalRepository<T: Codable & Identifiable> where T.ID == String {
private var cache: [String: T] = [:]
private let fileURL: URL
public init(directory: URL = .documentsDirectory, filename: String = "data.json") {
self.fileURL = directory.appendingPathComponent(filename)
// Synchronous load during init (actor isolation not yet active)
self.cache = Self.loadSynchronously(from: fileURL)
}
// MARK: - Public API
public func save(_ item: T) throws {
cache[item.id] = item
try persistToFile()
}
public func delete(_ id: String) throws {
cache[id] = nil
try persistToFile()
}
public func find(by id: String) -> T? {
cache[id]
}
public func loadAll() -> [T] {
Array(cache.values)
}
// MARK: - Private
private func persistToFile() throws {
let data = try JSONEncoder().encode(Array(cache.values))
try data.write(to: fileURL, options: .atomic)
}
private static func loadSynchronously(from url: URL) -> [String: T] {
guard let data = try? Data(contentsOf: url),
let items = try? JSONDecoder().decode([T].self, from: data) else {
return [:]
}
return Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
}
}
Usage
All calls are automatically async due to actor isolation:
let repository = LocalRepository<Question>()
// Read — fast O(1) lookup from in-memory cache
let question = await repository.find(by: "q-001")
let allQuestions = await repository.loadAll()
// Write — updates cache and persists to file atomically
try await repository.save(newQuestion)
try await repository.delete("q-001")
Combining with @Observable ViewModel
@Observable
final class QuestionListViewModel {
private(set) var questions: [Question] = []
private let repository: LocalRepository<Question>
init(repository: LocalRepository<Question> = LocalRepository()) {
self.repository = repository
}
func load() async {
questions = await repository.loadAll()
}
func add(_ question: Question) async throws {
try await repository.save(question)
questions = await repository.loadAll()
}
}
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
Related skills
More from affaan-m/ecc and the wider catalog.
swift-concurrency-6-2
Swift 6.2 concurrency patterns: single-threaded by default, explicit background offloading with @concurrent, safe MainActor conformances.
swift-protocol-di-testing
Protocol-based dependency injection for testable Swift code with mocks and Swift Testing.
swiftui-patterns
Modern SwiftUI patterns: @Observable state, type-safe navigation, view composition, and performance optimization.
taste
Agent skill from affaan-m/ecc.
tasteforge-video
Agent skill from affaan-m/ecc.
tdd-workflow
Test-driven development workflow enforcing 80%+ coverage with unit, integration, and E2E tests.