Skip to main content

Command Palette

Search for a command to run...

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

Published
7 min read
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:

  1. Try the real repository (network).

  2. On success, save to cache and return the fresh items.

  3. On failure, fall back to the cache if available.

  4. 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:

  1. Define behavior through a protocol (FeedRepository).

  2. Implement the core behavior in a focused class (RemoteFeedRepository).

  3. Add new cross-cutting concerns — caching, logging, analytics, retry logic — by wrapping the existing implementation in a Decorator.

  4. Compose at the Composition Root by nesting Decorators.

  5. 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.