Stop If-Else Pyramids: Pattern Matching & Strategy in C#

Stop If-Else Pyramids with C# Pattern Matching & Strategy

Kill brittle if-else ladders with C# pattern matching and Strategy. Learn clean designs, testable rules, and extensible domain logic.

.NET Fundamentals·By amarozka · September 16, 2025

Stop If-Else Pyramids with C# Pattern Matching & Strategy

Are you still climbing 200-line if-else ladders just to decide which tiny function to call? Spoiler: that “pyramid” is why your future self hates you.

In this post I’ll show you how to replace brittle if-else/switch spaghetti with C# pattern matching and the Strategy pattern. You’ll get small, composable pieces of logic, easier testing, and a design that actually wants to be extended instead of duplicated.

Why if-else pyramids hurt

I’ve inherited code like this more times than I care to admit:

if (order.User.IsVip)
{
    if (order.Total > 200)
        return ApplyVipHighValueDiscount(order);
    else if (order.Total > 100)
        return ApplyVipMediumDiscount(order);
    else if (order.CreatedOn.DayOfWeek == DayOfWeek.Friday)
        return ApplyVipFridayDeal(order);
    // ... several screens later
}
else if (order.Country == "DE")
{
    if (order.Total > 150) { /* ... */ }
    else if (order.Items.All(i => i.IsDigital)) { /* ... */ }
}
else if (campaign == Campaign.BlackFriday && order.Total > 50)
{
    // ... and so on
}

Problems:

  • Intent is hidden – you read branches, not rules.
  • High coupling – every condition knows everything about the world.
  • Extensibility tax – new rules mean new else if with unknown side effects.
  • Testing pain – you test paths through a monolith instead of small units.

Let’s refactor with two weapons: pattern matching (express intent as data + shape) and Strategy (let rules live where they belong).

A tiny domain to work with

We’ll model a discount engine:

  • Inputs: Order (country, total, items), User (segment), Campaign (current promo).
  • Output: a Money discount (could be zero).
public enum Segment { Regular, Vip, Employee }
public enum Campaign { None, BlackFriday, Summer }

public sealed record User(Segment Segment);
public sealed record Item(decimal Price, bool IsDigital);
public sealed record Order(
    User User,
    string Country,
    decimal Total,
    IReadOnlyList<Item> Items,
    DateTime CreatedOn
);

public readonly record struct Money(decimal Amount)
{
    public static Money Zero => new(0m);
    public static Money PercentOf(decimal baseAmount, decimal percent) => new(baseAmount * percent / 100m);
    public override string ToString() => Amount.ToString("C2");
}

Step 1: Pattern matching with switch expressions

Start by expressing rules as patterns rather than nested if/else.

public static Money ComputeDiscount(Order order, Campaign campaign) => (order, campaign) switch
{
    // VIP big baskets
    ({ User.Segment: Segment.Vip, Total: >= 200m }, _) => Money.PercentOf(order.Total, 12),

    // Black Friday for everyone with minimum basket
    ({ Total: >= 50m }, Campaign.BlackFriday) => Money.PercentOf(order.Total, 10),

    // Digital-only orders in DE
    ({ Country: "DE", Items: var items } o, _) when items.Count > 0 && items.All(i => i.IsDigital)
        => Money.PercentOf(o.Total, 7),

    // Friday VIP treat
    ({ User.Segment: Segment.Vip, CreatedOn.DayOfWeek: DayOfWeek.Friday }, _) => new(5m),

    // Employees always get a flat $20
    ({ User.Segment: Segment.Employee }, _) => new(20m),

    // Default
    _ => Money.Zero
};

What changed:

  • Patterns describe shape of data (User.Segment, Total: >= 50m, property patterns, relational patterns, tuple patterns).
  • when guards capture additional predicates without nesting.
  • Order encodes priority – first match wins.

This reads like a rulebook, not a labyrinth.

Tip: Keep each rule at one line, extracted to constants/functions if the body grows.

Step 2: From monolith to Strategies

Pattern matching makes intent clear, but we still have one function holding every rule. That’s fine for 5-10 rules. For dozens, you want Strategy so each rule owns its data and tests.

We’ll define a minimal strategy contract:

public interface IDiscountStrategy
{
    bool IsMatch(Order order, Campaign campaign);
    Money Compute(Order order, Campaign campaign);
}

A simple base class helps with guards:

public abstract class DiscountStrategy : IDiscountStrategy
{
    public abstract bool IsMatch(Order order, Campaign campaign);
    public abstract Money Compute(Order order, Campaign campaign);

    protected static bool DigitalOnly(Order order) =>
        order.Items.Count > 0 && order.Items.All(i => i.IsDigital);
}

Concrete strategies become tiny, testable units:

public sealed class VipHighValueStrategy : DiscountStrategy
{
    public override bool IsMatch(Order order, Campaign _) =>
        order.User.Segment is Segment.Vip && order.Total >= 200m;

    public override Money Compute(Order order, Campaign _) =>
        Money.PercentOf(order.Total, 12);
}

