Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Thread-safe data persistence in Swift using actors — in-memory cache with file-backed storage, eliminating data races by design.
.claude/skills/loulanyue-swift-actor-persistence/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-12 | ✗→✓ | ▲ Improved | -20% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 10% | 0% |
| case-09 | ✗→✓ | ▲ Improved | -5% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 3% | 0% |
| case-13 | ✗→✓ | ▲ Improved | 26% | 0% |
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.
The actor model guarantees serialized access — no data races, enforced by the compiler.
swiftpublic 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) }) } }
All calls are automatically async due to actor isolation:
swiftlet 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")
swift@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() } }
| 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 |
Sendable types for all data crossing actor boundaries.atomic writes to prevent data corruption if the app crashes mid-writeinit — async initializers add complexity with minimal benefit for local files@Observable ViewModels for reactive UI updatesDispatchQueue or NSLock instead of actors for new Swift concurrency codeawait — callers must handle async contextnonisolated to bypass actor isolation (defeats the purpose)DispatchQueue-based thread safety with modern Swift concurrency| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-12 | fail→pass | 14,637 | 5,926 | -60% | 1 | 1 | 0% | 2,689 | 2,147 | -20% | 0 | 0 | — |
case-04 | pass→pass | 21,447 | 19,078 | -11% | 1 | 1 | 0% | 3,603 | 4,652 | +29% | 0 | 0 | — |
case-05 | pass→pass | 15,635 | 16,971 | +9% | 1 | 1 | 0% | 3,052 | 4,375 | +43% | 0 | 0 | — |
case-01 | pass→pass | 16,729 | 14,488 | -13% | 1 | 1 | 0% | 3,348 | 4,311 | +29% | 0 | 0 | — |
case-02 | fail→pass | 18,230 | 12,614 | -31% | 1 | 1 | 0% | 3,300 | 3,619 | +10% | 0 | 0 | — |
case-03 | pass→pass | 22,035 | 12,679 | -42% | 1 | 1 | 0% | 4,034 | 3,586 | -11% | 0 | 0 | — |
case-06 | pass→pass | 18,435 | 11,660 | -37% | 1 | 1 | 0% | 3,178 | 3,405 | +7% | 0 | 0 | — |
case-07 | fail→fail | 16,077 | 11,688 | -27% | 1 | 1 | 0% | 2,636 | 3,098 | +18% | 0 | 0 | — |
case-08 | pass→pass | 11,742 | 5,006 | -57% | 1 | 1 | 0% | 1,928 | 2,017 | +5% | 0 | 0 | — |
case-09 | fail→pass | 13,824 | 6,597 | -52% | 1 | 1 | 0% | 2,478 | 2,358 | -5% | 0 | 0 | — |
case-10 | fail→pass | 14,677 | 7,967 | -46% | 1 | 1 | 0% | 2,330 | 2,410 | +3% | 0 | 0 | — |
case-11 | pass→pass | 11,537 | 8,198 | -29% | 1 | 1 | 0% | 1,993 | 2,638 | +32% | 0 | 0 | — |
case-13 | fail→pass | 15,266 | 12,684 | -17% | 1 | 1 | 0% | 2,649 | 3,336 | +26% | 0 | 0 | — |
case-14 | pass→pass | 11,868 | 7,146 | -40% | 1 | 1 | 0% | 2,081 | 2,352 | +13% | 0 | 0 | — |
case-15 | pass→pass | 11,655 | 9,115 | -22% | 1 | 1 | 0% | 2,099 | 2,867 | +37% | 0 | 0 | — |
case-16 | pass→pass | 7,839 | 3,947 | -50% | 1 | 1 | 0% | 1,317 | 1,773 | +35% | 0 | 0 | — |
case-17 | fail→fail | 16,862 | 13,158 | -22% | 1 | 1 | 0% | 3,054 | 3,486 | +14% | 0 | 0 | — |
case-18 | pass→fail | 14,815 | 10,055 | -32% | 1 | 1 | 0% | 2,353 | 2,683 | +14% | 0 | 0 | — |
case-19 | fail→fail | 14,416 | 11,734 | -19% | 1 | 1 | 0% | 2,391 | 3,060 | +28% | 0 | 0 | — |
case-20 | pass→pass | 18,442 | 13,025 | -29% | 1 | 1 | 0% | 3,273 | 3,470 | +6% | 0 | 0 | — |
case-21 | fail→fail | 18,120 | 12,859 | -29% | 1 | 1 | 0% | 2,742 | 3,343 | +22% | 0 | 0 | — |
case-22 | pass→pass | 7,846 | 5,767 | -26% | 1 | 1 | 0% | 1,351 | 2,212 | +64% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +18 percentage points is the difference between those two pass rates over the 22 comparable cases. 2 cases got worse with the skill loaded, and they are included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.