Decorator pattern in Swift

This post aims to give you a clear, intuitive perspective on the Decorator pattern. As a software developer, you have likely implemented this pattern countless times without realizing it.

Its main advantage lies in its flexibility: because a decorator acts as an independent add-on, you can seamlessly attach or remove capabilities without disrupting the core system.

The Decorator pattern

Here is a refined, clear, and professional rewrite of the explanation of the Decorator Pattern, tailored for Swift developers:

The core idea of this pattern is to dynamically wrap or stack behavior around a central component. Crucially, wrapping an object with layers of new functionality must never alter or break its core identity or fundamental behavior.

Everyday Analogies:

  • Clothing: A person wearing a t-shirt is still a person. Adding a sweater adds a layer of protection, and putting a coat over that adds even more, but at their core, they remain the same person.

  • In astronomy: the Decorator pattern is best visualized as a Rocky Planet Core (the Decoratee) wrapped by concentric cosmic layers, such as an Atmosphere or a Ring System (the Decorators). Just as adding an atmosphere provides radiation filtering or adding rings reflects light, each outer layer enhances the primary celestial body with new features without altering its fundamental identity at the center

  • Coffee: Plain coffee becomes a café au lait when you add milk, and a cappuccino when you sprinkle cocoa powder on top—yet at its core, it remains coffee.

Implementation in Swift:

To preserve this core behavior in code, the Decorator pattern relies on two fundamental Swift principles:

  • Conformance to the Same Protocol: Both the core object (decoratee) and the wrapper (decorator) must conform to the exact same protocol. This ensures they are fully interchangeable to any client code.

  • Dependency Injection: The decorator receives the decoratee instance (typically via initializer injection) and delegates work to it. Any client interacting with the wrapper is seamlessly interacting with the underlying object, augmented by the new behavior.

When we turn into Swift:

protocol Component {
    func execute() -> String
}

struct Decoratee: Component {
    func execute() -> String {
        return "Base execution"
    }
}

class Decorator: Component {
    private let component: Component

    init(component: Component) {
        self.component = component
    }

    func execute() -> String {
        return component.execute()
    }
}

The compositiona and usage will be following:

let coreObject : Component = Decoratee()
let decoratedObject : Component = Decorator(component: coreObject)
let result = decoratedObject.execute()

print(result)

Roles

Despite its simplicity, the Decorator pattern typically manifests in four distinct functional roles based on how it interacts with the inputs and outputs of the decoratee:

  • Monitor: The decorator observes execution without modifying any input or output data (e.g., Loggers, Profilers, or Analytics trackers).

  • Guard / Interceptor: The decorator intercepts and modifies, validates, or blocks the input before it reaches the decoratee (e.g., Cache lookups, Rate limiters, DDoS filters, or Failed login counters).

  • Fallback / Output Formatter: The decorator transforms or supplies the output after the decoratee executes, leaving inputs untouched (e.g.   Currency formatters, or Response mappers).

  • Transformer: The decorator actively alters both the input and output data (e.g., Encryption layers, where incoming data is encrypted and outgoing data is decrypted).

Across all four roles, the underlying principle remains unchanged: the core identity and essential business logic of the decoratee are never altered—even during data transformation, where the underlying information remains the same despite its changed representation.

 

Monitor

In this role no transformation is performed, is the most inoquoos, where you do not notice any behaviour but at the end is there, we can add a decorator for profiling any heavy task, but just adding on debug mode but not on release. 

import Foundation

// Interface & Decoratee
protocol DataService {
    func fetch(query: String) async throws -> String
}

struct RemoteService: DataService {
    func fetch(query: String) async throws -> String {
        try await Task.sleep(for: .milliseconds(600))
        return "Data for '\(query)'"
    }
}

// Performance Monitor Decorator
struct PerformanceMonitor: DataService {
    let wrapped: DataService
    var threshold: Duration = .milliseconds(500)

    func fetch(query: String) async throws -> String {
        let clock = ContinuousClock()
        let elapsed = try await clock.measure { _ = try await wrapped.fetch(query: query) }
        
        let ms = elapsed.components.seconds * 1000 + Int64(elapsed.components.attoseconds / 1_000_000_000_000_000)
        print("⏱️ Execution took \(ms)ms \(elapsed > threshold ? "⚠️ [THRESHOLD EXCEEDED]" : "")")
        
        return try await wrapped.fetch(query: query)
    }
}

By using a Factory pattern, you can seamlessly configure a decorator—such as injecting a logger or profiler—whenever the debug flag is enabled.