public sealed class BlackFridayStrategy : DiscountStrategy
{
    public override bool IsMatch(Order order, Campaign campaign) =>
        campaign is Campaign.BlackFriday && order.Total >= 50m;

    public override Money Compute(Order order, Campaign _) =>
        Money.PercentOf(order.Total, 10);
}

public sealed class GermanyDigitalOnlyStrategy : DiscountStrategy
{
    public override bool IsMatch(Order order, Campaign _) =>
        order.Country == "DE" && DigitalOnly(order);

    public override Money Compute(Order order, Campaign _) =>
        Money.PercentOf(order.Total, 7);
}

public sealed class VipFridayTreatStrategy : DiscountStrategy
{
    public override bool IsMatch(Order order, Campaign _) =>
        order.User.Segment is Segment.Vip && order.CreatedOn.DayOfWeek == DayOfWeek.Friday;

    public override Money Compute(Order order, Campaign _) => new(5m);
}

public sealed class EmployeeFlatStrategy : DiscountStrategy
{
    public override bool IsMatch(Order order, Campaign _) => order.User.Segment is Segment.Employee;
    public override Money Compute(Order order, Campaign _) => new(20m);
}

Now wire them up with a priority pipeline (first match wins):

public sealed class DiscountEngine
{
    private readonly IReadOnlyList<IDiscountStrategy> _strategies;

    public DiscountEngine(IEnumerable<IDiscountStrategy> strategies)
        => _strategies = strategies.ToList();

    public Money Calculate(Order order, Campaign campaign)
        => _strategies.FirstOrDefault(s => s.IsMatch(order, campaign))?.Compute(order, campaign)
           ?? Money.Zero;
}

Register with DI (ASP.NET Core example):

services
    .AddTransient<IDiscountStrategy, VipHighValueStrategy>()
    .AddTransient<IDiscountStrategy, BlackFridayStrategy>()
    .AddTransient<IDiscountStrategy, GermanyDigitalOnlyStrategy>()
    .AddTransient<IDiscountStrategy, VipFridayTreatStrategy>()
    .AddTransient<IDiscountStrategy, EmployeeFlatStrategy>()
    .AddSingleton<DiscountEngine>();

New rule? Add a class, register it, write a focused test. No pyramid.

Step 3: Combine Strategy with pattern matching for clarity

You can keep the decision boundary visible while delegating the heavy lifting:

public sealed class PatternMatchedDiscountEngine
{
    private readonly IServiceProvider _sp;
    public PatternMatchedDiscountEngine(IServiceProvider sp) => _sp = sp;

    public Money Calculate(Order order, Campaign campaign) => (order, campaign) switch
    {
        ({ User.Segment: Segment.Vip, Total: >= 200m }, _)
            => _sp.GetRequiredService<VipHighValueStrategy>().Compute(order, campaign),

        ({ Total: >= 50m }, Campaign.BlackFriday)
            => _sp.GetRequiredService<BlackFridayStrategy>().Compute(order, campaign),

        ({ Country: "DE", Items: var items }, _) when items.Count > 0 && items.All(i => i.IsDigital)
            => _sp.GetRequiredService<GermanyDigitalOnlyStrategy>().Compute(order, campaign),

        ({ User.Segment: Segment.Vip, CreatedOn.DayOfWeek: DayOfWeek.Friday }, _)
            => _sp.GetRequiredService<VipFridayTreatStrategy>().Compute(order, campaign),

        ({ User.Segment: Segment.Employee }, _)
            => _sp.GetRequiredService<EmployeeFlatStrategy>().Compute(order, campaign),

        _ => Money.Zero
    };
}

Why this works:

  • The switch is the policy map; strategies hold behavior.
  • You can see priorities and shapes in one place, but concrete computations are isolated.

Pattern matching techniques worth using daily

Property patterns

if (order is { Country: "US", User.Segment: Segment.Vip }) { /* ... */ }

Positional patterns

With records you can deconstruct on the left:

public sealed record Point(int X, int Y);

bool IsDiagonal(Point p) => p is (var x, var y) && x == y;

Logical/relational patterns

if (order.Total is >= 50 and < 100) { /* tier 1 */ }

Type patterns (avoid as + null check)

if (message is PaymentSucceeded s) PublishReceipt(s);

Tuple patterns for routing

(string ext, bool gz) key = (Path.GetExtension(file).ToLowerInvariant(), file.EndsWith(".gz"));
IImporter importer = key switch
{
    (".json", false) => jsonImporter,
    (".json", true)  => gzJsonImporter,
    (".csv",  _)     => csvImporter,
    _                 => throw new NotSupportedException("File type not supported")
};

Strategy variations that scale

Score-based (best match wins)

Sometimes multiple rules could apply. Score them and pick the max instead of first match.

public interface IScoredDiscountStrategy : IDiscountStrategy
{
    int Score(Order order, Campaign campaign); // 0 = not applicable
}

public sealed class ScoredDiscountEngine
{
    private readonly IReadOnlyList<IScoredDiscountStrategy> _strategies;
    public ScoredDiscountEngine(IEnumerable<IScoredDiscountStrategy> strategies)
        => _strategies = strategies.ToList();

