If your pull request adds one more case
– you probably added one more bug. Sounds harsh? In my experience, giant switch
blocks are bug factories. Today I’ll show you when a switch
is a smell, how to replace it with clean polymorphism (without overengineering), and when a small switch
is still the right tool.
The Problem With Big switch
You’ve seen it. A 200‑line method with a switch (paymentType)
or switch (documentStatus)
that sprawls across the screen. It “works”… until the next feature lands. Then:
- Ripple edits: One new enum value → 12 places to update.
- Duplicated rules: Validation, logging, metrics repeated in each case.
- Missed branches: Someone forgets to update the
default
→ runtime surprises. - Test pain: You must traverse unrelated branches to reach the logic you need.
This violates the Open/Closed Principle: your system is closed for modifications but open for extension. A giant switch
flips that: you must modify central code for every extension.
Rule of thumb from my projects: if a
switch
grows past ~4 cases and each branch has more than a couple of lines (I/O, side effects, business rules), it’s time to introduce polymorphism.
A Concrete Example: Discount Calculation (The Anti‑Pattern)
Let’s start with a code smell I met in a real e‑commerce service.
public enum CustomerType
{
Regular,
Vip,
Employee,
Affiliate
}
public sealed class DiscountService
{
public decimal Calculate(Order order)
{
switch (order.CustomerType)
{
case CustomerType.Regular:
return ApplyCaps(order.Total * 0.00m);
case CustomerType.Vip:
var vip = order.Total * 0.10m;
if (order.Total > 1000) vip += 25; // birthday coupons sprinkled later...
return ApplyCaps(vip);
case CustomerType.Employee:
return ApplyCaps(order.Total * 0.25m);
case CustomerType.Affiliate:
return ApplyCaps(order.Total * 0.05m);
default:
throw new ArgumentOutOfRangeException(nameof(order.CustomerType));
}
}
private decimal ApplyCaps(decimal value) => Math.Clamp(value, 0, 300);
}
Issues:
- Business rules are scattered inside the
switch
. - New promotional logic for VIPs leaks here.
- Tests for each rule couple to a central method. Changing one rule risks others.
- Adding
NonProfit
requires a PR changing this class + places that alsoswitch
onCustomerType
.
Refactor 1: Move Behavior to Types (Classic Polymorphism)
We’ll keep the input (CustomerType
) but shift the decision to objects that know how to compute their discount. The service will collaborate via an interface.
public interface IDiscountPolicy
{
decimal GetDiscount(Order order);
}
public sealed class RegularDiscount : IDiscountPolicy
{
public decimal GetDiscount(Order order) => 0m;
}
public sealed class VipDiscount : IDiscountPolicy
{
public decimal GetDiscount(Order order)
{
var baseValue = order.Total * 0.10m;
if (order.Total > 1000) baseValue += 25m;
return baseValue;
}
}
public sealed class EmployeeDiscount : IDiscountPolicy
{
public decimal GetDiscount(Order order) => order.Total * 0.25m;
}
public sealed class AffiliateDiscount : IDiscountPolicy
{
public decimal GetDiscount(Order order) => order.Total * 0.05m;
}
public sealed class CappedDiscountPolicy : IDiscountPolicy
{
private readonly IDiscountPolicy _inner;
public CappedDiscountPolicy(IDiscountPolicy inner) => _inner = inner;
public decimal GetDiscount(Order order) => Math.Clamp(_inner.GetDiscount(order), 0, 300);
}
We still need a way to map CustomerType
→ policy. Start with a slim factory (or DI container):
public interface IDiscountPolicyFactory
{
IDiscountPolicy Create(CustomerType customerType);
}
public sealed class DiscountPolicyFactory : IDiscountPolicyFactory
{
private readonly IReadOnlyDictionary<CustomerType, IDiscountPolicy> _policies;
public DiscountPolicyFactory()
{
_policies = new Dictionary<CustomerType, IDiscountPolicy>
{
[CustomerType.Regular] = new CappedDiscountPolicy(new RegularDiscount()),
[CustomerType.Vip] = new CappedDiscountPolicy(new VipDiscount()),
[CustomerType.Employee] = new CappedDiscountPolicy(new EmployeeDiscount()),
[CustomerType.Affiliate] = new CappedDiscountPolicy(new AffiliateDiscount())
};
}
public IDiscountPolicy Create(CustomerType type)
=> _policies.TryGetValue(type, out var policy)
? policy
: throw new ArgumentOutOfRangeException(nameof(type));
}
public sealed class DiscountService
{
private readonly IDiscountPolicyFactory _factory;
public DiscountService(IDiscountPolicyFactory factory) => _factory = factory;
public decimal Calculate(Order order)
=> _factory.Create(order.CustomerType).GetDiscount(order);
}
Benefits:
- The central
switch
disappears. - Each policy is testable in isolation.
- Cross‑cutting rules (like capping) become decorators.
- New policies are additive: implement
IDiscountPolicy
and register it.
Tip: In ASP.NET Core, register each policy as a service and compose them with DI. A dictionary of policies keyed by
CustomerType
can be injected, keeping creation out of your business code.
Refactor 2: Replace the Enum Entirely (Tell, Don’t Ask)
Enums often exist because we ask an object about its type and then decide. Instead, tell the object to do its job. Let’s make Customer
polymorphic.
public abstract class Customer
{
public required string Id { get; init; }
public abstract IDiscountPolicy DiscountPolicy { get; }
}
public sealed class RegularCustomer : Customer
{
public override IDiscountPolicy DiscountPolicy { get; } = new CappedDiscountPolicy(new RegularDiscount());
}
public sealed class VipCustomer : Customer
{
public override IDiscountPolicy DiscountPolicy { get; } = new CappedDiscountPolicy(new VipDiscount());
}
// ...other customers
public sealed class DiscountService
{
public decimal Calculate(Order order, Customer customer)
=> customer.DiscountPolicy.GetDiscount(order);
}
We removed the enum; the type system now ensures the right behavior. Adding NonProfitCustomer
becomes a new class with its own policy. No central place to forget.
Strategy Pattern With DI (When You Don’t Control the Types)
Sometimes the source object comes from another bounded context or an external API (you can’t make it polymorphic). Use Strategy keyed by a discriminator.
public interface IPaymentProcessor
{
Task ProcessAsync(Payment payment, CancellationToken ct);
}
public sealed class CardProcessor : IPaymentProcessor
{ /* ... */ }
public sealed class PayPalProcessor : IPaymentProcessor
{ /* ... */ }
public sealed class WireTransferProcessor : IPaymentProcessor
{ /* ... */ }
public sealed class PaymentRouter
{
private readonly IReadOnlyDictionary<string, IPaymentProcessor> _processors;
public PaymentRouter(IReadOnlyDictionary<string, IPaymentProcessor> processors)
=> _processors = processors;
public Task RouteAsync(Payment p, CancellationToken ct)
=> _processors.TryGetValue(p.Method, out var handler)
? handler.ProcessAsync(p, ct)
: throw new NotSupportedException($"Unknown method: {p.Method}");
}
Register processors in DI and inject IReadOnlyDictionary<string, IPaymentProcessor>
(or a keyed factory). You get the extension benefits without the monster switch
.
“But I Like Pattern Matching!” – Use It Wisely
C# pattern matching is great for small, pure, closed decisions. Prefer the switch expression for simple mappings:
public static string ToHex(ConsoleColor color) => color switch
{
ConsoleColor.Black => "#000000",
ConsoleColor.White => "#FFFFFF",
ConsoleColor.Red => "#FF0000",
ConsoleColor.Green => "#008000",
ConsoleColor.Blue => "#0000FF",
_ => throw new ArgumentOutOfRangeException(nameof(color))
};
This is fine: tiny, pure, easily exhaustive. What you want to avoid is packing behavior (I/O, DB calls, workflows) into large switch
arms.
Heuristic: If branches touch infrastructure (DB, HTTP, queues), prefer polymorphism/strategy. If branches are simple data transforms, pattern matching is okay.
Decision Matrix: Switch vs Polymorphism
Scenario | Size/Change Rate | Side Effects | Recommended |
---|---|---|---|
Map enum to string/number | Small (≤5) | None | Switch expression |
Validation rules per type | Medium/High | Maybe | Polymorphism + Decorators |
Workflow per status | Medium/High | Yes | State pattern / Strategy |
One-off initialization | Small | None | Switch expression |
External discriminator (string code) | Medium/High | Yes | Strategy keyed in DI |
Step‑By‑Step Refactoring Guide (Safely)
- Wrap the
switch
in a dedicated class (e.g.,DiscountPolicyFactory
). This isolates the churn. - Extract interfaces (
IDiscountPolicy
,IPaymentProcessor
). - Move code from each
case
into dedicated classes. Keep semantics identical. - Decorate cross‑cutting concerns (capping, logging, metrics) with small decorators.
- Introduce DI composition: register implementations; inject dictionaries/factories.
- Erase the enum (optional): lift behavior onto domain types; delete the last
switch
. - Add contract tests: a test per type/policy; a golden master test comparing old vs new through sample inputs.
Golden Master for Safety
[Fact]
public void NewAndOldImplementationsMatch()
{
var inputs = new[]
{
new Order { Total = 50, CustomerType = CustomerType.Regular },
new Order { Total = 1200, CustomerType = CustomerType.Vip },
new Order { Total = 200, CustomerType = CustomerType.Employee },
};
var oldService = new LegacyDiscountService(); // the switch version
var newService = new DiscountService(new DiscountPolicyFactory());
foreach (var order in inputs)
Assert.Equal(oldService.Calculate(order), newService.Calculate(order));
}
This locks behavior while you reshuffle code.
State Pattern for Evolving Workflows
Workflows (e.g., Draft → Review → Approved → Published
) suffer the most from giant switch
es. Model them as states:
public abstract class DocumentState
{
public abstract DocumentState Submit();
public abstract DocumentState Approve(User by);
public abstract DocumentState Reject(string reason);
}
public sealed class Draft : DocumentState
{
public override DocumentState Submit() => new InReview();
public override DocumentState Approve(User by) => throw new InvalidOperationException("Draft cannot be approved");
public override DocumentState Reject(string reason) => throw new InvalidOperationException("Draft cannot be rejected");
}
public sealed class InReview : DocumentState
{
public override DocumentState Submit() => this; // idempotent
public override DocumentState Approve(User by) => new Approved(by);
public override DocumentState Reject(string reason) => new Rejected(reason);
}
public sealed record Approved(User By) : DocumentState
{
public override DocumentState Submit() => this;
public override DocumentState Approve(User by) => this;
public override DocumentState Reject(string reason) => throw new InvalidOperationException("Approved cannot be rejected");
}
Win: Illegal transitions are impossible by design; no switch (status)
scattered around.
Tiny Diagram

