Applying the Decorator Pattern in SwiftUI to Add Caching Without Changing Existing Code

You've built a clean feed feature. The RemoteFeedRepository fetches items from the network, the LoadFeedUseCase runs the business logic, and the ViewModel drives the view. Everything is tested and working.
Now a new requirement arrives: cache the last successful response so the app shows content immediately on relaunch, even without a network connection.
The naive approach is to open RemoteFeedRepository and add caching logic directly. But that violates the Open/Closed Principle — code should be open for extension, closed for modification. Modifying a working, tested class to add new behavior risks breaking existing functionality and bloats a class that previously had one job.
The Decorator pattern gives you a better path: wrap the existing repository with a new object that adds caching on top, without touching a single line of the original.
Recap: The Existing Structure
From the previous article, the Domain layer defines:
protocol FeedRepository {
func fetchFeed() async throws -> [FeedItem]
}
And the Data layer provides:
final class RemoteFeedRepository: FeedRepository {
func fetchFeed() async throws -> [FeedItem] {
// fetches from network
}
}
The LoadFeedUseCase depends only on the FeedRepository protocol — it has no idea what sits behind it.
Step 1 — Build the Decorator
A Decorator implements the same protocol as the type it wraps, and holds a reference to the wrapped instance. It intercepts calls, adds behavior, and delegates the core work.
First, define a simple cache store:
// Data/Cache/FeedCacheStore.swift
protocol FeedCacheStore {
func save(_ items: [FeedItem]) throws
func load() throws -> [FeedItem]
}
Now create the Decorator:
// Data/Cache/CachingFeedRepository.swift
final class CachingFeedRepository: FeedRepository {
private let decoratee: FeedRepository
private let cache: FeedCacheStore
init(decoratee: FeedRepository, cache: FeedCacheStore) {
self.decoratee = decoratee
self.cache = cache
}
func fetchFeed() async throws -> [FeedItem] {
do {
let items = try await decoratee.fetchFeed()
try? cache.save(items)
return items
} catch {
if let cachedItems = try? cache.load(), !cachedItems.isEmpty {
return cachedItems
}
throw error
}
}
}
The logic is explicit:
Try the real repository (network).
On success, save to cache and return the fresh items.
On failure, fall back to the cache if available.
If both fail, propagate the original error.
RemoteFeedRepository is untouched. The caching logic lives in its own class with its own tests.
Step 2 — Implement the Cache Store
For a concrete implementation, UserDefaults works well for lightweight data. For larger datasets, use Core Data or the file system.
// Data/Cache/UserDefaultsFeedCacheStore.swift
final class UserDefaultsFeedCacheStore: FeedCacheStore {
private let defaults: UserDefaults
private let key = "cached_feed"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func save(_ items: [FeedItem]) throws {
let data = try JSONEncoder().encode(items)
defaults.set(data, forKey: key)
}
func load() throws -> [FeedItem] {
guard let data = defaults.data(forKey: key) else {
return []
}
return try JSONDecoder().decode([FeedItem].self, from: data)
}
}
Note: FeedItem needs Codable conformance for this to work. Add it in the Domain layer:
struct FeedItem: Equatable, Codable {
let id: UUID
let title: String
let imageURL: URL
}
Step 3 — Wire It in the Composition Root
Only one change is needed in the entire codebase — at the Composition Root:
// Before
FeedView(
viewModel: FeedViewModel(
loadFeed: LoadFeedUseCase(
repository: RemoteFeedRepository(url: feedURL)
)
)
)
// After
FeedView(
viewModel: FeedViewModel(
loadFeed: LoadFeedUseCase(
repository: CachingFeedRepository(
decoratee: RemoteFeedRepository(url: feedURL),
cache: UserDefaultsFeedCacheStore()
)
)
)
)
The LoadFeedUseCase and FeedViewModel have no idea a cache was added. The RemoteFeedRepository has no idea it's wrapped. Every existing test still passes without modification.
Step 4 — Test the Decorator in Isolation
The Decorator is fully testable without a real network or real storage:
final class CachingFeedRepositoryTests: XCTestCase {
func test_fetchFeed_savesRemoteItemsToCache() async throws {
let remoteItems = makeItems()
let (sut, remote, cache) = makeSUT(remoteResult: .success(remoteItems))
_ = try await sut.fetchFeed()
XCTAssertEqual(cache.savedItems, remoteItems)
}
func test_fetchFeed_returnsRemoteItemsOnSuccess() async throws {
let remoteItems = makeItems()
let (sut, _, _) = makeSUT(remoteResult: .success(remoteItems))
let received = try await sut.fetchFeed()
XCTAssertEqual(received, remoteItems)
}
func test_fetchFeed_returnsCachedItemsOnRemoteFailure() async throws {
let cachedItems = makeItems()
let (sut, _, cache) = makeSUT(remoteResult: .failure(anyError()))
cache.stubbedItems = cachedItems
let received = try await sut.fetchFeed()
XCTAssertEqual(received, cachedItems)
}
func test_fetchFeed_throwsWhenRemoteFailsAndCacheIsEmpty() async {
let (sut, _, _) = makeSUT(remoteResult: .failure(anyError()))
do {
_ = try await sut.fetchFeed()
XCTFail("Expected error to be thrown")
} catch {
XCTAssertNotNil(error)
}
}
// MARK: - Helpers
private func makeSUT(
remoteResult: Result<[FeedItem], Error>
) -> (CachingFeedRepository, FeedRepositoryStub, FeedCacheStoreSpy) {
let remote = FeedRepositoryStub(result: remoteResult)
let cache = FeedCacheStoreSpy()
let sut = CachingFeedRepository(decoratee: remote, cache: cache)
return (sut, remote, cache)
}
private func makeItems() -> [FeedItem] {
[FeedItem(id: UUID(), title: "Item", imageURL: URL(string: "https://any-url.com")!)]
}
private func anyError() -> Error { NSError(domain: "test", code: 0) }
}
// MARK: - Test Doubles
private class FeedRepositoryStub: FeedRepository {
private let result: Result<[FeedItem], Error>
init(result: Result<[FeedItem], Error>) { self.result = result }
func fetchFeed() async throws -> [FeedItem] { try result.get() }
}
private class FeedCacheStoreSpy: FeedCacheStore {
var savedItems: [FeedItem] = []
var stubbedItems: [FeedItem] = []
func save(_ items: [FeedItem]) throws { savedItems = items }
func load() throws -> [FeedItem] { stubbedItems }
}
Four focused tests. Each verifies one behaviour. No real network, no real disk.
Stacking Decorators
Because every Decorator implements the same protocol, they compose freely. Need to add logging on top of caching?
final class LoggingFeedRepository: FeedRepository {
private let decoratee: FeedRepository
private let logger: Logger
init(decoratee: FeedRepository, logger: Logger = Logger()) {
self.decoratee = decoratee
self.logger = logger
}
func fetchFeed() async throws -> [FeedItem] {
logger.log("Fetching feed...")
do {
let items = try await decoratee.fetchFeed()
logger.log("Fetched \(items.count) items.")
return items
} catch {
logger.log("Fetch failed: \(error)")
throw error
}
}
}
Wire them in the Composition Root by nesting:
repository: LoggingFeedRepository(
decoratee: CachingFeedRepository(
decoratee: RemoteFeedRepository(url: feedURL),
cache: UserDefaultsFeedCacheStore()
)
)
Each Decorator has one job. Each is independently testable. The core remote logic stays pristine.
What You Gain
| Concern | Without Decorator | With Decorator |
|---|---|---|
| Adding caching | Modify RemoteFeedRepository |
Create a new, separate class |
| Open/Closed Principle | Violated on every new requirement | Each new behavior is a new class |
| Testability | One large class tests multiple concerns | Each Decorator tested in isolation |
| Composability | Hard-coded behaviour chains | Freely nestable at the Composition Root |
| Risk | Changing working code risks regression | Existing code and tests untouched |
Summary
The Decorator pattern is one of the most powerful tools in a clean architecture toolkit. In SwiftUI:
Define behavior through a protocol (
FeedRepository).Implement the core behavior in a focused class (
RemoteFeedRepository).Add new cross-cutting concerns — caching, logging, analytics, retry logic — by wrapping the existing implementation in a Decorator.
Compose at the Composition Root by nesting Decorators.
Test each Decorator independently using stubs.
Every new requirement becomes a new class, not a modification to an existing one. The codebase grows by addition, not by mutation — and every piece remains testable, readable, and replaceable.





