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)
- List rules in plain English. Prioritize them.
- Add unit tests for existing behavior (golden master, if needed capture current outputs).
- Convert to a
switch
expression using property/tuple patterns. - Extract complex bodies into strategies.
- Move any shared checks to guards/helper methods.
- Wire strategies via DI. Keep rule order in one place.
- 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 onDateTime.Now
. - For I/O heavy logic, strategies should be async (
Task<Money>
). Pattern matching still works; theswitch
selects a strategy that youawait
.
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
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.
Make it explicit. Either first match wins (ordered list) or best score wins (scored strategy). Document it next to the map.
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.
Negligible for typical rule counts. If you care, resolve strategies once and reuse.
Yes: keep the switch
for routing by shape, and have strategies pull percentages/thresholds from configuration or a database.
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.