Strategy Design Pattern in Swift

Strategy Design Pattern in Swift

Hello everyone! A few weeks ago, I shared a demo explaining Integration Tests in Swift. In that demo, I created a Logger that interacts with the flow. While working on it, I realized the code was a great opportunity to introduce you to one of the most commonly used design patterns in software development: The Strategy Pattern.

In this article, I'll walk you through the basics of the Strategy Pattern, starting with a golf simulator demo and then transitioning to a realistic demo using my Logger class. We’ll conclude by discussing the pros and cons of this pattern. Let’s get started 😉!

What is the problem?

Let’s imagine that we want to create a golf simulator. This project will require to implement several features including hitting the ball with a golf club.

Mario Golf is the best golf simulator EVER. Change my mind!

In golf, you don’t use just one club for every shot. Instead, you choose different clubs depending on the situation, whether you need to hit a long-distance shot or carefully chip the ball onto the green. Overall, a golf player can use one of these types of clubs:

  • Driver: A club designed for long-distance shots, ideal for the tee shot or when the ball is far from the hole.

  • Iron: A versatile club, used for mid-range shots, balancing distance and accuracy.

  • Wedge: A club suited for short, controlled shots, useful for getting the ball close to the hole with precision.

I have implemented this piece of golf simulator writing all the logic for handling different clubs and their behaviors inside a single class:

enum Club {
    case driver
    case iron
    case wedge
}

class Golfer {
    func hitBall(using club: Club) {
        switch club {
        case .driver:
            print("Using Driver for a long-distance shot. Hits the ball 200 meters!")
        case .iron:
            print("Using Iron for a mid-range shot. Hits the ball 150 meters!")
        case .wedge:
            print("Using Wedge for a short, controlled shot. Hits the ball 50 meters!")
        }
    }
}

// Client usage
let golfer = Golfer()
golfer.hitBall(using: .driver) // "Using Driver for a long-distance shot. Hits the ball 200 meters!"
golfer.hitBall(using: .iron)   // "Using Iron for a mid-range shot. Hits the ball 150 meters!"
golfer.hitBall(using: .wedge)  // "Using Wedge for a short, controlled shot. Hits the ball 50 meters!"

This doesn’t look bad, since we simplified the feature to just three clubs, but in reality, this game will require a lot more clubs, similar to the right side in the picture above. If we try to make the code more realistic with all those clubs, we might end up with something like this:

enum Club {
    case driver
    case threeWood
    case fiveWood
    case sixIron
    case sevenIron
    case eightIron
    case nineIron
    case wedge
}

class Golfer {
    func hitBall(using club: Club) {
        switch club {
        case .driver:
            print("Using Driver for a long-distance shot. Hits the ball 250 meters!")
        case .threeWood:
            print("Using 3 Wood for a long-distance shot with more control. Hits the ball 220 meters!")
        case .fiveWood:
            print("Using 5 Wood for a long-distance shot with slightly more height. Hits the ball 200 meters!")
        case .sixIron:
            print("Using 6 Iron for a mid-range shot. Hits the ball 170 meters!")
        case .sevenIron:
            print("Using 7 Iron for a mid-range shot. Hits the ball 160 meters!")
        case .eightIron:
            print("Using 8 Iron for a mid-range shot. Hits the ball 150 meters!")
        case .nineIron:
            print("Using 9 Iron for a mid-range shot. Hits the ball 140 meters!")
        case .wedge:
            print("Using Wedge for a short, controlled shot. Hits the ball 50 meters!")
        }
    }
}

// Client usage
let golfer = Golfer()

// Hitting the ball with different clubs
golfer.hitBall(using: .driver)     // "Using Driver for a long-distance shot. Hits the ball 250 meters!"
golfer.hitBall(using: .threeWood)  // "Using 3 Wood for a long-distance shot with more control. Hits the ball 220 meters!"
golfer.hitBall(using: .fiveWood)   // "Using 5 Wood for a long-distance shot with slightly more height. Hits the ball 200 meters!"
golfer.hitBall(using: .sixIron)    // "Using 6 Iron for a mid-range shot. Hits the ball 170 meters!"
golfer.hitBall(using: .sevenIron)  // "Using 7 Iron for a mid-range shot. Hits the ball 160 meters!"
golfer.hitBall(using: .eightIron)  // "Using 8 Iron for a mid-range shot. Hits the ball 150 meters!"
golfer.hitBall(using: .nineIron)   // "Using 9 Iron for a mid-range shot. Hits the ball 140 meters!"
golfer.hitBall(using: .wedge)      // "Using Wedge for a short, controlled shot. Hits the ball 50 meters!"