enum ServiceFactory {
    static func makeDataService(isDebug: Bool) -> DataService {
        let baseService = RemoteService()

        if isDebug {
            return PerformanceMonitor(wrapped: baseService)
        }
        return baseService
    }
}

let isDebugMode = true 
let serviceA: DataService = ServiceFactory.makeDataService(isDebug: isDebugMode)

Guard / Interceptor

This decorator acts as a guard / interceptor by evaluating incoming inputs and triggering a specific action—such as returning cached data—whenever a condition is met. The core behavior remains intact: the caller receives the requested data, completely agnostic of whether it originated from the cache or the underlying service.

Here is an example:

import Foundation

// 1. Component
protocol DataService {
    func fetchData(key: String) -> String
}

// 2. Decoratee (Core network fetch)
struct NetworkDataService: DataService {
    func fetchData(key: String) -> String {
        print("🌐 Fetching from network...")
        return "Data for \(key)"
    }
}

// 3. Decorator (Caching layer)
final class CacheDecorator: DataService {
    private let decoratee: DataService
    private var cache: [String: String] = [:]

    init(decoratee: DataService) {
        self.decoratee = decoratee
    }

    func fetchData(key: String) -> String {
        if let cached = cache[key] {
            print("⚡️ Returning from cache")
            return cached
        }
        let data = decoratee.fetchData(key: key)
        cache[key] = data
        return data
    }
}

When executed, both calls to fetchData return identical results. From the caller’s perspective, the behavior is indistinguishable; under the hood, however, different paths were taken: the first call fetched fresh data from the remote source, while the second served it directly from the cache.

let service: DataService = CacheDecorator(decoratee: NetworkDataService())

print(service.fetchData(key: "user")) // 🌐 Fetching from network... -> Data for user
print(service.fetchData(key: "user")) // ⚡️ Returning from cache   -> Data for user

Fallback / Output formatter

In this scenario, the decorator acts exclusively on the output. While the formatted responses look different, they represent the exact same underlying value—a concept demonstrated by the following Currency Decorator example. Although the decorator returns different strings depending on the selected currency (e.g., USD or EUR), the monetary value remains identical after conversion.

import Foundation

// 1. Component Protocol
protocol PriceService {
    func getFormattedPrice(for productID: String) async -> String
}

// 2. Concrete Component (Base: USD Format -> $100.50)
struct BaseUSDPriceService: PriceService {
    private let rawPrices: [String: Decimal] = ["PROD-1": 100.50]

    func getFormattedPrice(for productID: String) async -> String {
        let price = rawPrices[productID] ?? 0.00
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.currencySymbol = "$"
        formatter.currencyGroupingSeparator = ","
        formatter.currencyDecimalSeparator = "."
        
        return formatter.string(from: price as NSDecimalNumber) ?? "$\(price)"
    }
}

// 3. Decorator (Output Formatter: Converts USD -> EUR Format -> 100,50 €)
struct EURCurrencyDecorator: PriceService {
    let wrapped: PriceService
    let usdToEurRate: Decimal = 0.92 // Exchange rate conversion

    func getFormattedPrice(for productID: String) async -> String {
        // 1. Get raw formatted output from wrapped USD service
        let usdString = await wrapped.getFormattedPrice(for: productID)
        
        // 2. Extract numeric value from USD string
        let cleanedString = usdString.replacingOccurrences(of: "$", with: "")
                                     .replacingOccurrences(of: ",", with: "")
        guard let usdAmount = Decimal(string: cleanedString) else { return "0,00 €" }

        // 3. Convert currency and reformat (Symbol on the RIGHT)
        let eurAmount = usdAmount * usdToEurRate
        
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.minimumFractionDigits = 2
        formatter.maximumFractionDigits = 2
        formatter.decimalSeparator = ","
        formatter.groupingSeparator = "."

        let formattedNumber = formatter.string(from: eurAmount as NSDecimalNumber) ?? "\(eurAmount)"
        
        // Action on Exit: Transformed into EUR format
        return "\(formattedNumber) €" 
    }
}

To support a new currency, simply create another decorator that handles the conversion from USD to that specific currency.

// Base Service (USD)
    let usdService: PriceService = BaseUSDPriceService()
    let usdPrice = await usdService.getFormattedPrice(for: "PROD-1")
    print("USD Base Output: \(usdPrice)") 
    // Output: $100.50

    // Decorator (EUR)
    let eurService: PriceService = EURCurrencyDecorator(wrapped: usdService)
    let eurPrice = await eurService.getFormattedPrice(for: "PROD-1")
    print("EUR Decorator Output: \(eurPrice)") 
    // Output: 92,46 €

Transformer

