Are you sure that every if
and switch
in your code pulls its weight? I’ve reviewed enough PRs to know that many bugs live inside simple branches and loops. The good news: with a few rules of thumb and some modern C# features, you can write branches that read like plain English and loops that are fast, safe, and easy to test. In this post I’ll show you the patterns I use daily, why they work, and where they save real time in projects.
Why control flow still matters
Control statements decide what runs and when. A messy condition or the wrong loop type can:
- hurt performance (unneeded allocations, extra checks),
- hide bugs (off‑by‑one, wrong order of checks),
- reduce readability (future you won’t thank present you).
In my team, the biggest wins came not from “faster algorithms”, but from clearer branches, early returns, and a bit of pattern matching.
if
/else
: keep it short and honest
1) Guard clauses first
Skip deep nesting. Fail fast, then continue with the happy path.
public static Order PlaceOrder(User? user, Cart cart)
{
if (user is null) throw new ArgumentNullException(nameof(user));
if (!cart.Items.Any()) throw new InvalidOperationException("Cart is empty");
if (!user.IsVerified) return Order.Rejected("User not verified");
// Happy path is now flat and readable
return Order.Confirm(user, cart);
}
Tip: prefer return
, continue
, or throw
over adding another else
level.
2) Short‑circuiting is your friend
Use &&
and ||
to avoid extra work and null checks.
if (customer != null && customer.IsActive && customer.Email?.EndsWith("@example.com") == true)
{
SendPromo(customer);
}
The checks stop as soon as the result is known.
3) Cache repeated values
Avoid calling properties or methods in every comparison inside a loop.
for (int i = 0, n = items.Count; i < n; i++)
{
if (items[i].Price > limit) break;
}
switch
: from statement to expression
C# gives you two flavors:
switch
statement – classic branching withcase
labels.switch
expression – compact mapping that returns a value.
When to use which
- Use the expression when you map an input to a value: status → text, enum → handler, range → category.
- Use the statement when you run sequences of steps or need multiple actions per branch.
Modern switch
expression examples
Map status codes to a group
public static string ClassifyStatus(int code) => code switch
{
>= 200 and < 300 => "Success",
304 => "NotModified",
400 or 401 or 403 => "ClientError",
404 => "NotFound",
429 => "TooManyRequests",
>= 500 => "ServerError",
_ => "Other"
};
Notes:
- Order matters: first matching arm wins.
- Always end with
_
(the catch‑all) unless you really want the compiler to warn about missing cases.
Type and property patterns
using System.Globalization;
public static string FormatCell(object? value) => value switch
{
null => "<null>",
string s when s.Length > 32 => s[..32] + "…",
string s => s,
int n => n.ToString("N0", CultureInfo.InvariantCulture),
decimal d => d.ToString("F2", CultureInfo.InvariantCulture),
User { Age: >= 18, IsVerified: true } => "Verified adult",
IEnumerable<int> nums => "[" + string.Join(",", nums.Take(5)) + (nums.Skip(5).Any() ? ",…]" : "]"),
_ => value?.ToString() ?? "<unknown>"
};
Here we mix type patterns, guards (when
), relational patterns (>=
), and property patterns.
A safe “fallthrough”
C# doesn’t allow implicit fallthrough, but you can share logic by pointing to the same arm:
var priceBand = countryCode switch
{
"US" or "CA" => "NorthAmerica",
"DE" or "FR" or "NL" => "WesternEurope",
_ => "Other"
};
Loops: for
, foreach
, while
, do
Each loop has a sweet spot. Pick the one that makes intent clear.
foreach
: readability first
Use it for most collection walks.
foreach (var item in orders)
{
if (item.Total <= 0) continue;
Process(item);
}
Avoid modifying the collection you iterate. If you must remove items, iterate backwards with for
.
for (int i = list.Count - 1; i >= 0; i--)
{
if (ShouldRemove(list[i])) list.RemoveAt(i);
}
for
: indexes and spans
Use for
when you need an index or you work with Span<T>
/arrays.
Span<int> squares = stackalloc int[10];
for (int i = 0; i < squares.Length; i++)
{
squares[i] = i * i;
}
while
: unknown length streams
using var reader = File.OpenText(path);
string? line;
while ((line = await reader.ReadLineAsync()) is not null)
{
// Process each line
}
do
/while
: run at least once
string input;
do
{
input = Console.ReadLine() ?? string.Empty;
} while (input.Length == 0);
break
, continue
, return
break
– leave the current loop.continue
– skip to next iteration.return
– when you’re done with the whole method.
Use the smallest hammer that expresses your intent.
Pattern matching for business rules
Pattern matching often makes boolean soup readable.
public record User(int Age, bool IsVerified, string Country);
public static bool CanRentCar(User u) => u switch
{
{ Country: "US", Age: >= 25 } => true,
{ Country: "US", Age: >= 21, IsVerified: true } => true,
{ Country: "DE", Age: >= 21 } => true,
_ => false
};
This reads like a policy document, not like nested if
chains.
Trick: keep the most specific cases first, then fall back to broader ones.
Replace boolean soup with named checks
Give long expressions names. Your future self will thank you.
bool IsHoliday(Order o) => o.Date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
bool IsHighValue(Order o) => o.Total >= 10_000m;
if (IsHoliday(order) || IsHighValue(order))
{
RequireManagerApproval(order);
}
Error handling inside loops
Throwing on every iteration is slow and noisy. Prefer Try
patterns.
if (int.TryParse(input, out var n))
{
numbers.Add(n);
}
else
{
_logger.LogWarning("Invalid number: {Input}", input);
}
For async work in loops, pass a CancellationToken
and honor it:
public static async Task ProcessAllAsync(IEnumerable<Job> jobs, CancellationToken ct)
{
foreach (var job in jobs)
{
ct.ThrowIfCancellationRequested();
await RunJobAsync(job, ct);
}
}
Small performance wins (without micro‑optimizing)
- Avoid extra allocations in hot paths. For simple transforms, prefer
switch
expressions and span‑based loops. - Hoist constants: cache
items.Count
orarray.Length
when it saves repeated work. - Use
Regex
precompilation if a regex runs in a loop. - Prefer
foreach
forIEnumerable<T>
; usefor
when the index matters or for arrays/spans. - Don’t build strings in loops. Use
StringBuilder
or write to aSpan<char>
if you need low‑level control. - Break early when you already know the answer.
Example of early exit:
public static bool HasDuplicateId(IEnumerable<Customer> customers)
{
var seen = new HashSet<int>();
foreach (var c in customers)
{
if (!seen.Add(c.Id)) return true; // found duplicate, we’re done
}
return false;
}
When to use LINQ vs loops
LINQ reads nicely for transforms and filters, but it can hide allocations or multiple passes. A simple rule:
- Readability first. Prefer LINQ when it keeps the intent clear and performance is fine.
- Switch to loops for hot paths, custom indexing, or when profiling shows a problem.
// Clear intent, good default
var activeEmails = users
.Where(u => u.IsActive)
.Select(u => u.Email)
.ToList();
// Hot path alternative
var list = new List<string>(users.Count);
for (int i = 0; i < users.Count; i++)
{
var u = users[i];
if (u.IsActive) list.Add(u.Email);
}
State machines with switch
A compact state loop often reads better than scattered flags.
enum ParseState { Start, Header, Body, Done, Error }
ParseState Next(ParseState s, Token t) => (s, t.Type) switch
{
(ParseState.Start, TokenType.Hash) => ParseState.Header,
(ParseState.Header, TokenType.NewLn) => ParseState.Body,
(ParseState.Body, TokenType.EOF) => ParseState.Done,
_ => ParseState.Error
};
You can then drive it:
var state = ParseState.Start;
foreach (var token in tokens)
{
state = Next(state, token);
if (state is ParseState.Done or ParseState.Error) break;
}
Testing branches and loops
- One assert per scenario keeps tests focused.
- Name tests by rule (“
RentCar_US_21_Verified_true
”). - Use data‑driven tests for tables of inputs.
[Theory]
[InlineData("US", 25, true, true)]
[InlineData("US", 21, true, true)]
[InlineData("US", 21, false, false)]
[InlineData("DE", 21, false, true)]
public void CanRentCar_rules(string country, int age, bool verified, bool expected)
{
var u = new User(age, verified, country);
Assert.Equal(expected, CanRentCar(u));
}
Naming and clarity checklist
Use this quick pass on every PR:
- Are guard clauses placed at the top?
- Are conditions small, named, and ordered from simple to complex?
- Would a
switch
expression read better than nestedif
? - Are loops using the right tool (
foreach
by default,for
when needed)? - Is there an early exit where possible?
- Do tests cover the tricky edges first?
A tiny flow diagram you can copy into a PR
+----------+ no +------------------+
| Input | -----> | Guard returns |
+----------+ +------------------+
| yes
v
+-----------------+ map +------------------+
| Simple mapping | -----> | switch expression|
+-----------------+ +------------------+
| else
v
+--------------------+ +-----------------+
| Multi-step branch | ---> | switch statement|
+--------------------+ +-----------------+
FAQ: short answers to common questions
_
catch‑all in a switch
expression? Usually yes. It keeps the compiler from complaining and makes intent clear. Omit it only when you want warnings for new enum members.
switch
expressions slower than if
chains? In most real code, they’re comparable. The choice should be readability first. Measure if the path is hot.
goto
ever OK? Rarely in modern C#. Clear branches and loops beat goto
. State machines or small functions are cleaner.
Guard clauses, early returns, and extracting small methods. Each method should do one thing.
for
over foreach
? When you need the index, when you modify by index, or when working with arrays/spans.
Avoid them for expected cases. Use TryParse
/TryGetValue
and log.
if
? Yes. is
with patterns in if
is great for single checks; use a switch
expression when mapping many patterns.
Conclusion: cleaner branches, safer loops, happier code
Strong control flow is about clarity first and small, local wins: guards at the top, switch
expressions for mapping, the right loop for the job, and early exits. Start applying the checklist in your next PR and see how much noise it removes.
What’s the smallest control‑flow tweak that saved you the most time? Share it in the comments – I’ll add the best ones to my checklist.