Hello. In the previous lesson, you used records, required, and init to make data contracts more explicit and immutable. Those precise types give pattern matching something useful to inspect: instead of repeatedly probing loosely structured data with nested if statements, you can describe the meaningful shapes and states of an input directly.
This lesson focuses on refactoring conditional logic without making it merely shorter. By the end, you should be able to choose between if guard clauses and pattern matching, use modern C# patterns for values, ranges, object properties, types, and tuples, and order a switch so its business rules remain easy to verify.
Pattern matching expresses a decision table
Complex conditional code is difficult not because if is inherently bad, but because the rules become scattered across nesting levels. Consider a leaderboard visibility decision:
public static LeaderboardVisibility GetVisibility(
PlayerSummary? player,
LeaderboardOptions options)
{
if (player is null)
{
return LeaderboardVisibility.NotFound;
}
if (player.IsBanned)
{
return LeaderboardVisibility.Hidden;
}
if (!options.IncludeUnranked && player.Rank is null)
{
return LeaderboardVisibility.Excluded;
}
if (player.Rank is not null && player.Rank <= 100)
{
return LeaderboardVisibility.Featured;
}
return LeaderboardVisibility.Standard;
}
This is acceptable code, particularly because the early returns avoid deep nesting. But the decision rules are more clearly seen as a table:
| Player state | Option | Result |
|---|---|---|
| Player absent | Any | NotFound |
| Player banned | Any | Hidden |
| No rank | Unranked entries excluded | Excluded |
| Rank is 100 or better | Any | Featured |
| Any remaining valid player | Any | Standard |
A tuple pattern lets the code mirror that table:
public static LeaderboardVisibility GetVisibility(
PlayerSummary? player,
LeaderboardOptions options) =>
(player, options) switch
{
(null, _) => LeaderboardVisibility.NotFound,
({ IsBanned: true }, _) => LeaderboardVisibility.Hidden,
({ Rank: null }, { IncludeUnranked: false }) =>
LeaderboardVisibility.Excluded,
({ Rank: <= 100 }, _) => LeaderboardVisibility.Featured,
({ }, _) => LeaderboardVisibility.Standard
};
Each arm has two parts:
pattern => result
The arms are evaluated top to bottom, and the first matching arm wins. That ordering is part of the business logic:
- A banned player must be hidden even if they are in the top 100.
- An unranked player is excluded only when the caller opted out of unranked entries.
- The final
{ }means “any remaining non-null player.”
Two patterns are especially worth distinguishing:
_is a discard: it matches any value, includingnull.{ }matches any non-null object.
The refactored version is not magically more correct than the original. Its advantage is that every case is visible at the same level, making omissions and contradictory cases easier to spot during review.
The small pattern vocabulary that solves most backend branching
Modern C# patterns compose. Start with the simple forms, then combine them only when doing so makes a rule clearer.
| Pattern | Example | Useful for |
|---|---|---|
| Constant | null, GameMode.Ranked | Discrete states |
| Type/declaration | ScoreSubmission submission | Type check plus safe cast |
| Relational | < 0, >= 100 | Numeric or comparable ranges |
| Logical | > 0 and < 100, Saturday or Sunday | Combining simple conditions |
| Property | { IsRanked: true, Score: > 0 } | Rules based on object state |
| Tuple | (true, TimeBand.Peak, _) | Rules that depend on several inputs |
| Guard | pattern when condition | A condition that is not naturally a pattern |
A declaration pattern replaces a type check followed by a cast:
if (payload is ScoreSubmission submission)
{
Process(submission);
}
submission is available only in the successful branch and is already correctly typed. A null value cannot match a declaration pattern.
A property pattern examines an object without repeatedly naming that object:
if (submission is
{
IsRanked: true,
Score: > 0 and < 100_000
})
{
QueueForRankedLeaderboard(submission);
}
This reads as a rule: a ranked submission whose score is positive and below the manual-review threshold.
Relational and logical patterns are particularly helpful when a chain of comparisons is really defining categories:
public static ScoreBand GetScoreBand(int score) =>
score switch
{
< 0 => ScoreBand.Invalid,
0 => ScoreBand.NoScore,
> 0 and < 1_000 => ScoreBand.Low,
>= 1_000 and < 10_000 => ScoreBand.Medium,
_ => ScoreBand.High
};
Notice that the later arms can be simpler because earlier arms have already excluded some values. In this example, the final arm means scores of at least 10,000.
Before continuing, study Microsoft Learn’s overview for the core forms used in this lesson.
Pattern matching overview - C#
Read Microsoft Learn’s Pattern matching overview to see the official syntax for switch expressions, range checks, and object patterns. Focus on how pattern order and the discard pattern affect correctness.
In “Compare discrete values,” read from the explanation of enum dispatch through the discussion of the discard arm. Follow enum dispatch and note why an explicit fallback is valuable. Then read “Relational patterns,” starting with the water-state example and continuing through the explanation of compiler checking for overlapping arms. Use range patterns to see how ordered ranges can be expressed without compound Boolean conditions. Finally, read “Multiple inputs” from the Order example through the positional-pattern example, stopping before “List patterns.” Pay attention to object patterns, especially the distinction between matching named properties and matching positional record components.
A production-style refactor: score-submission routing
Suppose the game backend receives a score submission and must decide what to do with it. The model intentionally reflects an external boundary: the match ID may be missing or malformed, even if normal application code should create complete objects.
public sealed record ScoreSubmission(
string? MatchId,
int Score,
bool IsRanked,
bool IsFromTrustedServer);
public enum SubmissionDisposition
{
Reject,
QueueForReview,
AcceptRanked,
AcceptUnranked
}
Here is a conventional implementation:
public static SubmissionDisposition Classify(
ScoreSubmission? submission)
{
if (submission is null)
{
throw new ArgumentNullException(nameof(submission));
}
if (string.IsNullOrWhiteSpace(submission.MatchId))
{
return SubmissionDisposition.Reject;
}
if (submission.Score < 0)
{
return SubmissionDisposition.Reject;
}
if (submission.IsRanked && !submission.IsFromTrustedServer)
{
return SubmissionDisposition.QueueForReview;
}
if (submission.IsRanked && submission.Score >= 100_000)
{
return SubmissionDisposition.QueueForReview;
}
if (submission.IsRanked)
{
return SubmissionDisposition.AcceptRanked;
}
return SubmissionDisposition.AcceptUnranked;
}
This is already reasonable because each invalid condition returns immediately. Pattern matching becomes valuable when we want the method to make its classification rules explicit:
public static SubmissionDisposition Classify(
ScoreSubmission? submission) =>
submission switch
{
null => throw new ArgumentNullException(nameof(submission)),
{ MatchId: var matchId }
when string.IsNullOrWhiteSpace(matchId) =>
SubmissionDisposition.Reject,
{ Score: < 0 } =>
SubmissionDisposition.Reject,
{ IsRanked: true, IsFromTrustedServer: false } =>
SubmissionDisposition.QueueForReview,
{ IsRanked: true, Score: >= 100_000 } =>
SubmissionDisposition.QueueForReview,
{ IsRanked: true } =>
SubmissionDisposition.AcceptRanked,
{ } =>
SubmissionDisposition.AcceptUnranked
};
There are several design choices worth noticing.
Validate before classifying valid cases
The first arms deal with unacceptable input: absent submission, missing match ID, and negative score. Later arms can therefore assume a valid score and usable match ID.
That ordering avoids duplicated checks. It also makes it clear that a score from an untrusted source is not rejected outright; it is intentionally routed for review.
Use when for conditions that are not a natural shape
string.IsNullOrWhiteSpace is a method call, so it cannot be represented purely as a property or relational pattern. A when guard is appropriate here:
{ MatchId: var matchId }
when string.IsNullOrWhiteSpace(matchId)
Use guards sparingly. A long Boolean formula inside when recreates the hidden complexity that pattern matching was supposed to expose. If the rule has a meaningful name or requires several calculations, extract it:
{ IsRanked: true } submission
when RequiresManualReview(submission) =>
SubmissionDisposition.QueueForReview
The method name then communicates the domain rule, while its implementation can be tested separately.
Put narrow patterns before broad ones
This arm is broad:
{ IsRanked: true } => SubmissionDisposition.AcceptRanked
If it appeared before the review arms, those review arms could never run. The compiler can detect many such subsumed patterns and report a diagnostic, but business-rule ordering should remain obvious to a human reader as well.
The final { } is deliberate here. At that point, every remaining non-null submission is valid and unranked. By contrast, if the selector were object from an untrusted integration boundary, a safer final arm would be:
{ } => throw new ArgumentException(
"Unsupported submission type.",
nameof(submission))
Do not use _ merely to silence an exhaustiveness warning. A catch-all arm should express a real policy for all remaining inputs.
Choosing property patterns, tuples, and nested switches
A property pattern is usually the clearest choice when one object is the center of the decision:
submission switch
{
{ IsRanked: true, Score: >= 100_000 } => ...,
{ IsRanked: true } => ...,
{ } => ...
};
A tuple pattern is clearer when the rule depends on several independent dimensions. For example, leaderboard multipliers might depend on whether an event is official, a time band, and whether a match is ranked:
public static decimal GetRewardMultiplier(
bool isOfficialEvent,
RewardTimeBand timeBand,
bool isRanked) =>
(isOfficialEvent, timeBand, isRanked) switch
{
(true, RewardTimeBand.Peak, true) => 2.0m,
(true, RewardTimeBand.Standard, true) => 1.5m,
(true, RewardTimeBand.OffPeak, _) => 0.75m,
_ => 1.0m
};
The discard in the off-peak rule says ranking status does not matter in that particular case.
Do not force every decision into one large switch. A senior-level refactor often improves code by introducing a small intermediate category first. For example:
- Categorize a timestamp as
Peak,Standard, orOffPeak. - Use that category alongside the other inputs in a compact tuple switch.
- Keep the calculation of the category independently testable.
That separates “what time band is this?” from “what policy applies to that band?” and prevents a single expression from becoming a dense wall of conditions.
For a concise walkthrough of relational, logical, and tuple patterns, watch this segment from Nick Chapsas’s The evolution of Pattern Matching in C#.
The evolution of Pattern Matching in C# (from version 6 to 10)
Watch Nick Chapsas demonstrate the C# 9 additions that made pattern matching practical for range-based and multi-input business rules.
Watch C# 9 patterns. Focus on the not, and, and or patterns first, then observe how tuple patterns replace combinations of unrelated Boolean checks. Treat the examples as syntax tools, not as a reason to compress every condition into one arm.
When not to use pattern matching
Pattern matching is a tool for classifying inputs. It is not automatically the best design for every conditional.
Keep a simple if when the condition is a straightforward guard clause:
if (cancellationToken.IsCancellationRequested)
{
return;
}
Keep behavior on a type when that behavior naturally belongs to the type and varies with its implementation. A growing switch over many game-rule classes may indicate missing polymorphism or an overly broad abstraction.
Avoid a giant switch expression when:
- individual arms contain multi-step workflows, persistence, logging, and side effects;
- rules need extensive explanation before they are understandable;
- a selector has dozens of combinations that could be reduced into named categories;
- the switch is becoming a substitute for a well-modeled domain concept.
A useful compromise is to use a switch expression to compute a decision, then perform the corresponding workflow elsewhere:
var disposition = Classify(submission);
return disposition switch
{
SubmissionDisposition.Reject => RejectSubmission(submission),
SubmissionDisposition.QueueForReview => QueueForReview(submission),
SubmissionDisposition.AcceptRanked => AcceptRankedSubmission(submission),
SubmissionDisposition.AcceptUnranked => AcceptUnrankedSubmission(submission),
_ => throw new InvalidOperationException(
$"Unknown disposition: {disposition}.")
};
For public APIs, this separation will later make validation, persistence, observability, and authorization easier to introduce without turning one endpoint method into a decision-and-workflow monolith.
Portfolio implementation: refactor one decision, not the whole codebase
Apply this to one small piece of the game backend, such as score classification, achievement eligibility, or leaderboard visibility.
Use this workflow:
- Preserve the existing behavior first. Write down the current rules as a table of input states and outcomes.
- Identify the selector. It may be one record, one enum, a nullable value, or a tuple of independent inputs.
- Put invalid or exceptional cases first. Handle
null, invalid ranges, and unsupported states deliberately. - Order specific cases before general cases.
- Use a final arm intentionally. It should either describe the valid remainder or reject an unsupported input.
- Build with warnings enabled.
dotnet build GameBackend.sln --configuration Release
During review, ask whether the refactored code makes the rule set easier to change safely. If an added rule requires editing several unrelated nested blocks, the refactor has not yet achieved its purpose.
Key takeaways
- Pattern matching is most valuable when conditional logic is really a classification problem.
switcharms are evaluated in order, so specific cases belong before broad cases.- Property patterns express object-state rules; tuple patterns express rules across independent inputs.
- Relational patterns and
and,or, andnotpatterns make range logic more readable than compound comparisons. - Use
whenfor a small condition that cannot be naturally represented as a pattern; extract complex guards into named methods. _matches everything, while{ }matches any non-null object.- Do not use a catch-all arm only to suppress warnings. Make it encode a meaningful fallback or failure policy.
- Keep simple guard clauses simple, and avoid turning pattern matching into a dense replacement for good domain design.
Next, you will move from synchronous decision logic to asynchronous I/O workflows and learn how to propagate CancellationToken correctly through a .NET call chain.
Can't find a good explanation? Sign up and we'll make it for you
Sign up