    public Money Calculate(Order order, Campaign campaign)
        => _strategies
            .Select(s => (s.Score(order, campaign), s))
            .OrderByDescending(t => t.Item1)
            .FirstOrDefault().s?.Compute(order, campaign) ?? Money.Zero;
}

Rule registry by key

If selection depends on a key (file extension, message type), keep a registry map to avoid Dictionary<string,Func> scattered across your app.

public interface IStrategyRegistry<TKey, TStrategy>
{
    void Register(TKey key, TStrategy strategy);
    TStrategy Resolve(TKey key);
}

public sealed class StrategyRegistry<TKey, TStrategy> : IStrategyRegistry<TKey, TStrategy>
{
    private readonly Dictionary<TKey, TStrategy> _map = new();
    public void Register(TKey key, TStrategy strategy) => _map[key] = strategy;
    public TStrategy Resolve(TKey key) => _map.TryGetValue(key, out var s) ? s : throw new KeyNotFoundException();
}

Testing becomes pleasant

With pyramids, you test paths. With strategies, you test rules.

// xUnit examples
public class DiscountsTests
{
    [Fact]
    public void VipHighValue_Gets_12Percent()
    {
        var order = new Order(new User(Segment.Vip), "US", 250m, new[] { new Item(250m, false) }, DateTime.UtcNow);
        var strategy = new VipHighValueStrategy();

        var result = strategy.Compute(order, Campaign.None);

        Assert.Equal(new Money(30m), result);
    }

    [Fact]
    public void Germany_DigitalOnly_Gets_7Percent()
    {
        var order = new Order(new User(Segment.Regular), "DE", 100m, new[] { new Item(40m, true), new Item(60m, true) }, DateTime.UtcNow);
        var s = new GermanyDigitalOnlyStrategy();
        Assert.True(s.IsMatch(order, Campaign.None));
        Assert.Equal(new Money(7m), s.Compute(order, Campaign.None));
    }
}

Key benefits:

  • Tests are tiny and deterministic.
  • You can run a matrix of scenarios with a data-driven test for the switch if you keep a small mapping engine.

Refactoring a real pyramid (step-by-step checklist)

  1. List rules in plain English. Prioritize them.
  2. Add unit tests for existing behavior (golden master, if needed capture current outputs).
  3. Convert to a switch expression using property/tuple patterns.
  4. Extract complex bodies into strategies.
  5. Move any shared checks to guards/helper methods.
  6. Wire strategies via DI. Keep rule order in one place.
  7. Delete the pyramid and commit with a single, readable diff.

Smell detector: if adding a rule means touching multiple unrelated if blocks, you need patterns + strategy.

Performance & pitfalls

  • Pattern matching is compiled to efficient IL. For large tables, consider ordering by most common rule first.
  • Avoid over-matching: if your switch tries to know everything, you’re re-creating the pyramid. Keep it at routing level and delegate.
  • Be mindful of time-based rules: prefer injecting IClock so tests don’t depend on DateTime.Now.
  • For I/O heavy logic, strategies should be async (Task<Money>). Pattern matching still works; the switch selects a strategy that you await.

Bonus: translating legacy predicates into patterns

Legacy check:

if ((user.IsVip && total > 200) ||
    (campaign == Campaign.BlackFriday && total > 50) ||
    (country == "DE" && items.All(i => i.IsDigital)))
{
    // apply discount
}

Pattern version:

(order, campaign) switch
{
    ({ User.Segment: Segment.Vip, Total: > 200m }, _) => ApplyVip(order),
    ({ Total: > 50m }, Campaign.BlackFriday) => ApplyBlackFriday(order),
    ({ Country: "DE", Items: var items }, _) when items.All(i => i.IsDigital) => ApplyGermanyDigital(order),
    _ => NoDiscount(order)
};

It reads like a specification, not a maze.

FAQ: Pattern matching & Strategy in real teams

Isn’t a big switch just a different kind of pyramid?

Only if you dump all behavior inside it. Keep the switch to select and push behavior to strategies.

What about rule order conflicts?

Make it explicit. Either first match wins (ordered list) or best score wins (scored strategy). Document it next to the map.

How do I expose this to non-devs (product/ops)?

Wrap strategies with a readable descriptor and generate a rules report at startup. Some teams go further with JSON/YAML rules that build strategies at runtime.

Does DI create runtime costs?

Negligible for typical rule counts. If you care, resolve strategies once and reuse.

Can I data-drive rules (no redeploy)?

Yes: keep the switch for routing by shape, and have strategies pull percentages/thresholds from configuration or a database.

How many rules fit before Strategy is overkill?

My rule of thumb: if a single function exceeds ~30 lines or changes in multiple PRs per month, split.

Conclusion: Your future self will thank you

Stop balancing on a wobbling tower of if/else. Write rules as patterns. Encapsulate behavior in strategies. You’ll ship changes faster, break less, and your tests will read like documentation.

Take one ugly pyramid in your codebase and translate the top five rules into a switch + strategies. How did it go? Share your before/after in the comments – I’d love to see the wins.

Leave a Reply

Your email address will not be published. Required fields are marked *