5.7 KiB
ADR-013: Repository Caching and Streaming Patterns
Status: Accepted
Date: 2025-01-11
Context:
Repositories coordinate between NetworkService and PersistenceService layers, managing how data flows between remote APIs and local storage. I needed to establish patterns for:
- Data freshness: When to fetch from network vs local cache
- Real-time updates: How to keep UI synchronized with data changes
- Offline support: Enable viewing cached data without network
- Conflict resolution: Handle discrepancies between local and remote data
- User experience: Minimize loading states and perceived latency
Decision:
I adopted a streaming-based caching pattern where repositories use AsyncStream to coordinate continuous data synchronization between network and persistence layers.
Key Patterns:
1. Stream-Based Synchronization
Repositories set up AsyncStream listeners that automatically persist incoming data:
actor PURLsRepository: PURLsRepositoryProtocol {
private var streamTask: Task<Void, Never>?
init(...) {
Task {
await startPURLsSync()
}
}
private func startPURLsSync() {
streamTask = Task { [weak self] in
guard let self else { return }
// Listen to network service stream
for await purls in networkService.purlsStream() {
guard !Task.isCancelled else { break }
// Automatically persist incoming data
let storablePurls = purls.map { purlResponse in
StorablePURL(address: current, purlResponse: purlResponse)
}
try await persistenceService.storePURLs(purls: storablePurls)
}
}
}
}
2. Network Service Streams
NetworkServices emit data through AsyncStream, enabling reactive updates:
actor PURLsNetworkService: PURLsNetworkServiceProtocol {
private let purlsStreamContinuation: AsyncStream<[PURLResponse]>.Continuation
func fetchPURLs(for address: String) async throws {
let request = OMGAPIFactory.makeAllPURLsRequest(for: address)
let response: PURLsResponse = try await client.run(request)
// Emit through stream for automatic caching
continuation.yield(response.response.purls)
}
func purlsStream() -> AsyncStream<[PURLResponse]> {
purlsAsyncStream
}
}
3. SwiftData as Cache
PersistenceServices use SwiftData's ModelContainer, which views can query directly:
public protocol PURLsRepositoryProtocol: Sendable {
var purlsContainer: ModelContainer { get }
// ...
}
// Views query directly
@Query(sort: \StorablePURL.name) var purls: [StorablePURL]
Caching Strategy:
- Write-through: Network fetches automatically persist to Swift Data via streams
- Read from cache: Views use SwiftData @Query to read local data
- Explicit refresh: User-triggered or app lifecycle events fetch from network
- No complex merge: Network data is source of truth, overwrites local
- Optimistic updates: Some operations update UI immediately, then sync
Benefits of Stream-Based Approach:
- Automatic synchronization: Data flows continuously from network to storage
- Decoupled coordination: Repository doesn't manually call persistence after each network call
- Real-time updates: UI stays synchronized via SwiftData observation
- Task management: Stream tasks are managed in repository lifecycle
- Cancellation support: Streams can be cancelled on repository deinit
Conflict Resolution:
Current approach: Server wins (last-write-wins)
- Network data always overwrites local cache
- No complex merge logic
- Suitable for single-user, single-device scenarios
- Conflicts rare due to API design
Cache Invalidation:
- Explicit fetch: Repository
fetchPURLs()method triggers network call - Lifecycle events: App foreground, user login trigger refreshes
- Mutation operations: Create/delete operations automatically refresh via stream
- No TTL: Data remains valid until explicitly refreshed
Consequences:
Positive
- Reactive UI: SwiftData observation keeps views updated automatically
- Offline viewing: Local cache available without network
- Clean coordination: Streaming pattern reduces manual orchestration
- Type safety: @Model types provide compile-time safety
- Performance: Views read from local SwiftData, not waiting for network
Negative
- Memory: Stream tasks run continuously during repository lifetime
- Complexity: Async stream coordination requires understanding async patterns
- Limited offline: Can't create/modify data offline (server is source of truth)
Neutral
- Conflict resolution: Simple strategy works for current use case
- Cache size: No automatic cleanup, relies on periodic full refreshes
When to Fetch:
- User-initiated: Pull-to-refresh, explicit button taps
- App lifecycle: Returning to foreground
- After mutations: Create/update/delete operations
- Initial load: First time viewing a feature
Related Decisions:
- ADR-006: Swift Data over Core Data - SwiftData as cache layer
- ADR-011: Actor Isolation for Repository and Service Concurrency - Stream task management in actors
- ADR-012: DTO-Based Data Flow - DTOs flow through streams to persistence
Notes:
The streaming-based caching pattern provides automatic synchronization with minimal manual coordination. While it requires understanding async streams, it results in clean, reactive code where data flows naturally from network through repositories to local storage and UI.