Ok, now things are getting dirty. As you can see, each club has its own specific behavior when hitting the ball, but all this logic is packed into the Golfer class. This as a lot of disadvantages:

  1. Bloated Golfer Class:

    • The class is handling too much responsibility by managing the behavior of each club. As we add more clubs, the class becomes more difficult to maintain.
  2. Violation of Open/Closed Principle:

    • Every time we add a new club or change the behavior of an existing club, we need to modify the Golfer class. This makes the class prone to bugs, as changes could inadvertently break existing behavior.
  3. Limited Extensibility:

    • Let’s say we want to add additional functionality, like adjusting for wind or terrain. We would have to extend the switch statement with more conditions, making the code even more complex and harder to read.
  4. Lack of Flexibility:

    • If we wanted to change the behavior of a club dynamically (for example, the behavior of a Driver in windy conditions), it would require a lot of changes inside the Golfer class itself. This tightly couples the class to the specific behavior of each club, making it harder to adapt.

This is perfect case for the Strategy Pattern, since it allows us to separate each club’s behavior into its own class. This way, we can dynamically switch between different clubs (or strategies) without modifying the Golfer class. It also makes the system more flexible and extendable because we can add new clubs (strategies) without changing the core logic.

Now that you understand the problem, let’s dive into the Strategy pattern itself.

What is the Strategy Pattern?

The Strategy Pattern is a behavioral design pattern that allows you to define a set of algorithms, encapsulate each one as a separate class or struct, and make them interchangeable. This approach lets the algorithm vary independently from the clients that use it.

In simple terms, you can change how a particular task is done without altering the class that performs the task.

Key Components for Strategy Pattern

Let’s break down the essential components that make up the Strategy Pattern. For that, we will refactor the golf simulator code above.

In this example, each golf club represents a strategy that you can switch between, and the Golfer class is the context that chooses which strategy (club) to use for each shot.

Let me explain in detail each component that involves the strategy pattern development:

  1. Context (Main class)

    The Context is the class that contains a reference to a strategy. It’s responsible for executing a task, but it delegates the actual implementation of that task to a strategy object.

    The Context doesn’t know the details of the strategy, it just knows that it follows a certain interface. This allows the Context to remain flexible and easily switch between different strategies without modifying its own logic.

// Context (Golfer)
class Golfer {
    private var club: GolfClub

    init(club: GolfClub) {
        self.club = club
    }

    func setClub(_ club: GolfClub) {
        self.club = club
    }

    func hitBall() {
        print(club.hitBall())
    }
}
  1. Strategy (Protocol)

    The Strategy is an interface or protocol that defines a set of behaviors or algorithms that can be used interchangeably. The Context interacts with this interface, ensuring that any concrete strategy implementing it can be seamlessly swapped in.

// Strategy protocol
protocol GolfClub {
    func hitBall() -> String
}
  1. Concrete Strategies

    They are the specific implementations of the Strategy interface. Each concrete strategy provides a unique algorithm or behavior, allowing the Context to perform the task in different ways. You can have multiple concrete strategies, and the Context can switch between them at runtime.

// Concrete strategies (clubs)
class Driver: GolfClub {
    func hitBall() -> String {
        return "Hit the ball a long distance with the Driver! (250 meters)"
    }
}

class ThreeWood: GolfClub {
    func hitBall() -> String {
        return "Hit the ball with the 3 Wood! (220 meters)"
    }
}

class FiveWood: GolfClub {
    func hitBall() -> String {
        return "Hit the ball with the 5 Wood! (200 meters)"
    }
}

class SixIron: GolfClub {
    func hitBall() -> String {
        return "Hit the ball with the 6 Iron! (170 meters)"
    }
}

class SevenIron: GolfClub {
    func hitBall() -> String {
        return "Hit the ball with the 7 Iron! (160 meters)"
    }
}

class EightIron: GolfClub {
    func hitBall() -> String {
        return "Hit the ball with the 8 Iron! (150 meters)"
    }
}

class NineIron: GolfClub {
    func hitBall() -> String {
        return "Hit the ball with the 9 Iron! (140 meters)"
    }
}

