Patrón de Actores en Swift para Persistencia Segura en Hilos
Patrones para construir capas de persistencia de datos seguras en hilos utilizando actores en Swift. Combina caché en memoria con almacenamiento respaldado por archivos, aprovechando el modelo de actores para eliminar condiciones de raza en tiempo de compilación.
Cuándo Activarlo
- Construir una capa de persistencia de datos en Swift 5.5+
- Necesitas acceso seguro a estado mutable compartido entre hilos
- Quieres eliminar la sincronización manual (bloqueos, DispatchQueues)
- Desarrollas aplicaciones offline-first con almacenamiento local
Patrón Principal
Repositorio Basado en Actores
El modelo de actores garantiza acceso serializado — sin condiciones de raza, impuesto por el compilador.
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)
// Carga síncrona durante init (el aislamiento del actor aún no está activo)
self.cache = Self.loadSynchronously(from: fileURL)
}
// MARK: - API Pública
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: - Privado
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) })
}
}
Uso
Todas las llamadas son automáticamente asíncronas debido al aislamiento del actor:
let repository = LocalRepository<Question>()
// Lectura — búsqueda O(1) rápida desde caché en memoria
let question = await repository.find(by: "q-001")
let allQuestions = await repository.loadAll()
// Escritura — actualiza el caché y persiste en archivo de forma atómica
try await repository.save(newQuestion)
try await repository.delete("q-001")
Combinación con ViewModel @Observable
@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()
}
}
Decisiones Clave de Diseño
| Decisión | Justificación |
|---|---|
| Actor (no class + lock) | Seguridad en hilos impuesta por el compilador, sin sincronización manual |
| Caché en memoria + persistencia en archivo | Lecturas rápidas desde caché, escrituras duraderas en disco |
| Carga síncrona en init | Evita la complejidad de inicializadores asíncronos |
| Diccionario indexado por ID | Búsqueda O(1) por identificador |
Genérico sobre Codable & Identifiable | Reutilizable con cualquier tipo de modelo |
Escrituras atómicas (.atomic) | Previene escrituras parciales en caso de fallo |
Mejores Prácticas
- Usa tipos
Sendablepara todos los datos que cruzan los límites del actor - Mantén la API pública del actor al mínimo — expón solo operaciones de dominio, no detalles de persistencia
- Usa escrituras
.atomicpara evitar corrupción de datos si la aplicación falla a mitad de una escritura - Carga síncronamente en
init— los inicializadores asíncronos añaden complejidad con poco beneficio para archivos locales - Combina con ViewModels
@Observablepara actualizaciones reactivas de la interfaz de usuario
Anti-patrones a Evitar
- Usar
DispatchQueueoNSLocken lugar de actores para código nuevo de concurrencia en Swift - Exponer el diccionario interno del caché a invocadores externos
- Hacer que la URL del archivo sea configurable sin validación
- Olvidar que todas las llamadas a métodos de actor requieren
await— los invocadores deben manejar el contexto asíncrono - Usar
nonisolatedpara evitar el aislamiento del actor (contradice el propósito)
Cuándo Usarlo
- Almacenamiento local de datos en aplicaciones iOS/macOS (datos de usuario, configuraciones, contenido en caché)
- Arquitecturas offline-first que sincronizan con un servidor más tarde
- Cualquier estado mutable compartido al que múltiples partes de la aplicación accedan concurrentemente
- Reemplazar la seguridad en hilos basada en
DispatchQueuepor la concurrencia moderna de Swift