2026-04-05

Understanding C# to Advance (with Examples)

This post is a hands-on guide to help you move from comfortable to confident in modern C#. You’ll learn the why and how behind features you’ll use daily in production code.

TL;DR

  • Know your value vs reference semantics
  • Embrace async/await, cancellation, and streaming
  • Use pattern matching and records for clarity
  • Write efficient queries with LINQ (and know when not to)
  • Wire clean architecture with DI and minimal APIs
  • Test async + edge cases, enable nullable reference types
  • Keep performance in mind with spans and allocation-aware patterns

1) Value vs Reference Types (and how it bites)

Structs (value types) copy by value. Classes (reference types) copy references.

Bug-prone example:

public struct Counter
{
    public int Value;
}

static void Increment(Counter c) => c.Value++; // operates on a copy!

var c = new Counter { Value = 0 };
Increment(c);
Console.WriteLine(c.Value); // 0 (surprise)

Fix with ref, or make it a class if you need reference semantics:

static void Increment(ref Counter c) => c.Value++;

var c = new Counter { Value = 0 };
Increment(ref c);
Console.WriteLine(c.Value); // 1

Prefer immutability when possible to avoid accidental state sharing:

public record Money(decimal Amount, string Currency);

var m1 = new Money(10m, "USD");
var m2 = m1 with { Amount = 12m }; // non-destructive mutation

Guidelines:

  • Use struct for small, immutable, copy-friendly types (e.g., coordinates, money, ids).
  • Use class when identity, inheritance, or shared mutable state matter.

2) Allocation-aware parsing with Span/Memory

Span<T> and ReadOnlySpan<T> let you slice without allocating new strings/arrays.

Naive CSV parse allocates for each Split:

string[] parts = line.Split(','); // allocations per field

Span-based approach avoids allocations:

public static (ReadOnlySpan<char> A, ReadOnlySpan<char> B, ReadOnlySpan<char> C) First3Csv(ReadOnlySpan<char> line)
{
    int i1 = line.IndexOf(',');
    var a = i1 < 0 ? line : line[..i1];
    if (i1 < 0) return (a, ReadOnlySpan<char>.Empty, ReadOnlySpan<char>.Empty);

    var rem = line[(i1 + 1)..];
    int i2 = rem.IndexOf(',');
    var b = i2 < 0 ? rem : rem[..i2];
    var c = i2 < 0 ? ReadOnlySpan<char>.Empty : rem[(i2 + 1)..];
    return (a, b, c);
}

var (a, b, c) = First3Csv("alice,24,NY".AsSpan());

Tips:

  • Use .AsSpan() on strings for read-only spans.
  • Prefer ReadOnlySpan<char> params for high-throughput parsing.
  • In async APIs or long-lived storage, use Memory<T> instead.

3) Async/Await done right

Rules of thumb:

  • Always accept CancellationToken in async methods
  • In library code, use ConfigureAwait(false) to avoid capturing contexts
  • Use await using for IAsyncDisposable
  • Prefer streaming with IAsyncEnumerable<T> for large datasets

Example: robust HTTP call with cancellation and streaming

public sealed class GitHubClient(HttpClient http)
{
    public async Task<string> GetUserRawAsync(string id, CancellationToken ct = default)
    {
        using var req = new HttpRequestMessage(HttpMethod.Get, $"/users/{id}");
        using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
        resp.EnsureSuccessStatusCode();
        return await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
    }
}

Async stream (server-sent sequence or paging):

public async IAsyncEnumerable<int> CountToAsync(int n, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
    for (var i = 1; i <= n; i++)
    {
        ct.ThrowIfCancellationRequested();
        await Task.Delay(10, ct).ConfigureAwait(false);
        yield return i;
    }
}

await foreach (var x in CountToAsync(5, ct))
{
    Console.WriteLine(x);
}

4) Pattern Matching, Records, and Clearer Domain Models

Use property, relational, and logical patterns to encode rules declaratively:

public record Order(decimal Total, decimal Weight, string Country);

public static decimal ShippingOf(Order o) => o switch
{
    { Total: > 100m, Country: "US" } => 0m,
    { Weight: <= 1m }               => 4.99m,
    { Country: "CA" }              => 7.49m,
    _                               => 9.99m
};

Combining guards (when) keeps branches tight:

public static string RiskLevel(int score) => score switch
{
    < 0 or > 100 => throw new ArgumentOutOfRangeException(nameof(score)),
    >= 80        => "High",
    >= 50        => "Medium",
    _            => "Low"
};

5) LINQ without regrets

LINQ is expressive but can hide expensive multiple enumerations.

Bad:

var query = orders.Where(o => o.Total > 100m);
var count = query.Count();     // 1st enumeration
var list  = query.ToList();    // 2nd enumeration (expensive source = twice the cost)

Better:

var list = orders.Where(o => o.Total > 100m).ToList();
var count = list.Count; // already in memory

Grouping and top-N:

var topCustomers = orders
    .GroupBy(o => o.CustomerId)
    .Select(g => new { CustomerId = g.Key, Total = g.Sum(o => o.Total) })
    .OrderByDescending(x => x.Total)
    .Take(10)
    .ToList();

Tips:

  • Keep queries lazy until the edge, then materialize once.
  • For DB queries (EF Core), keep as IQueryable until the DB; for in-memory, use IEnumerable.
  • For hot paths, prefer loops or Span<T> over LINQ to reduce allocations.

6) Exceptions and the Result pattern

