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:
- switchstatement – classic branching with- caselabels.
- switchexpression – 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 switchexpressions and span‑based loops.
- Hoist constants: cache items.Countorarray.Lengthwhen it saves repeated work.
- Use Regexprecompilation if a regex runs in a loop.
- Prefer foreachforIEnumerable<T>; useforwhen the index matters or for arrays/spans.
- Don’t build strings in loops. Use StringBuilderor 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 switchexpression read better than nestedif?
- Are loops using the right tool (foreachby default,forwhen 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.

