# 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:

```swift
protocol FeedRepository {
    func fetchFeed() async throws -> [FeedItem]
}
```

And the Data layer provides:

```swift
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:

```swift
// Data/Cache/FeedCacheStore.swift
protocol FeedCacheStore {
    func save(_ items: [FeedItem]) throws
    func load() throws -> [FeedItem]
}
```

Now create the Decorator:

```swift
// 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.

```swift
// 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:

```swift
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:

```swift
// 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:

```swift
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?

```swift
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:

```swift
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.