Reserve exceptions for exceptional cases, not control flow. Prefer Try... APIs:

if (!int.TryParse(input, out var value))
    return Problem("Invalid number.");

A lightweight Result type can improve clarity:

public readonly record struct Result<T>(bool Ok, T? Value, string? Error)
{
    public static Result<T> Success(T value) => new(true, value, null);
    public static Result<T> Fail(string error) => new(false, default, error);
}

public static Result<int> ParsePositiveInt(string s)
{
    if (!int.TryParse(s, out var v)) return Result<int>.Fail("Not a number");
    if (v <= 0) return Result<int>.Fail("Must be positive");
    return Result<int>.Success(v);
}

7) Dependency Injection and Minimal APIs (clean wiring)

Minimal API with DI, typed client, and options:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOptions<MyOptions>()
    .Bind(builder.Configuration.GetSection("My"))
    .ValidateDataAnnotations();

builder.Services.AddHttpClient<GitHubClient>(c =>
{
    c.BaseAddress = new Uri("https://api.github.com/");
    c.DefaultRequestHeaders.UserAgent.ParseAdd("myapp/1.0");
});

builder.Services.AddScoped<IMailer, SmtpMailer>();

var app = builder.Build();

app.MapGet("/users/{id}", async (string id, GitHubClient gh, ILoggerFactory lf, CancellationToken ct) =>
{
    var logger = lf.CreateLogger("Users");
    logger.LogInformation("Fetching {Id}", id);
    return Results.Text(await gh.GetUserRawAsync(id, ct));
});

app.Run();

public sealed class MyOptions
{
    public required string ApiKey { get; init; }
}

public interface IMailer { Task SendAsync(string to, string subject, string body, CancellationToken ct = default); }
public sealed class SmtpMailer : IMailer
{
    public Task SendAsync(string to, string subject, string body, CancellationToken ct = default)
        => Task.CompletedTask; // stub
}

Notes:

  • Prefer typed clients over IHttpClientFactory.CreateClient("name") for testability and cohesion.
  • Validate options at startup to fail fast.

8) Testing async, data-driven, and edge cases

xUnit examples:

public class MathTests
{
    [Theory]
    [InlineData("5", 5)]
    [InlineData("01", 1)]
    public void ParsePositiveInt_Valid(string input, int expected)
    {
        var r = ParsePositiveInt(input);
        Assert.True(r.Ok);
        Assert.Equal(expected, r.Value);
    }

    [Theory]
    [InlineData("-1")]
    [InlineData("abc")]
    public void ParsePositiveInt_Invalid(string input)
    {
        var r = ParsePositiveInt(input);
        Assert.False(r.Ok);
        Assert.NotNull(r.Error);
    }

    [Fact]
    public async Task CountToAsync_CanCancel()
    {
        using var cts = new CancellationTokenSource(1);
        await Assert.ThrowsAsync<TaskCanceledException>(async () =>
        {
            await foreach (var _ in CountToAsync(1000, cts.Token)) { }
        });
    }
}

9) Nullable Reference Types (NRT) = fewer bugs

Turn on NRT and fix warnings instead of bugs in prod.

In your .csproj:

<PropertyGroup>
  <Nullable>enable</Nullable>
  <WarningsAsErrors>true</WarningsAsErrors>
</PropertyGroup>

Use ? for references that can be null, and handle them:

string? maybeName = GetNameOrNull();
if (string.IsNullOrWhiteSpace(maybeName))
    return;
Console.WriteLine(maybeName.Length);

10) Performance-minded checklist

  • Prefer readonly struct and in parameters for large value types you only read
  • Avoid unnecessary allocations (watch string concatenation in loops; use StringBuilder or spans)
  • Cache regexes: private static readonly Regex Rx = new("pattern", RegexOptions.Compiled);
  • Use ArrayPool<T> for large, transient buffers
  • Minimize boxing: prefer generics over object, avoid struct + interface hot paths
  • Measure with BenchmarkDotNet; don’t guess

11) EditorConfig/Analyzers to keep code sharp

Adopt consistent rules and fail CI on critical warnings.

# .editorconfig
root = true

[*.cs]
dotnet_diagnostic.CS8618.severity = error   # Non-nullable uninitialized
dotnet_diagnostic.CS8602.severity = error   # Dereference of possibly null

dotnet_style_null_propagation = true:suggestion
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_prefer_primary_constructors = true:suggestion

Putting it together: a tiny, robust pipeline

public static async Task ProcessUsersAsync(Stream input, GitHubClient gh, IMailer mail, CancellationToken ct)
{
    using var reader = new StreamReader(input);
    string? line;
    while ((line = await reader.ReadLineAsync(ct)) is not null)
    {
        var span = line.AsSpan();
        var comma = span.IndexOf(',');
        if (comma < 0) continue;
        var id = new string(span[..comma]);
        var email = new string(span[(comma + 1)..]);

        string json = await gh.GetUserRawAsync(id, ct);
        if (json.Contains("\"site_admin\": true", StringComparison.Ordinal))
        {
            await mail.SendAsync(email, "Welcome Admin", "Hi there!", ct);
        }
    }
}

This combines spans, async IO, DI-friendly components, and careful string handling.


Where to go next

  • Official C# docs and language reference
  • .NET Performance docs + BenchmarkDotNet
  • Async guidance by Stephen Toub (blogs and talks)
  • EF Core docs for LINQ-to-Entities specifics

Master these foundations and your day-to-day C# will become faster, clearer, and safer.