Performance Notes (Without Myths)
- For enums with trivial mapping, a
switch
can compile to efficient jump tables. Don’t over‑abstract the simplest path. - Polymorphism adds a virtual dispatch (tiny) but often removes branching and duplication, yielding fewer cache misses and easier hot‑path optimization.
- If performance truly matters, write a microbenchmark (BenchmarkDotNet) against realistic data. Let numbers, not hunches, drive the decision.
Premature micro‑optimizing a complex
switch
is like waxing a car with a flat tire. Fix the design first.
Testing Strategies
- Per‑policy tests: assert each strategy’s business rules.
- Contract tests for routers/factories: unknown keys must throw predictable exceptions; known keys route correctly.
- Mutation testing (or simply toggling conditions) can expose hidden coupling that giant
switch
blocks tend to hide.
Example test for routing:
[Theory]
[InlineData("card")]
[InlineData("paypal")]
public async Task Known_methods_are_processed(string method)
{
var spy = new SpyProcessor();
var router = new PaymentRouter(new Dictionary<string, IPaymentProcessor>
{
[method] = spy
});
await router.RouteAsync(new Payment { Method = method }, CancellationToken.None);
Assert.True(spy.Called);
}
When a switch
Is Totally Fine
- Bootstrapping a small feature with ≤3 options.
- Pure mapping (no side effects) where readability is best with a switch expression.
- Guard code to validate external values early, fail fast, and hand over to typed objects afterwards.
If the code grows, graduate to polymorphism – don’t marry your prototype.
FAQ: Polymorphism vs Switch – Real‑World Concerns
Not if you keep classes tiny and focused. Five 20‑line classes beat one 200‑line method every day for testability and change safety.
Use it for data shaping and pure logic. Avoid behavioral sprawl: large branches with I/O or workflows. That’s where polymorphism shines.
Wrap them. Introduce a strategy keyed by a discriminator (code, enum, header). Keep the rest of your system object‑oriented.
DI makes composition explicit. Start simple (a small factory). When the set grows, move to DI with keyed dictionaries or named services.
Measure. In most business code, maintainability wins. Only micro‑optimize a proven hot path.
Conclusion: Kill Giant switch
es, Grow Small Objects
Big switch
statements centralize changing behavior and punish you with ripple edits, bugs, and brittle tests. Move behavior to the right place using polymorphism, strategy, and state patterns. Keep pattern matching for small, pure mappings. Your codebase will be easier to extend, safer to refactor, and kinder to future‑you.
Where is the biggest switch
in your codebase today, and how would you split it into two or three tiny polymorphic objects? Drop a comment – I’ll help sketch a refactor plan.