Decorator
Decorator wraps an object and presents the same interface while adding behavior around the original call. You can add a concern without editing every concrete implementation.
When it helps in iOS
A product detail screen may request the same summary several times while navigating between tabs. A caching decorator can sit around the network service, so the screen stays unaware of caching.
Example: cached product summaries
import Foundation
struct ProductSummary {
let id: String
let name: String
}
protocol ProductLoading {
func product(id: String) async throws -> ProductSummary
}
actor CachedProductLoader: ProductLoading {
private let base: any ProductLoading
private var cache: [String: ProductSummary] = [:]
init(base: any ProductLoading) {
self.base = base
}
func product(id: String) async throws -> ProductSummary {
if let cached = cache[id] { return cached }
let value = try await base.product(id: id)
cache[id] = value
return value
}
func invalidate(id: String) {
cache[id] = nil
}
}Both the network loader and the decorator conform to ProductLoading. The actor protects its cache from simultaneous mutation.
How to use it in a real app
At the app's composition root, create CachedProductLoader(base: apiLoader) and pass it to the detail feature. Invalidate the cache after a product edit or when the account changes. For large images or long-lived data, use a cache with size and expiry rules instead of an unbounded dictionary.
Watch for: Two concurrent cache misses can still both call base.product(id:) because the actor can re-enter during await. Add in-flight request coalescing if duplicate requests are costly.
Trace the layers
The detail feature calls one ProductLoading value. The cache checks its dictionary, delegates a miss to the wrapped loader, and stores a successful response. Errors are not cached here, so a later request can retry. The wrapper can be composed with a logging decorator without changing the feature.
For a real cache, define expiry, memory limits, account scoping, and invalidation before shipping. Actor isolation protects dictionary access, but reentrancy during await can duplicate requests. Test hit, miss, error, and invalidation paths; measure whether caching improves the screen before adding more layers.
Follow a request through both layers
The screen sees one ProductLoading dependency. CachedProductLoader first checks its dictionary. A hit returns without calling the network; a miss delegates to base, then stores the successful ProductSummary. The wrapper has the same protocol as the wrapped service, so the composition root can substitute it without changing the screen. Because the cache is an actor, mutations are isolated, although an await lets another request enter during a miss.
From example to an iOS feature
Create the network loader, wrap it once, then inject the wrapper into the product detail view model. The view model still reports loading and error states; caching is not a UI concern. When a user edits a product, call invalidate(id:) before reloading. Scope the cache to the correct account or store; otherwise switching accounts can show stale or unauthorized data. Add an expiry and memory limit for long-running apps.
Failure modes and verification
Two concurrent misses for the same ID may both fetch. If that is expensive, track in-flight tasks by ID and let later callers await the first task. Do not cache errors forever unless that is a deliberate policy. Test hit, miss, error, invalidation, and concurrent misses. A Proxy decides whether access is allowed; this decorator adds behavior around an allowed call.