Design PatternsC++OOPSoftware ArchitectureSOLID Principles

The Strategy Design Pattern — Explained Simply

10 min read

The Strategy Design Pattern — Explained Simply

Build flexible code that's easy to extend, using a notification system as our guide


Table of Contents

  1. The Problem
  2. The Strategy Pattern
  3. A Real-World Analogy
  4. The UML Diagram
  5. Step 1 — The Strategy Interface
  6. Step 2 — Concrete Strategies
  7. Step 3 — The Context
  8. Step 4 — Putting It All Together
  9. The Superpower — Adding New Strategies
  10. When to Use It
  11. Common Mistakes
  12. Summary

The Problem

You're building an app that sends notifications to users. You start with email — easy:

class NotificationService {
public:
    void sendAlert(string message) {
        cout << "[EMAIL] " << message << endl;
    }
};

Then your boss asks for SMS. You add an if:

void sendAlert(string message, string method) {
    if (method == "email") {
        // email logic...
    } else if (method == "sms") {
        // sms logic...
    }
}

Then comes Push, Slack, WhatsApp, Teams. Soon your method is 200 lines of if/else if chains. Every new channel means modifying this class and risking regressions in code that already works.

Look closely at those branches. The only thing that varies between them is how the message gets delivered. The decision to notify and the message itself stay constant — only the delivery algorithm changes.

So why is all that varying logic crammed into one class? That's the question the Strategy Pattern answers.


The Strategy Pattern

The Strategy Pattern says:

Encapsulate the part that changes (the delivery algorithm) behind a common interface, and let the main class delegate to it through that interface.

It has three pieces:

  1. Strategy interface — defines the contract that all algorithms must follow (e.g., "any notifier must implement send()")
  2. Concrete strategies — the actual implementations (Email, SMS, Push). Each is its own class.
  3. Context — the class that uses a strategy through the interface, without knowing which concrete implementation it holds

The Context calls send() on the interface. At runtime, the actual implementation that runs depends on which concrete strategy is currently plugged in — a behavior known as runtime polymorphism. Because the Context only depends on the interface, you can swap strategies freely without modifying it.


A Real-World Analogy

Think about ordering food at a restaurant.

You walk in and tell the waiter: "I'll have the pasta." The waiter writes it down and passes the order to the kitchen. The waiter doesn't cook the food and doesn't even know how the pasta is made — that's the chef's job.

Now imagine the restaurant changes their pasta recipe. The waiter's job doesn't change at all. If they add a new dish to the menu, the waiter doesn't need retraining — a new chef handles it. The waiter remains decoupled from the kitchen's internals.

In our pattern:

  • The waiter is the Context (our NotificationService) — takes orders, delegates work
  • The menu is the Strategy interface — the contract listing what's possible
  • Each chef/recipe is a Concrete Strategy (Email, SMS, Push) — does the actual work

The waiter doesn't write if (dish == "pasta") cook_pasta() else if (dish == "pizza") cook_pizza(). The waiter delegates. That's exactly what we want our NotificationService to do.


The UML Diagram

Here's the structure we're building:

              +-------------------------------+
              |    NotificationStrategy       |
              |        (interface)            |
              +-------------------------------+
              | + send(msg): void             |
              +-------------------------------+
                  ^         ^         ^
                  |         |         |
                  | (implements)      |
                  |         |         |
        +-----------+ +-----------+ +-----------+
        |  Email    | |   SMS     | |   Push    |
        | Strategy  | | Strategy  | | Strategy  |
        +-----------+ +-----------+ +-----------+
        | + send()  | | + send()  | | + send()  |
        +-----------+ +-----------+ +-----------+


        +---------------------------------------+
        |        NotificationService            |
        |             (context)                 |
        +---------------------------------------+
        | - strategy: NotificationStrategy*     |
        +---------------------------------------+
        | + setStrategy(strategy)               |
        | + notify(msg)  --> calls strategy     |
        +---------------------------------------+
                          |
                          | delegates to
                          v
                  NotificationStrategy

Reading the diagram:

  • The interface at the top (NotificationStrategy) defines the contract every strategy must follow. It is an abstract class — it cannot be instantiated directly because it has no implementation; it only declares what methods must exist.
  • The three concrete classes (EmailStrategy, SMSStrategy, PushStrategy) each provide their own implementation of the contract. The arrows pointing up represent inheritance — each derived class commits to providing every method declared in the interface.
  • The Context (NotificationService) holds a pointer to the interface type. When notify() is called, it dispatches to strategy->send(). The actual method that runs is resolved at runtime based on which concrete object the pointer references — this is runtime polymorphism in action.

Step 1 — The Strategy Interface

The contract that every notification method must follow.

class NotificationStrategy {
public:
    // Pure virtual function:
    // Forces all derived classes to implement send().
    // Makes this class abstract (cannot be instantiated).
    virtual void send(string message) = 0;
 
    // Virtual destructor:
    // Ensures the correct destructor is called when deleting
    // objects via a base class pointer (prevents undefined behavior).
    virtual ~NotificationStrategy() {}
};

What = 0 Does

Adding = 0 to a virtual function makes it a pure virtual function. This has three effects:

  1. The class becomes an abstract class — it cannot be instantiated directly
  2. Any class that inherits from it must implement every pure virtual function, or it too becomes abstract
  3. The compiler will produce an error if a derived class is used to create an object without first implementing all pure virtual functions

This is how the contract is enforced at compile time, before your program ever runs.

Why the Virtual Destructor Matters