In this scenario, the decorator transforms inputs on the way in and reverses the operation on outputs on the way out. This dual role is well illustrated by a cryptography layer: while the client always interacts with clear, unencrypted data, the decorator automatically ciphers it before storage and deciphers it upon retrieval.

import Foundation
import CryptoKit

// 1. Interface & Raw Storage (Decoratee)
protocol StorageService {
    func save(id: String, payload: Data)
    func load(id: String) -> Data?
}

class RawStorageService: StorageService {
    private var db = [String: Data]()

    func save(id: String, payload: Data) {
        db[id] = payload
        print("💾 [Storage] Saved \(payload.count) raw bytes for '\(id)'")
    }

    func load(id: String) -> Data? {
        print("📖 [Storage] Reading raw bytes for '\(id)'")
        return db[id]
    }
}

// 2. Transformer Decorator (Encrypts on save, Decrypts on load)
struct CryptoDecorator: StorageService {
    let wrapped: StorageService
    let key: SymmetricKey

    func save(id: String, payload: Data) {
        // ENTRANCE: Encrypt Plaintext -> Ciphertext before storing
        guard let sealedBox = try? AES.GCM.seal(payload, using: key),
              let ciphertext = sealedBox.combined else { return }
        
        print("🔐 [Crypto] Encrypted \(payload.count) bytes -> \(ciphertext.count) bytes")
        wrapped.save(id: id, payload: ciphertext)
    }

    func load(id: String) -> Data? {
        // EXIT: Fetch Ciphertext -> Decrypt back to Plaintext
        guard let ciphertext = wrapped.load(id: id),
              let box = try? AES.GCM.SealedBox(combined: ciphertext),
              let plaintext = try? AES.GCM.open(box, using: key) else { return nil }
        
        print("🔓 [Crypto] Decrypted \(ciphertext.count) bytes back to original plaintext")
        return plaintext
    }
}

Its usage is as follows:

let key = SymmetricKey(size: .bits256)
let storage: StorageService = CryptoDecorator(wrapped: RawStorageService(), key: key)

let originalData = "Secret User Token".data(using: .utf8)!

// 1. Save (Encrypts on Entry)
storage.save(id: "user_session", payload: originalData)

// 2. Load (Decrypts on Exit)
if let loadedData = storage.load(id: "user_session"),
   let decryptedString = String(data: loadedData, encoding: .utf8) {
    print("Received: '\(decryptedString)'")
}

Compositon of decocators

Last but not least, composing multiple decorators is one of the core strengths of the Decorator pattern. Because every decorator implements the exact same interface as the component it wraps, you can seamlessly chain them together—nesting behavior like Russian dolls.


Here is a complete usage example combining two separate decorators around a single storage component:

let key = SymmetricKey(size: .bits256)
let rawStorage = RawStorage()

// Composition Chain: Client -> MonitorDecorator -> CryptoDecorator -> RawStorage
let pipeline: StorageService = MonitorDecorator(
    wrapped: CryptoDecorator(
        wrapped: rawStorage,
        key: key
    )
)

let payload = "Secret Data".data(using: .utf8)!

// Execution sequence:
// 1. Monitor starts timer -> Calls Crypto
// 2. Crypto encrypts payload -> Calls RawStorage
// 3. RawStorage saves to memory
// 4. Returns back through layers -> Monitor logs total duration
pipeline.save(id: "config", payload: payload)
let loaded = pipeline.load(id: "config")

Its usage is as follows:

let key = SymmetricKey(size: .bits256)
let storage: StorageService = CryptoDecorator(wrapped: RawStorageService(), key: key)

let originalData = "Secret User Token".data(using: .utf8)!

// 1. Save (Encrypts on Entry)
storage.save(id: "user_session", payload: originalData)

// 2. Load (Decrypts on Exit)
if let loadedData = storage.load(id: "user_session"),
   let decryptedString = String(data: loadedData, encoding: .utf8) {
    print("Received: '\(decryptedString)'")
}

Conclusions

Although the Decorator pattern can adopt different functional roles, confusing them for separate patterns is easy. At its core, the essence remains unchanged: adding or removing features dynamically alters capabilities, but never alters the fundamental nature of the underlying behavior.

References

There are two primary historical references linked to the formal documentation of the Decorator pattern:

  • 1994 (GoF Book): Design Patterns: Elements of Reusable Object-Oriented Software — Addison-Wesley published the definitive text defining the pattern.

  • 1991 (ET++ Framework / PhD Thesis): Erich Gamma’s doctoral dissertation at the University of Zurich, where early architectural precursors were documented prior to the GoF book.

Copyright © 2024-2026 JaviOS. All rights reserved