class Wedge: GolfClub {
    func hitBall() -> String {
        return "Hit a short, controlled shot with the Wedge! (50 meters)"
    }
}
  1. Client

    The Client is the code that interacts with the Context and provides the strategy it should use. The Client decides which strategy is appropriate for the task at hand, and it can change the strategy during runtime if needed. This allows the Context to adapt to different conditions dynamically.

// Client usage
let golfer = Golfer(club: Driver())
golfer.hitBall() // "Hit the ball a long distance with the Driver! (250 meters)"

golfer.setClub(ThreeWood())
golfer.hitBall() // "Hit the ball with the 3 Wood! (220 meters)"

golfer.setClub(FiveWood())
golfer.hitBall() // "Hit the ball with the 5 Wood! (200 meters)"

golfer.setClub(SixIron())
golfer.hitBall() // "Hit the ball with the 6 Iron! (170 meters)"

golfer.setClub(SevenIron())
golfer.hitBall() // "Hit the ball with the 7 Iron! (160 meters)"

golfer.setClub(EightIron())
golfer.hitBall() // "Hit the ball with the 8 Iron! (150 meters)"

golfer.setClub(NineIron())
golfer.hitBall() // "Hit the ball with the 9 Iron! (140 meters)"

golfer.setClub(Wedge())
golfer.hitBall() // "Hit a short, controlled shot with the Wedge! (50 meters)"

This looks much better! The strategy pattern allows us to separate the context and logic for all the concrete strategies. Now let’s say that we want to implement a SuperPowerfulClub:

It’s as simple as creating the new club by conforming to the GolfClub protocol (the strategy), and that’s it! The Golfer can use this new club right off the bat:

// A new super strategy!
class SuperPowerfulClub: GolfClub {
    func hitBall() -> String {
        return "FIRE BALL!!! 🔥🔥🔥🔥"
    }
}

// Client usage
let golfer = Golfer(club: SuperPowerfulClub())
golfer.hitBall() // "FIRE BALL!!! 🔥🔥🔥🔥"

Implementing the clubs using the Strategy Pattern leads to a straightforward and clean refactor. Now that we've explored how the Strategy Pattern can improve a golf simulator, let’s see how it applies to my Logger code.

The Logger (A “realistic” example)

Now let’s look at the Logger I mentioned at the beginning. I created a Logger class to log messages in different ways. Sometimes we want to log to memory (great for testing), while other times we might want to log to a file or a third party analytics tool. I don’t want to clutter my Logger class with the specifics of how the logging happens of filling it with many if/else conditions. Instead, we can use the Strategy Pattern to encapsulate the logging behavior.

Here’s how I implemented it using Swift:

Step 1: Defining the LoggingStrategy Protocol

We start by defining a protocol for the logging strategies. Each strategy will follow this interface to ensure consistency.

protocol LoggingStrategy {
    func log(_ message: String)
    func clear()
    var loggedMessages: [String] { get }
}

Step 2: In-Memory Logging Strategy

The InMemoryStrategy stores log messages in memory. It’s quick and simple, ideal for cases where we don’t need to persist logs long-term.

class InMemoryStrategy: LoggingStrategy {
    private var logs: [String] = []

    func log(_ message: String) {
        logs.append(message)
        print("[InMemoryStrategy]: \(message)")
    }

    func clear() {
        logs.removeAll()
        print("[InMemoryStrategy] Cleared all log messages from memory.")
    }

    var loggedMessages: [String] {
        logs
    }
}

Step 3: File Logging Strategy

The FileLoggingStrategy stores log messages in a file. It’s useful when you need to persist logs beyond the runtime of the application.

import Foundation

class FileLoggingStrategy: LoggingStrategy {
    private let fileURL: URL

    init(fileURL: URL) {
        self.fileURL = fileURL
    }

    init(fileName: String) {
        self.fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
            .appendingPathComponent(fileName)
    }

    func log(_ message: String) {
        do {
            let data = (message + "\n").data(using: .utf8)!
            if FileManager.default.fileExists(atPath: fileURL.path) {
                let fileHandle = try FileHandle(forWritingTo: fileURL)
                fileHandle.seekToEndOfFile()
                fileHandle.write(data)
                fileHandle.closeFile()
            } else {
                try data.write(to: fileURL)
            }
            print("[FileLoggingStrategy]: \(message)")
        } catch {
            print("Failed to log message to file: \(error.localizedDescription)")
        }
    }

