From a Pile of Conditionals to Readable Design: Switches, Flag Parameters and Rule Tables
There is no single right target for nested conditionals: polymorphism is cheaper when new types arrive often, a switch expression when new operations do, and a rule table when rules change often and share one shape. The post turns PricingEngine's discount conditionals into all three targets through the same 144-combination snapshot, with an enum, guard clauses, a split flag parameter and a Percentage type, and shows real CS8509, CS0535 and missing-rule test output.
Version note: The code in this post was built and run with C# 14 and .NET 10 (SDK 10.0.402), with tests on xUnit.net v3 4.0.3 and Verify.XunitV3 32.0.0.
Guess First: Where Does the Student Discount Go?
By the end of the third post PricingEngine.Calculate had become readable, but two methods stayed as they were. The discount by customer type and campaign:
private static decimal TierDiscount(int customerType, string? campaign, decimal subtotal, int bookCount)
{
decimal discount = 0;
if (customerType == 2)
{
if (campaign == "SUMMER")
discount = subtotal * 0.20m;
else
discount = subtotal * 0.10m;
}
else if (customerType == 1)
{
if (campaign == "SUMMER")
discount = subtotal * 0.15m;
else if (bookCount >= 5)
discount = subtotal * 0.05m;
}
else
{
if (campaign == "SUMMER" && subtotal >= 500)
discount = subtotal * 0.10m;
}
return discount;
}
And the coupon: CouponDiscount is made of nested ifs in the same way, and Calculate's isPreview flag makes one method do two jobs (the amount to pay with shipping and the quote without it). Two requests came from the Product Owner. The first: a new student customer type with a 12 percent discount. The second: the answer to "is a fixed-amount coupon accepted?" should be managed separately for each customer type. Which kind of structure would make the second request as easy as the first? Or is there no structure that makes both easy at once?
Groundwork First: An Enum, Guard Clauses and Splitting the Flag
Before the design decision, we fix the ground with three small, mechanical steps. The safety net is the 144-combination snapshot from the third post; every step is its own commit and the snapshot is green after each.
The first is turning int customerType into an enum. The numbers 0, 1 and 2 forced the reader to look for the comment line:
public enum CustomerTier
{
Regular = 0,
Member = 1,
Vip = 2
}
public sealed record PriceRequest(List<CartLine> Lines, CustomerTier Tier, string? Campaign, string? Coupon);
The snapshot test's inputs stayed [0, 1, 2]; the test was adapted to the new type only with a (CustomerTier)customerType cast. That way not a single line of the approved file changed, which proves the claim "same inputs, same outputs". If you also want nicer keys in the snapshot, you do that in a separate test commit.
The second is flattening the coupon with guard clauses. Every level of nested ifs makes the reader hold one more condition in their head. With early returns, each rule gets its own line:
private static decimal CouponDiscount(string? coupon, CustomerTier tier, string? campaign, decimal subtotal)
{
if (string.IsNullOrEmpty(coupon))
return 0m;
if (coupon == "WELCOME10")
return tier == CustomerTier.Regular ? WelcomeCoupon.Of(subtotal) : 0m;
if (!coupon.StartsWith("FIX"))
return 0m;
// A VIP customer gets no fixed-amount coupon on top of the summer campaign.
if (tier == CustomerTier.Vip && campaign == "SUMMER")
return 0m;
return decimal.Parse(coupon[3..]);
}
The third is splitting the flag parameter. Someone reading the call Calculate(request, isPreview: true) has to go into the method to find out what true means. The flag says the method really answers two different questions: "what is the amount without shipping?" and "what is the amount to pay?". We split them into two named methods:
// Cart preview and quote: shipping excluded.
public decimal Quote(PriceRequest request) => Math.Round(NetAmount(request), 2);
// Checkout: shipping included.
public decimal Checkout(PriceRequest request)
{
decimal net = NetAmount(request);
net += ShippingPolicy.FeeFor(net);
return Math.Round(net, 2);
}
The callers live in the same repo, so they moved in one commit; with external callers we would apply the parallel change from the third post. Now the real decision: what do we turn TierDiscount into?
Target 1: Switch Expression and Pattern Matching
C#'s switch expression lets you read all the conditions side by side, as if they were a single table. With when, an extra condition can be attached to the same customer type:
private static decimal TierDiscount(CustomerTier tier, string? campaign, decimal subtotal, int bookCount)
{
bool summer = campaign == "SUMMER";
return tier switch
{
CustomerTier.Vip => subtotal * (summer ? 0.20m : 0.10m),
CustomerTier.Member when summer => subtotal * 0.15m,
CustomerTier.Member => bookCount >= 5 ? subtotal * 0.05m : 0m,
CustomerTier.Regular => summer && subtotal >= 500 ? subtotal * 0.10m : 0m,
};
}
The snapshot is green, but the compiler gives a warning:
src/Bookstore.Pricing/PricingEngine.cs(29,21): warning CS8524: The switch expression does not handle some values of its input type (it is not exhaustive) involving an unnamed enum value. For example, the pattern '(Bookstore.Pricing.CustomerTier)3' is not covered. [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
In C# an enum variable can also hold numbers that have no name: (CustomerTier)3 is a valid value. The most common way to silence this warning is to add a discard arm like _ => throw ... at the end. But the discard arm isn't free: if someone adds a new value to the enum tomorrow, that value silently falls into the discard too, and the bug only shows up at runtime. A better way is to separate the two warnings. CS8524 is only about unnamed values, while CS8509 fires when a named enum member is forgotten. In .editorconfig we turn one off and raise the other to an error:
root = true
[*.cs]
# Turn off the warning for unnamed enum values; break the build when a named member is forgotten.
dotnet_diagnostic.CS8524.severity = none
dotnet_diagnostic.CS8509.severity = error
Now let's try the Product Owner's first request and add Student = 3 to the enum. Without touching anything else, the build says:
src/Bookstore.Pricing/PricingEngine.cs(29,21): error CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Pricing.CustomerTier.Student' is not covered. [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
The compiler tells us where the student discount has to be written. The cost of this approach is clear too: if a number coming from outside turns into an undefined enum value, the switch throws SwitchExpressionException at runtime. That is why validating the value with Enum.IsDefined at the boundary of the system, when reading it from the database or the API, matters.
Target 2: Strategy Through Polymorphism
The second option is to move each customer type's discount rule into its own class. This is exactly the structure described in the Design Patterns series post "Strategy Pattern in Depth: Keyed Services, IEnumerable<IStrategy> and Func for Runtime Selection"; here we use it as a refactoring target:
public interface ITierPolicy
{
decimal Discount(decimal subtotal, int bookCount, bool summer);
}
public sealed class RegularTier : ITierPolicy
{
public decimal Discount(decimal subtotal, int bookCount, bool summer) =>
summer && subtotal >= 500 ? subtotal * 0.10m : 0m;
}
public sealed class MemberTier : ITierPolicy
{
public decimal Discount(decimal subtotal, int bookCount, bool summer) =>
summer ? subtotal * 0.15m
: bookCount >= 5 ? subtotal * 0.05m
: 0m;
}
public sealed class VipTier : ITierPolicy
{
public decimal Discount(decimal subtotal, int bookCount, bool summer) =>
subtotal * (summer ? 0.20m : 0.10m);
}
public static class TierPolicies
{
public static ITierPolicy For(CustomerTier tier) => tier switch
{
CustomerTier.Regular => new RegularTier(),
CustomerTier.Member => new MemberTier(),
CustomerTier.Vip => new VipTier(),
};
}
The snapshot is green again. Two things to notice. First, the switch didn't disappear; it moved to the boundary of the system, to the single place where we go from the enum to a policy. Polymorphism doesn't eliminate the switch, it saves you from repeating it. If five different places decide based on customer type, a single factory remains instead of five switches. Second, adding a new customer type now means writing a new class and adding one line to the factory; existing classes aren't touched.
What about the Product Owner's second request, the per-type question "is a fixed coupon accepted?" That is a new operation. When we add bool AcceptsFixedCoupon(bool summer); to the interface:
src/Bookstore.Pricing/TierPolicies.cs(15,34): error CS0535: 'MemberTier' does not implement interface member 'ITierPolicy.AcceptsFixedCoupon(bool)' [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
src/Bookstore.Pricing/TierPolicies.cs(23,31): error CS0535: 'VipTier' does not implement interface member 'ITierPolicy.AcceptsFixedCoupon(bool)' [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
src/Bookstore.Pricing/TierPolicies.cs(9,35): error CS0535: 'RegularTier' does not implement interface member 'ITierPolicy.AcceptsFixedCoupon(bool)' [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
A new operation requires touching every class. With the switch expression it was the other way round: a new operation was a new switch and didn't touch existing code, while a new type required touching every switch.
Target 3: A Rule Table
The third option is to take the rules out of code and turn them into data. Each row is a rule; the first matching row applies:
public sealed record TierRule(CustomerTier Tier, bool? Summer, int MinBooks, decimal MinSubtotal, Percentage Rate);
public static class TierRules
{
// Order matters: the first matching rule applies.
public static readonly TierRule[] Default =
[
new(CustomerTier.Vip, Summer: true, MinBooks: 0, MinSubtotal: 0m, Rate: Percentage.FromFraction(0.20m)),
new(CustomerTier.Vip, Summer: false, MinBooks: 0, MinSubtotal: 0m, Rate: Percentage.FromFraction(0.10m)),
new(CustomerTier.Member, Summer: true, MinBooks: 0, MinSubtotal: 0m, Rate: Percentage.FromFraction(0.15m)),
new(CustomerTier.Member, Summer: false, MinBooks: 5, MinSubtotal: 0m, Rate: Percentage.FromFraction(0.05m)),
new(CustomerTier.Regular, Summer: true, MinBooks: 0, MinSubtotal: 500m, Rate: Percentage.FromFraction(0.10m)),
];
public static decimal Discount(IEnumerable<TierRule> rules, CustomerTier tier, bool summer, decimal subtotal, int bookCount)
{
TierRule? rule = rules.FirstOrDefault(r =>
r.Tier == tier
&& (r.Summer is null || r.Summer == summer)
&& bookCount >= r.MinBooks
&& subtotal >= r.MinSubtotal);
return rule is null ? 0m : rule.Rate.Of(subtotal);
}
}
The snapshot is green. Adding the student type here means one value in the enum and one row in the table; a three-line diff in two files, without a single compiler warning. If one day the table is read from a JSON file or the database, adding a row doesn't even require a code change.
The absence of a compiler warning is also the weak point of this approach: if Student is added to the enum and someone forgets to add a row, students silently get a 0 percent discount. The check the compiler did for the switch has to be done by a test here:
[Fact]
public void Every_tier_appears_in_the_rule_table()
{
CustomerTier[] missing = Enum.GetValues<CustomerTier>()
.Where(tier => TierRules.Default.All(r => r.Tier != tier))
.ToArray();
Assert.Empty(missing);
}
failed Bookstore.Pricing.Tests.TierRulesTests.Every_tier_appears_in_the_rule_table (8ms)
Assert.Empty() Failure: Collection was not empty
Collection: [Student]
Another cost of the table is that order carries meaning. If two rows match the same order, the upper one wins, and that knowledge lives not in code but in how the rows are arranged. As the table grows, tests showing that rows don't overlap, or that an overlap is deliberate, become necessary.
The Answer: A New Type or a New Operation?
There is no single structure that makes both opening requests easy. This is the tension Philip Wadler named the expression problem in the programming languages literature: making both data types and operations extensible at the same time is hard. Which target you choose depends on which kind of change arrives more often in your code base:
| Change | Switch expression | Polymorphism (strategy) | Rule table |
|---|---|---|---|
| New customer type | One arm in every switch; the compiler points to it with CS8509 | A new class and one line in the factory | A new row; a test catches a missing one |
| New operation (e.g. coupon acceptance) | A new switch; existing code untouched | A new method in every class; the compiler enforces it with CS0535 | A new column; every row gets updated |
| Who changes the rule? | A developer, through a deploy | A developer, through a deploy | If needed, configuration or an admin screen |
| Where it fits best | A small, closed set; rules should read at a glance | Types with rich behavior and new types arriving often | Rules that change often and share the same shape |
The decision for Bookstore: the number of customer types is small and rarely changes, while campaign rates are changed often by the business. So a rule table for the rates and a switch expression for behavior that differs between types (like coupon acceptance) is a reasonable start. Polymorphism starts to make sense once each customer type accumulates its own behavior beyond discounts (loyalty points, shipping privileges, an invoice note). Back to the principle from the first post: the goal of a refactoring isn't to make today's code look nice, but to make the next expected change easier.
Small Types: Percentage
The table's Rate column isn't a decimal but a small type. Is 0.20m 20 percent or 0.2 percent? A decimal doesn't say; a type does, and it rejects an invalid value the moment it is created:
public readonly record struct Percentage
{
private Percentage(decimal fraction) => Fraction = fraction;
public decimal Fraction { get; }
public static Percentage FromFraction(decimal fraction) =>
fraction is >= 0m and <= 1m
? new Percentage(fraction)
: throw new ArgumentOutOfRangeException(nameof(fraction), fraction, "A percentage must be between 0 and 1.");
public decimal Of(decimal amount) => amount * Fraction;
public override string ToString() => $"{Fraction * 100m:0.##}%";
}
There is a reason I store the value as a fraction: the Of method performs exactly the old subtotal * 0.30m multiplication. I tried the alternative: when the type stored percentage points (10 and 30) and computed amount * Points / 100m, nine lines of the snapshot changed because of the decimal scale difference we saw in the third post; for example the preview with WELCOME10 printed "108.0" instead of "108.00". Adding a small type is a refactoring step too, and it has to pass through the same net.
The discount cap (MaxDiscount) and the WELCOME10 coupon are now Percentage as well. By the same logic, a Money type for amounts and a Coupon type that parses "FIX20" for the coupon string are the next candidates; extracting a number stored inside a string with decimal.Parse(coupon[3..]) at every use is typical primitive obsession. The details of value object design, equality and persistence are in the DDD series post "End to End: Refactoring Bookstore's Ordering Context from Anemic CRUD to a Rich Model"; here our goal is only to turn magic numbers in conditionals into named values.
Practice: The Gilded Rose Kata
The best-known exercise for trying this post's steps on other code is the Gilded Rose Refactoring Kata maintained by Emily Bache. The repo is MIT-licensed and contains starting code in dozens of languages; there are csharp.xUnit and csharp.NUnit folders for C#, and a TypeScript folder. As of 14 November 2026 the csharp.xUnit project targets .NET 10; it comes with a sample test that is deliberately left red and an approval test written with Verify that approves 30 days of output. Read the kata's rules in the requirements file in the repo; here we only give a roadmap that fits this series.
First widen the net as in the second post: instead of a single 30-day output, approve combinations of item names, remaining days and quality values, then find the holes with coverage and a few deliberate mutations. Then move in small steps with the rhythm of the third post: guard clauses, extracting the loop body into one method per item, turning magic strings into named constants. Finally ask this post's question: is the new item type the kata asks for a new type or a new operation? A switch expression on the item type, a strategy per item type, or a rule table? Solving the same kata with the three targets separately and comparing the diffs teaches more than memorizing the table.
Common Pitfalls
Turning every switch into polymorphism. Treating the switch as "bad code" and converting each one into a class hierarchy spreads one-line rules across four files and makes adding a new operation expensive. The question is always the same: does this code more often get new types, or new operations?
Silencing the compiler with a discard arm. A _ => 0m arm silences CS8524 but also disables CS8509. A new enum member silently falls through to the default value. Use a discard only when "everything else" really deserves the same behavior.
Using modern C# features just because they are new. A four-level nested property pattern or a five-element tuple pattern can be harder to read than the old ifs. A switch expression reads well when its arms are short and parallel; if one arm does a ten-line calculation, that calculation should go into its own method.
Growing a rule table without validating it. Because the table is data, the compiler doesn't check it. A missing row, overlapping rows and the wrong order are only caught by tests; if the table is read from outside, it should also be validated when it is read.
Changing behavior while transforming conditionals. Moving to guard clauses changes the order of checks. In this example the order doesn't affect the result because "WELCOME10" doesn't start with "FIX", but when two conditions can match the same input, reordering can change behavior. The combination snapshot is there to catch exactly this kind of mistake.
A Small Experiment
★☆☆ Find a switch expression over an enum with a discard arm in your own code. Remove the discard, turn off CS8524 and raise CS8509 to an error in .editorconfig, and see what the build says.
★★☆ Add the Product Owner's second request to each of this post's three targets: VIP customers can't use a fixed coupon in summer, the other types can. How many files and how many lines did each one touch?
★★★ Solve the Gilded Rose kata in C# or TypeScript: first build a net with a combination snapshot, then solve it with two different targets (switch expression and strategy) on two separate branches. Add the new item type the kata asks for to both branches and compare the diffs.
The code we have transformed so far was testable or easily became testable. In the next post we move to code that isn't: OrderImportJob, which reads a CSV, calls a static SMTP helper and uses DateTime.Now and new SqlConnection. To be able to write tests for it, we'll first have to break its dependencies.