SuperAPI AI Directory

swift-actor-persistence

General & other

View repository

Updated 10-16 07:23

About this skill

Thread-Safe Persistence with Swift Actors

A pattern for building a thread-safe data persistence layer with Swift actors. This pattern combines in-memory caching with file-based storage, leveraging the actor model to eliminate data races at compile time.

When to Use

  • When building a data persistence layer in Swift 5.5+
  • When thread-safe access to shared mutable state is needed
  • When you want to eliminate manual synchronization operations (such as locks, DispatchQueues)
  • When building offline-first apps with local storage

Core Pattern

Actor-Based Repository

The actor model guarantees serialized access — enforced by the compiler, ensuring no data races occur.

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)
        // 在 init 期间同步加载(此时 actor 隔离尚未生效)
        self.cache = Self.loadSynchronously(from: fileURL)
    }

    // MARK: - 公共 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 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

Due to actor isolation, all calls automatically become asynchronous:

let repository = LocalRepository<Question>()

// 读取 —— 从内存缓存中进行快速 O(1) 查找
let question = await repository.find(by: "q-001")
let allQuestions = await repository.loadAll()

// 写入 —— 原子化地更新缓存并持久化到文件
try await repository.save(newQuestion)
try await repository.delete("q-001")

Combining with an @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
Use an actor (instead of class + lock) The compiler enforces thread safety, no manual synchronization needed
In-memory cache + file persistence The cache provides fast reads, the disk provides persistent writes
Load synchronously in init Avoids the complexity of asynchronous initialization, and the impact on local files is small
Dictionary keyed by ID O(1) lookup by identifier
Generic support Codable & Identifiable Reusable in any model type
Atomic file writes (.atomic) Prevents data corruption from partial writes during a crash

Best Practices

  • For all data crossing actor boundaries use Sendable types
  • Keep the actor's public API minimal — expose only business domain operations, not persistence details
  • Use .atomic writes to prevent data corruption if the app crashes during a write
  • In init load synchronously — asynchronous initialization adds complexity, while synchronous reads of local files offer higher returns
  • Combine with an @Observable ViewModel for reactive UI updates

Anti-Patterns to Avoid

  • In new Swift concurrency code, using DispatchQueue or NSLock instead of an actor
  • Exposing the internal cache dictionary directly to external callers
  • Making the file URL configurable without validation
  • Forgetting that all actor method calls are await — callers must handle the asynchronous context
  • Using nonisolated to bypass actor isolation (this defeats the purpose of using an actor)

When to Use

  • Local data storage in iOS/macOS apps (user data, settings, cached content)
  • Offline-first architecture that syncs with a server later
  • Any shared mutable state accessed concurrently by multiple parts of an app
  • Replace legacy thread-safety solutions based on DispatchQueue with modern Swift Concurrency