Agent Skills: Swift Actors for Thread-Safe Persistence

Thread-safe data persistence using Swift actors with in-memory cache and file-backed storage. Use during maestro:implement when building persistence layers in Swift.

UncategorizedID: ReinaMacCredy/my-workflow/maestro:swift-actor-persistence

Install this agent skill to your local

pnpm dlx add-skill https://github.com/ReinaMacCredy/maestro/tree/HEAD/skillpacks/ecc/skills/maestro%3Aswift-actor-persistence

Skill Files

Browse the full folder contents for maestro:swift-actor-persistence.

Download Skill

Loading file tree…

skillpacks/ecc/skills/maestro:swift-actor-persistence/SKILL.md

Skill Metadata

Name
maestro:swift-actor-persistence
Description
"Thread-safe data persistence using Swift actors with in-memory cache and file-backed storage. Use during maestro:implement when building persistence layers in Swift."

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.

Maestro Integration

Lifecycle: implement Activates when: maestro:new-track detects relevant tech in tech-stack.md, or maestro:implement encounters matching task types.

Phase Guidance

In maestro:implement: Apply actor-based persistence when implementing data storage. Use actors to eliminate data races by design with file-backed caching.

Related Skills

  • maestro:swift-concurrency-6-2
  • maestro:swift-protocol-di-testing
  • maestro:swiftui-patterns

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 Sendable types for all data crossing actor boundaries
  • Keep the actor's public API minimal — only expose domain operations, not persistence details
  • Use .atomic writes 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 @Observable ViewModels for reactive UI updates

Anti-Patterns to Avoid

  • Using DispatchQueue or NSLock instead 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 nonisolated to 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

Relationship to Maestro Workflow

  • /maestro:new-track -- Detects this skill during Step 9.5 (skill matching)
  • /maestro:implement -- Loads this skill's guidance during task execution
  • /maestro:review -- Uses checklists as review criteria