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
Sendabletypes - Keep the actor's public API minimal — expose only business domain operations, not persistence details
- Use
.atomicwrites to prevent data corruption if the app crashes during a write - In
initload synchronously — asynchronous initialization adds complexity, while synchronous reads of local files offer higher returns - Combine with an
@ObservableViewModel for reactive UI updates
Anti-Patterns to Avoid
- In new Swift concurrency code, using
DispatchQueueorNSLockinstead 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
nonisolatedto 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
DispatchQueuewith modern Swift Concurrency