    var loggedMessages: [String] {
        do {
            let data = try Data(contentsOf: fileURL)
            let content = String(data: data, encoding: .utf8)
            return content?.components(separatedBy: "\n").filter { !$0.isEmpty } ?? []
        } catch {
            print("Failed to read log messages from file: \(error.localizedDescription)")
            return []
        }
    }

    func clear() {
        do {
            try "".write(to: fileURL, atomically: true, encoding: .utf8)
            print("[FileLoggingStrategy] Cleared all log messages from file.")
        } catch {
            print("Failed to clear log messages: \(error.localizedDescription)")
        }
    }
}

Step 4: The Logger Class

We now introduce a Logger class (Context) to tie everything together. The Logger will accept any strategy that conforms to the LoggingStrategy protocol. This makes it easy to swap strategies at runtime.

class Logger {
    private var strategy: LoggingStrategy

    init(strategy: LoggingStrategy) {
        self.strategy = strategy
    }

    func setStrategy(_ strategy: LoggingStrategy) {
        self.strategy = strategy
    }

    func log(_ message: String) {
        strategy.log(message)
    }

    var loggedMessages: [String] {
        strategy.loggedMessages
    }

    func clear() {
        strategy.clear()
    }
}

The Logger class gives us a flexible API to log messages, whether it's to memory, a file or something else in the future such as an abstraction for a third party analytics framework.

Step 5: Predefined Logger Strategies

To make the Logger even easier to use, I’ve added two helper methods to initialize it with the predefined strategies:

extension Logger {
    static var inMemory: Logger {
        return Logger(strategy: InMemoryStrategy())
    }

    static func fileLogging(fileName: String = "onlineStoreApp.log") -> Logger {
        let fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
            .appendingPathComponent(fileName)
        return fileLogging(fileURL: fileURL)
    }

    static func fileLogging(fileURL: URL) -> Logger {
        return Logger(strategy: FileLoggingStrategy(fileURL: fileURL))
    }
}

And now all the clients can call the Logger in a very simple way:

// ==== Production =====
struct RootView: View {
    @State private var cartStore = CartStore(logger: .fileLogging())
    @State private var productStore = ProductStore(
        discountCalculator: .init(discountProvider: .live),
        logger: .fileLogging()
    )
    // More code...
}

// ===== Testing =====
@Test
func fetchThreeProductsFromAPI() async {
    let productStore = ProductStore(
       apiClient: .testSuccess,
       databaseClient: .inMemory,
       discountCalculator: .init(discountProvider: .empty),
       logger: .inMemory
    )
    // More code...
}

To learn more, I recommend watching my Integration Test video and reviewing the source code. This will give you a clear idea of how to use the Strategy Pattern for this and other features.

Pros and Cons of the Strategy Pattern

Like any design pattern, the Strategy Pattern has its advantages and disadvantages:

Pros:

  • ✅ Flexibility: Switch between different algorithms (strategies) at runtime.

  • ✅ Separation of Concerns: Keeps the core logic of your class separate from the implementation details of the algorithms.

  • ✅ Reusability: Strategies can be reused across different classes or projects.

  • ✅ Encapsulation: Each strategy is isolated in its own class, making it easier to maintain and extend.

  • ✅ It adheres to the Open/Closed Principle.

Cons:

  • ❌ Increased Complexity: Adding too many strategies can make the codebase harder to navigate.

  • ❌ Overhead: If you only need one or two strategies, implementing the Strategy Pattern might be overkill.

Wrap Up

In this article, we explored the Strategy Pattern through a golf simulator and a Logger class. By applying the pattern, we simplified our code by separating behaviors into individual strategies, making it more flexible and maintainable.

The golf simulator started with all club logic inside one class, which quickly became complex. Using the Strategy Pattern, we isolated each club's behavior, allowing dynamic changes without altering the main class. Similarly, we applied this pattern to the Logger, making it easy to switch between logging methods without modifying core logic.

The Strategy Pattern helps you keep code clean and adaptable but can introduce complexity if overused. It’s a great solution when you need to swap behaviors easily and extend your system without modifying existing code. Just be careful not to overuse it.

I encourage you to try it in your own projects and check out my other design pattern articles, like this about State Pattern in Swift.

That’s all for me. Remember, my name is Pitt and this… this is swiftandtips.com! Thanks for reading and have a great day! 😃