When you delete an object through a base-class pointer (delete strategy), C++ needs to know to call the derived class's destructor — otherwise resources held by the derived class never get cleaned up. Without a virtual destructor, this leads to undefined behavior: typically a resource leak, but the standard makes no guarantees about what actually happens.

The fix is one line: virtual ~NotificationStrategy() {}. Always include it on any class meant to be used as a base.


Step 2 — Concrete Strategies

Each notification channel is a separate class implementing the interface.

class EmailStrategy : public NotificationStrategy {
public:
    void send(string message) override {
        cout << "[EMAIL] Connecting to SMTP..." << endl;
        cout << "[EMAIL] Body: " << message << endl;
    }
};
 
class SMSStrategy : public NotificationStrategy {
public:
    void send(string message) override {
        cout << "[SMS] Calling Twilio API..." << endl;
        cout << "[SMS] Text: " << message << endl;
    }
};
 
class PushStrategy : public NotificationStrategy {
public:
    void send(string message) override {
        cout << "[PUSH] Pinging device..." << endl;
        cout << "[PUSH] Alert: " << message << endl;
    }
};

The override Keyword

override declares the programmer's intent to replace a virtual function from the base class. The compiler verifies this: if the function signature doesn't match a virtual function in the parent (e.g., misspelled name, wrong parameter type), compilation fails. It's a low-cost guarantee against silent bugs caused by accidental method shadowing.

Independence Between Strategies

Notice that each class only references itself and the interface. EmailStrategy has no knowledge of SMSStrategy. They share only the contract defined by NotificationStrategy. This independence is what makes the pattern scalable — adding a new channel later cannot disturb the existing ones because they don't reference each other.


Step 3 — The Context

The class that uses a strategy through the interface, without knowing which concrete type it holds.

class NotificationService {
private:
    // Holds any object whose type derives from NotificationStrategy.
    // The Context only knows the interface, never the concrete class.
    NotificationStrategy* strategy;
 
public:
    NotificationService() : strategy(nullptr) {}
 
    // Inject (or replace) the strategy at runtime
    void setStrategy(NotificationStrategy* s) {
        strategy = s;
    }
 
    // Delegate the work to whichever strategy is currently set
    void notify(string message) {
        if (!strategy) {
            cout << "ERROR: no strategy set!" << endl;
            return;
        }
        strategy->send(message);   // <-- THE KEY LINE
    }
};

How the Key Line Works

The line strategy->send(message) is where the entire pattern pays off.

strategy is a pointer of type NotificationStrategy*, but at runtime it actually points to a concrete object — EmailStrategy, SMSStrategy, or PushStrategy.

The result: one line of code in the Context can trigger entirely different behavior depending on which strategy was injected — and the Context itself contains zero conditional logic.


Step 4 — Putting It All Together

int main() {
    NotificationService service;
 
    EmailStrategy email;
    SMSStrategy sms;
    PushStrategy push;
 
    // Send via email
    service.setStrategy(&email);
    service.notify("Your order has been placed!");
 
    // Switch to SMS at runtime — no code change in the service
    service.setStrategy(&sms);
    service.notify("Your OTP is 482910");
 
    // Switch to push
    service.setStrategy(&push);
    service.notify("You have 3 new messages");
 
    return 0;
}

Output:

[EMAIL] Connecting to SMTP...
[EMAIL] Body: Your order has been placed!
[SMS] Calling Twilio API...
[SMS] Text: Your OTP is 482910
[PUSH] Pinging device...
[PUSH] Alert: You have 3 new messages

The delivery method was swapped three times at runtime without modifying a single line inside NotificationService. The Context delegated; the strategies did the work. Notice that strategy selection happens outside the service — in main(), where it belongs. The Context never needs to know which channels exist.


The Superpower — Adding New Strategies

Boss comes back: "We need Slack notifications."

Here's the complete code change required:

// New file. No existing code is touched.
class SlackStrategy : public NotificationStrategy {
public:
    void send(string message) override {
        cout << "[SLACK] Posting to #engineering: " << message << endl;
    }
};
// Use it
SlackStrategy slack;
service.setStrategy(&slack);
service.notify("Build #412 passed!");

Existing files modified: zero. NotificationService, EmailStrategy, SMSStrategy — all untouched. An entire new behavior was added by writing one new class.

This is the Open/Closed Principle in action: software entities should be open for extension (new strategies welcome) but closed for modification (existing code stays untouched). Compare this to the original if/else approach where adding Slack meant editing the giant method — and risking regressions in email and SMS handling.


When to Use It

Use Strategy when:

  • You see a chain of if/else or switch statements that all do the same kind of thing in different ways — this is the #1 signal
  • You need to swap behavior at runtime (user choice, config, environmental conditions)
  • You want to add new behaviors over time without modifying existing code

Skip it when:

  • You have only two simple options unlikely to grow — a plain if/else is sufficient
  • The variants share most of their code — extract the one thing that differs (e.g., a small helper function) instead of introducing a full strategy hierarchy

Summary

The Strategy Pattern in one sentence:

Extract the part that varies into its own class behind an interface, so it can be swapped without modifying the code that uses it.

The three pieces:

  1. Interface — the abstract contract
  2. Concrete strategies — the implementations
  3. Context — depends only on the interface; never on the concrete classes

Found something incorrect?

If you spot any errors, outdated information, or have suggestions to improve this article, I'd love to hear from you. Feel free to reach out through any of the channels below.

Looking for a developer?

I'm a freelance web developer based in Hyderabad specializing in React & Next.js. If you need help with web application development or building a high-converting landing page, I'd love to chat.

Share this post