Design Patterns · Beginner-Friendly Guide

Strategy Pattern for Dummies

Kill the if/else chain. Build a real C# payment & discount console app where each algorithm lives in its own swappable class — picked by a factory, chained by a composite, consumed by a service that doesn't care.

No prior patterns needed C# console app Bad vs. Good code
01

The problem: if/else hell

You're building an online checkout. Customers can pay by card or cash. Regulars get 10% off, VIPs get 20% off, and any order over €1,000 gets an extra €50 off. Easy, right? You open PaymentService and write a few ifs.

Then product asks for PayPal. Then crypto. Then "Black Friday gives everyone an extra 30% but only on Fridays in November." Your method is now 200 lines and every change risks breaking the others. You're afraid to touch it.

That fear is the design pattern radar going off. The shape of your problem — many ways of doing the same thing, picked at runtime — has a name. It's the Strategy pattern, and it's the simplest, highest-ROI pattern you'll ever learn.

Brain Power

Look at the "bad" code in the next card. To add a new payment method (say, PayPal), how many existing methods do you have to edit? Now imagine three teams adding three new methods in parallel. What goes wrong at merge time?

You have to edit ProcessPayment and probably the discount logic too — every developer touches the same file, every merge produces conflicts, and one mistake breaks everyone's payment path. That's exactly the smell Strategy fixes. New behaviour = new class, not an edit to an existing one. (Yes — that's the Open/Closed Principle in disguise.)
❌ Bad

One method, many reasons to change

Everything crammed into one place. Adding a payment method or a discount rule means re-opening this method and risking the existing branches. This is what you're about to refactor away.

// ❌ BAD — every new rule = an edit to this same method
public void ProcessPayment(Order order, string method)
{
    decimal total = order.Price * order.Quantity;

    // discount rules
    if (order.CustomerType == "vip")      total -= total * 0.20m;
    else if (order.CustomerType == "reg") total -= total * 0.10m;
    if (total > 1000m)                    total -= 50m;
    // tomorrow: black friday? edit here.
    // next week: loyalty points?  edit here.

    // payment rules
    if (method == "card")
        Console.WriteLine($"Card payment of {total:C}");
    else if (method == "cash")
        Console.WriteLine($"Cash payment of {total:C}");
    // tomorrow: paypal? crypto? applepay? edit here too.
    else
        throw new ArgumentException("Unknown method");
}
Preview

What we'll build instead

By the end of this page you'll have the same console app — but split into tiny, swappable classes. Adding PayPal will be a 15-line file that you drop in. No existing class will change.

// ── The shape of the refactored solution ───────────
Interface/
  IPaymentStrategy.cs       ← contract for "how to pay"
  IDiscountStrategy.cs      ← contract for "how to discount"

Strategies/
  CardPaymentStrategy.cs    ← one way to pay
  CashPaymentStrategy.cs    ← another way to pay
  RegularDiscountStrategy.cs
  VipDiscountStrategy.cs
  BigOrderDiscountStrategy.cs
  BlackFridayDiscount.cs    ← drop-in, nobody else changes
  CompositeDiscountStrategy.cs  ← chains discounts

Factory/
  PaymentStrategyFactory.cs ← picks a payment strategy
  DiscountStrategyFactory.cs ← builds a composite discount

Service/
  PaymentService.cs         ← orchestrates, knows no concretes

Program.cs                  ← reads input, hands off

// Promise: every new payment method or discount rule
//          will be a NEW file, never an edit. That's Strategy.
Section 1 wrap — what you now know
  • The smell that calls for Strategy: a growing if/else or switch that picks how to do something based on a "type" value.
  • Each branch of that switch is a strategy waiting to be extracted into its own class.
  • The win isn't fewer lines today — it's that tomorrow's new behaviour is a new file, not an edit to an existing one.
  • That is literally the Open/Closed Principle: open for extension, closed for modification.
02

The Strategy idea in one sentence

Here it is — the entire pattern, one sentence: define a family of algorithms, put each in its own class, and make them interchangeable behind a common interface.

Think of a navigation app. You ask for directions; you pick "driving", "walking", or "cycling". The app doesn't have a giant if (mode == "driving") … else if (mode == "walking") …. It has an IRouteStrategy with three implementations — CarRoute, WalkRoute, BikeRoute — and a tiny piece of code that picks one based on your choice and asks it for the route. Adding ScooterRoute tomorrow is a new file. Done.

That's what we're about to do to our checkout. The "algorithm" is "how to charge the customer" and "how to discount the order". Each one becomes a class. The service just talks to the interface.

There are no dumb questions
Isn't this just polymorphism with extra steps?
Strategy is polymorphism — applied with intent. Polymorphism is the C# language feature; Strategy is the design decision to lean on it for picking algorithms at runtime instead of hard-coding them.
What's the difference between Strategy and the State pattern? They look identical.
Structurally similar, semantically different. State models "what the object currently is and how that controls its behaviour"; the object changes its own state over time. Strategy models "which algorithm to plug in"; the caller picks it and the strategy is usually stateless.
Do strategies have to be stateless?
Not strictly, but prefer it. A stateless strategy is trivially thread-safe and can be cached in a dictionary (which is exactly what the factory in section 5 does). If a strategy needs data, pass it via the method parameter, not via fields.
Is this overkill for two payment methods?
For exactly two that will never change? Probably. The moment you suspect a third will appear, or the branches start having their own private helpers, extract. The cost of refactoring later is always higher than starting clean now.
03

Step 1 — define the contract

Every Strategy implementation starts with one decision: what's the smallest possible interface that captures "do the thing"?

For payment, the "thing" is: given a total, charge it. For discount, the "thing" is: given a running total, return a new (smaller) total. That's it. One method each. Tiny interfaces are powerful interfaces — they're easy to implement, easy to mock, and impossible to misuse.

This is also the Interface Segregation Principle from SOLID: clients shouldn't depend on methods they don't use. If your interface has five methods and a strategy only cares about one of them, the interface is too fat.

Contract

IPaymentStrategy — one method

Anything that knows how to charge a customer implements this interface. The service will only ever see this type — never the concrete class.

namespace ConsoleApp34.Interface;

public interface IPaymentStrategy
{
    // Given a final total, perform the payment.
    // No return value — payment either succeeds or throws.
    void Process(decimal total);
}

// That's the whole contract. Resist the urge to add
// Validate(), GetFee(), GetReceipt() "while you're here".
// Each addition forces EVERY implementer to provide it.
Contract

IDiscountStrategy — also one method

A discount takes the current total and returns a new one. By returning a decimal (instead of mutating something), discounts compose — the output of one feeds the input of the next. (You'll see why that matters in Section 6.)

namespace ConsoleApp34.Interface;

public interface IDiscountStrategy
{
    // Pure function: take the running total,
    // return the new total after applying this discount.
    decimal Apply(decimal currentTotal);
}

// Two interfaces, one method each.
// Total of 4 lines of code that will save you 400.
Watch Out

A common rookie move is to pass the whole Order object into Apply. Tempting, because then a discount can inspect anything about the order. Resist. The narrower the input, the more reusable the strategy. Need extra data? Push the decision up to the factory (Section 5) — let it pick which strategies apply, then hand each one only the minimum it needs.

04

Step 2 — implement the concrete strategies

Now the fun part: each algorithm becomes a class that does one thing. No inheritance trees, no shared base class, no magic — just small classes that implement a small interface.

Notice how short each file is. That's not a coincidence — that's the whole point. A class you can read in 10 seconds is a class you can test, swap, and trust.

✅ Strategy

CardPaymentStrategy

"How to pay by card." Real life would call a gateway here; for the console app we just print. The interface doesn't care.

using ConsoleApp34.Interface;

namespace ConsoleApp34.Strategies;

public class CardPaymentStrategy : IPaymentStrategy
{
    public void Process(decimal total)
    {
        Console.WriteLine($"Processing card payment of {total:C}");
    }
}
✅ Strategy

CashPaymentStrategy

"How to pay by cash." Same shape, different body. The two classes don't know each other exist — and they don't need to.

using ConsoleApp34.Interface;

namespace ConsoleApp34.Strategies;

public class CashPaymentStrategy : IPaymentStrategy
{
    public void Process(decimal total)
    {
        Console.WriteLine($"Processing cash payment of {total:C}");
    }
}
Family

Discount strategies — same idea, different rule

Four little classes. Each one implements Apply(decimal) and minds its own business. BlackFridayDiscount isn't even wired up yet — that's deliberate. It proves the point: a strategy can exist before any caller uses it, ready to plug in.

public class RegularDiscountStrategy : IDiscountStrategy
{
    public decimal Apply(decimal currentTotal)
        => currentTotal - currentTotal * 0.1m;       // -10%
}

public class VipDiscountStrategy : IDiscountStrategy
{
    public decimal Apply(decimal currentTotal)
    {
        Console.WriteLine("VIP applied");
        return currentTotal - currentTotal * 0.2m;   // -20%
    }
}

public class BigOrderDiscountStrategy : IDiscountStrategy
{
    public decimal Apply(decimal currentTotal)
    {
        if (currentTotal > 1000m) return currentTotal - 50m;
        return currentTotal;                          // -€50 over €1000
    }
}

public class BlackFridayDiscount : IDiscountStrategy
{
    public decimal Apply(decimal currentTotal)
    {
        Console.WriteLine("Black Friday discount applied");
        return currentTotal - currentTotal * 0.3m;   // -30% (drop-in)
    }
}
Sharpen your pencil

Write a PayPalPaymentStrategy that prints "Processing PayPal payment of {total:C} (incl. 2% fee)" and includes a 2% surcharge in the printed amount. How many existing files do you need to edit to add it?

Reveal the answer + code
Zero existing files. Just add one new file:

using ConsoleApp34.Interface;

namespace ConsoleApp34.Strategies;

public class PayPalPaymentStrategy : IPaymentStrategy
{
    public void Process(decimal total)
    {
        var withFee = total * 1.02m;
        Console.WriteLine(
            $"Processing PayPal payment of {withFee:C} (incl. 2% fee)");
    }
}

(In Section 5 you'll see the ONE place where a new
 entry gets added to a dictionary so the factory can
 hand it out — but no existing strategy is touched.)
05

Step 3 — let a factory pick the strategy

Somebody still has to decide which strategy to use. The user typed "card" — that string has to become a CardPaymentStrategy somewhere. We don't want that decision in the service (it'd just be the same switch in a different room). We park it in a Factory.

The factory is the only place that knows the concrete classes exist. The service depends on the interface; the factory hands it an instance. Now there's exactly one file to edit when a new payment method ships — and it's a one-line dictionary entry.

Factory

PaymentStrategyFactory — dictionary lookup

A dictionary keyed by an enum beats a switch for two reasons: (1) it's data, not control flow — you can extend it without changing logic; (2) the strategies are instantiated once and reused (cheap, thread-safe when stateless).

using ConsoleApp34.Interface;
using ConsoleApp34.Strategies;

namespace ConsoleApp34;

public class PaymentStrategyFactory
{
    // One instance per strategy — built once, reused forever.
    private readonly Dictionary<PaymentMethod, IPaymentStrategy> _strategies = new()
    {
        [PaymentMethod.Card] = new CardPaymentStrategy(),
        [PaymentMethod.Cash] = new CashPaymentStrategy(),
        // [PaymentMethod.PayPal] = new PayPalPaymentStrategy(),  ← drop-in
    };

    public IPaymentStrategy Create(PaymentMethod method) => _strategies[method];
}

// PaymentMethod is just a tiny enum that bounds valid input:
public enum PaymentMethod { Card, Cash }
Factory

DiscountStrategyFactory — picks several at once

Discounts stack, so the factory doesn't return one strategy — it builds a list and wraps it in a Composite (Section 6). The service still gets back a single IDiscountStrategy and doesn't know any of this happened.

using ConsoleApp34.Interface;
using ConsoleApp34.Strategies;

namespace ConsoleApp34;

public class DiscountStrategyFactory
{
    public IDiscountStrategy Create(Order order)
    {
        var strategies = new List<IDiscountStrategy>();

        // VIPs get the VIP discount; everyone else gets the regular one.
        if (order.CustomerType == "vip")
            strategies.Add(new VipDiscountStrategy());
        else
            strategies.Add(new RegularDiscountStrategy());

        // Big orders always get an extra €50 off, on top of the above.
        strategies.Add(new BigOrderDiscountStrategy());

        // Wrap the whole list as a single IDiscountStrategy.
        return new CompositeDiscountStrategy(strategies);
    }
}
Brain Power

The DiscountStrategyFactory still contains an if on CustomerType. Did we just push the problem down one layer instead of solving it? Why is this okay here — and where would it stop being okay?

It's okay because the if is now doing one job: picking which strategies apply. The interesting behaviour — the actual discount math — lives in the strategies and never gets edited when a new one is added. The line moves out of "okay" the moment that if grows past three or four branches; then the factory itself becomes the bottleneck and you swap it for a registry, attributes, or DI container resolution.
06

Step 4 — chain them with a Composite

Here's a beautiful trick: a strategy can itself be a list of strategies. CompositeDiscountStrategy implements IDiscountStrategy — it looks like a strategy from the outside — but inside it just loops over a bunch of other strategies and threads the running total through each one.

This is the Composite pattern applied to Strategy. It's how Strategy graduates from "pick one of N" to "apply M of them in order". And because it's the same interface, the caller never knows whether they're getting one discount, three, or zero.

using ConsoleApp34.Interface;

namespace ConsoleApp34.Strategies;

public class CompositeDiscountStrategy(List<IDiscountStrategy> strategies)
    : IDiscountStrategy
{
    private readonly List<IDiscountStrategy> _strategies = strategies;

    public decimal Apply(decimal currentTotal)
    {
        decimal total = currentTotal;

        foreach (var strategy in _strategies)
        {
            Console.WriteLine($"Applying {strategy.GetType().Name}");
            total = strategy.Apply(total);   // output of one = input of next
        }

        return total;
    }
}

// Why this is special:
//  • It IS-A IDiscountStrategy, so callers don't notice it.
//  • It HAS-A list of IDiscountStrategy, so it can run any number of them.
//  • Order matters: VIP -20% first, THEN big-order -€50 = different result
//    than the reverse. The factory decides the order.
There are no dumb questions
What happens if the list is empty?
The loop runs zero times and currentTotal is returned unchanged — a "no discount" composite. That's a feature, not a bug: it lets the factory unconditionally wrap whatever it built without special-casing the empty case.
Could a composite contain another composite?
Yes — and that's the magic. You can group discounts ("loyalty bundle" = customer + frequent-buyer + birthday) into one composite, then put that composite into a larger one alongside "BigOrder". The caller still sees a single IDiscountStrategy.
Isn't logging from inside the composite a side effect?
It is, and in production you'd inject an ILogger rather than calling Console.WriteLine directly. For a teaching console app it's fine; for a real service, it's exactly the kind of thing Dependency Inversion is for.
07

Step 5 — wire it up with a thin service

Now the payoff. PaymentService orchestrates everything in six lines of method body. It depends on the two factories (which depend on the two interfaces). It doesn't import any concrete strategy. Adding PayPal does not touch this file. Adding Black Friday does not touch this file. That is what closed-for-modification looks like in practice.

✅ Service

PaymentService — zero ifs

Read this and count the conditionals. There are none. The factories pick; the composite chains; the strategy charges. The service just connects them.

namespace ConsoleApp34.Service;

public class PaymentService(
    DiscountStrategyFactory discountFactory,
    PaymentStrategyFactory  paymentFactory)
{
    private readonly DiscountStrategyFactory _discountFactory = discountFactory;
    private readonly PaymentStrategyFactory  _paymentFactory  = paymentFactory;

    public void ProcessPayment(Order order, PaymentMethod paymentMethod)
    {
        var discount = _discountFactory.Create(order);          // ← which discounts?
        var payment  = _paymentFactory.Create(paymentMethod);   // ← which payment?

        decimal subtotal = order.GetSubtotal();
        decimal total    = discount.Apply(subtotal);            // ← apply (maybe chained)
        payment.Process(total);                                 // ← charge
    }
}

// Six lines of body. Compare with the 20-line `if/else` from Section 1.
// Now extend the system: count how many lines you have to ADD to support
// PayPal. (Answer: ~10 in a new file, plus one dictionary line in the factory.)
Entry point

Program.cs — read input, hand off

The composition root: read the user's input, build the order, construct the factories, hand them to the service, and run. In an ASP.NET Core app, this whole block is replaced by a DI container — the philosophy is identical.

using ConsoleApp34;
using ConsoleApp34.Service;

var order = new Order
{
    CustomerType = GetCustomerType().Trim().ToLower(),
    Price        = GetPrice(),
    Quantity     = GetQuantity()
};

if (!TryGetPaymentMethod(out var paymentMethod))
{
    Console.WriteLine("Invalid payment method.");
    return;
}

// ── The composition root ─────────────────────────────
var paymentService = new PaymentService(
    new DiscountStrategyFactory(),
    new PaymentStrategyFactory());

paymentService.ProcessPayment(order, paymentMethod);
Console.WriteLine("Order complete!");

// Helper methods omitted for brevity — they just Console.ReadLine.
Sharpen your pencil

A VIP customer buys 4 items at €300 each. Trace by hand what the console prints, step by step. (Hint: subtotal first, then the composite walks the list — VIP, then BigOrder — then the payment strategy fires.)

Reveal the trace
Subtotal:  4 × €300                       = €1,200.00

Composite kicks in:
  → "Applying VipDiscountStrategy"
  → "VIP applied"
  → 1200 − 1200 × 0.20                    = €960.00

  → "Applying BigOrderDiscountStrategy"
  → 960 > 1000? NO  → no €50 off          = €960.00

Payment:
  → "Processing card payment of €960.00"

Final:
  → "Order complete!"

⚠️  Notice: BigOrder checks the CURRENT total (€960), not the
   subtotal (€1,200). Order of strategies in the factory matters.
Watch Out

The order in which the factory appends strategies to the composite is part of the business logic. "VIP first, then €50 off the discounted total" gives a different result from "€50 off raw, then VIP %". If anyone touches that order, your prices change. Pin the expected sequence with a unit test before you ship.

08

Recap & where to go next

You started with one 20-line method that everyone was afraid to touch. You ended with a handful of tiny classes, each doing one thing, glued together by two factories and a six-line service. Adding new behaviour is now a new file — never an edit. That is the Strategy pattern.

Big Picture

The whole flow on one screen

Strategy + Factory + Composite is one of the most common "trio" combinations in real codebases. Memorise the shape — you'll recognise it everywhere from ASP.NET Core middleware to ETL pipelines.

// User input  ──>  PaymentService
//                  │
//                  ├──> DiscountStrategyFactory.Create(order)
//                  │       └──> CompositeDiscountStrategy(
//                  │              [ VipDiscountStrategy,
//                  │                BigOrderDiscountStrategy ])
//                  │
//                  ├──> PaymentStrategyFactory.Create(method)
//                  │       └──> CardPaymentStrategy
//                  │
//                  ├──> total = discount.Apply(subtotal)
//                  └──> payment.Process(total)
//
// Add PayPal?      → +1 new file in /Strategies + 1 line in factory.
// Add Black Friday → +1 dictionary entry to enable it. ALREADY written.
// Add a logger?    → wrap each strategy in a LoggingDecorator (next pattern!).
Cheatsheet

When to reach for Strategy

A pocket guide. Pin it next to your monitor for the next time you catch yourself writing a third else if.

// ── Symptoms (use Strategy) ─────────────────────
//  • A switch on a "type" / "kind" / "mode" string or enum.
//  • Several methods doing "the same thing, different way".
//  • Every new variant requires editing an existing method.
//  • You can describe the choice as a noun: "a discount", "a sort", "an exporter".

// ── Recipe ──────────────────────────────────────
//  1. Define a tiny interface (1 method if possible).
//  2. Move each branch's code into its own class implementing the interface.
//  3. Add a factory (dictionary, switch, or DI registration) to pick one.
//  4. The caller depends ONLY on the interface.
//  5. If multiple need to combine → wrap them in a Composite.

// ── Anti-symptoms (don't bother) ────────────────
//  • Exactly 2 branches that will never grow.
//  • The branches share > 80% of their code.
//  • The pick happens at compile time, not runtime.

// ── Cousins worth knowing ───────────────────────
//  Decorator — wraps a strategy to add behaviour (logging, caching).
//  State     — same shape, but the OBJECT swaps its own strategy.
//  Template Method — inherited fixed skeleton, only steps differ.
//                    (Strategy is its composition-based alternative.)
The whole tutorial in one breath
  • Strategy = define a family of algorithms, put each in its own class, make them swappable behind a common interface.
  • It's polymorphism applied with intent: "new behaviour = new file, never an edit".
  • Keep the interface tiny — one method per strategy is the gold standard.
  • Factory owns the "which one?" decision so the caller depends only on the interface.
  • Composite turns "pick one of N" into "apply M in order" without the caller knowing.
  • Strategy is the textbook example of the Open/Closed Principle from SOLID.
Mastery Move — where to go next

1. Replace the factories with DI. In ASP.NET Core, register every IPaymentStrategy with the container and resolve by key. The pattern stays — the wiring gets shorter.

2. Add a Decorator. Wrap any strategy in a LoggingPaymentStrategy or RetryingPaymentStrategy. Cross-cutting concerns without polluting your strategies.

3. OOP & SOLID for Dummies — Strategy is the practical face of OCP, ISP and DIP. The full theory is one click away.

4. Minimal API for Dummies — see strategies (and DI) plugged into a real HTTP service.

Read this page again in three months after you've shipped a couple of features. The first time you catch yourself not writing an if and reaching for a strategy instead — that's when the pattern has actually stuck.