Search Tech Journey

Find topics, journeys and posts

back to blog
system designintermediate 38m2026-06-10

MS Stack Ch 3 — C# language

C# 12 + .NET 8: the type system, LINQ, generics, async/await, pattern matching, records, nullable reference types. The language you'll write 1000s of lines of in production.

Chapter 3 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.

Why this chapter

C# in 2026 is not the same language as C# in 2016. Records, pattern matching, top-level statements, nullable reference types, file-scoped namespaces, primary constructors, collection expressions, and required members all change how idiomatic code looks. Reading modern C# with a 2016 mental model feels alien; writing 2016-style code into a modern codebase makes reviewers wince. This chapter is the modern C# you will actually write into the ASP.NET Core API, the EF Core models, and the background workers.

The language is also the lever for almost every other chapter. Async/await is the foundation of every HTTP call and database query in chapter 5. The type system and nullable reference types are what keep EF Core models in chapter 11 honest. LINQ powers the data pipeline in chapter 12. Pattern matching is how you write clean controller logic in chapter 5 and middleware in chapter 19. Get this chapter right and the next eight feel obvious; skip it and every controller looks like an accident.

Shipping-tier means you can write idiomatic C# 12: records + with for DTOs, switch expressions for type discrimination, async/await without deadlock, NRT enabled with warnings clean, LINQ without double-enumeration, and the right collection for the use case. Expert-tier means you have used Span<T>/Memory<T> for zero-allocation parsing, written a ValueTask returning method for a hot path, built a Roslyn source generator, used ref struct and readonly ref for performance, and can read the IL the compiler emits when something looks slow.

You finish this chapter when you can read any modern C# 12 file at a glance, write a complete async repository class with cancellation and NRT, and explain why record class and record struct exist as separate things.

Concepts and depth

Type system — value vs reference, boxing, nullable value vs nullable reference

C# has two universes. Value types (int, double, bool, DateTime, Guid, every struct, every enum) are stored inline — on the stack in locals, in-line inside the containing object on the heap. Assignment copies the bits. Equality is field-by-field if you define it that way (records and tuples do; bare structs use reflection-based default which is slow — always override Equals/GetHashCode on custom structs).

Reference types (string, arrays, List<T>, every class, every record class) live on the heap; the variable holds a reference (a pointer). Assignment copies the reference, not the data. Two variables can point at the same object. Equality is reference identity by default (object.Equals) — override or use record class to get value equality.

Boxing happens when a value type is stored in a reference-typed slot: object x = 42 copies the int onto the heap and stores a reference. (int)x unboxes (copies back). Boxing allocates, hurts perf in hot paths, and breaks identity (object.ReferenceEquals(box1, box2) is false even for the same int). Generics eliminate boxing in collection scenarios — List<int> stores ints directly, unlike ArrayList.

Nullable value types (int? = Nullable<int>) wrap a value type with a HasValue flag — they are still value types, still stack-allocated, just carry the null bit. Nullable reference types (NRT, C# 8+) are not a runtime feature — they are compile-time annotations. string? and string are the same System.String at runtime; the difference is whether the compiler will warn you when you assign null or dereference. Turn it on in every csproj:

<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors>nullable</WarningsAsErrors>
Good enough to ship
  • • Pick struct vs class deliberately
  • • NRT enabled, warnings clean (no stray !)
  • • Avoid boxing in hot paths
Expert tier
  • readonly struct + in parameters for zero-copy
  • ref struct (Span<T>) for stack-only types
  • • Custom IEqualityComparer<T> for HashSet/Dictionary keys

Classes, structs, records, interfaces, abstract, sealed

A class is the default reference type. A struct is the default value type. Use struct when the type is small (≤ 16 bytes is the rule of thumb), immutable, and primarily compared by value (think DateTime, TimeSpan, Money). Use class for everything else. record class (the default record keyword) gives you a reference type with value equality, immutability by default, and a with expression for non-destructive mutation. record struct gives you the same conveniences over a value type.

Interfaces are shape contracts (IEnumerable<T>, IDisposable, ILogger<T>). Since C# 8 they can have default methods, which mostly exist for ABI evolution of frameworks — most app code does not need them. Interfaces are the basis of dependency injection: register IUserRepository → resolve to whatever implementation the DI container chose.

abstract class is a partial implementation that cannot be instantiated; subclasses fill in the abstract members. Use sparingly — composition (DI + interfaces) is usually better than inheritance. sealed prevents subclassing; mark classes sealed by default if you do not specifically intend them as base classes. Sealed classes also let the JIT devirtualise calls (small perf win), and sealing record types is a common micro-optimisation.

public sealed record User(Guid Id, string Email, DateTimeOffset CreatedAt);

var u1 = new User(Guid.NewGuid(), "alice@example.com", DateTimeOffset.UtcNow);
var u2 = u1 with { Email = "alice@new.example.com" };
Console.WriteLine(u1 == u2);   // False — value equality
Console.WriteLine(u1.ToString()); // User { Id = ..., Email = ..., CreatedAt = ... }

Properties, indexers, expression-bodied members

public class User
{
    public Guid Id { get; init; }                       // init-only — set during construction
    public string Email { get; private set; }           // public read, private write
    public string Display => $"{Email} ({Id:N})";       // expression-bodied get-only

    public int this[int year] => Login.Count(l =&gt; l.Year == year);  // indexer

    public required string Name { get; init; }          // C# 11: must be set
}

Auto-properties are the default; only write full backing-field properties when you need notification (INPC), validation, or computed-on-set logic. init accessors make the property settable only during object initialisation — pairs with records and with expressions. required (C# 11) forces the caller to set the property in an object initialiser, eliminating the "I forgot a field" class of bugs. Indexers (this[...]) let your type be used with obj[key] syntax — useful for collection-like types.

Generics — constraints, variance

Generics let you write code once that works for many types, with compile-time type safety and no boxing.

public class Cache&lt;TKey, TValue&gt; where TKey : notnull
{
    private readonly Dictionary&lt;TKey, TValue&gt; _store = new();
    public void Set(TKey k, TValue v) =&gt; _store[k] = v;
    public TValue? Get(TKey k) =&gt; _store.TryGetValue(k, out var v) ? v : default;
}

Common constraints: where T : class (reference type), where T : struct (value type), where T : notnull (non-nullable — applies to NRT), where T : new() (parameterless ctor), where T : SomeBaseClass, where T : ISomeInterface, where T : unmanaged (blittable value type, for Span/Memory perf code). Combine with commas.

Variance is out (covariant — IEnumerable<Cat> is assignable to IEnumerable<Animal>) and in (contravariant — Action<Animal> is assignable to Action<Cat>). Most app code never declares variance; you consume it via framework interfaces like IEnumerable<T> and IComparer<T>. The rule of thumb: an interface that only returns T is covariant; one that only accepts T is contravariant; one that both produces and consumes T cannot be either (like IList<T>).

Delegates, events, lambdas, expression trees (awareness)

A delegate is a typed function pointer: Action<int> (returns void), Func<int, string> (takes int, returns string), Predicate<T> (returns bool). Lambdas (x =&gt; x * 2) are inline delegate literals; the compiler infers the type from context. Events are a thin layer on top of delegates that restricts callers from invoking or overwriting — use them for the publisher-subscriber pattern in legacy WPF/WinForms code; in modern web code prefer IObservable<T> or Channel<T> for streams.

Expression trees (Expression&lt;Func&lt;T, bool&gt;&gt;) capture a lambda as a data structure instead of compiling it to IL. This is what powers IQueryable<T> and EF Core: when you write db.Users.Where(u =&gt; u.Active), EF Core inspects the expression tree and translates it to SQL. Application code rarely builds expression trees by hand (use System.Linq.Expressions if you must); the awareness is "if a parameter is Expression<Func<...>>, the framework will inspect and translate it, so closures over local state will not work the same way they do for plain Func<...>".

Pattern matching — is, switch expressions, property patterns, list patterns

Pattern matching turns long if/else chains into single, exhaustive expressions:

public string Describe(object o) =&gt; o switch
{
    null                            =&gt; "nothing",
    int n when n &lt; 0                =&gt; $"negative int {n}",
    int n                           =&gt; $"int {n}",
    string { Length: 0 }            =&gt; "empty string",
    string s                        =&gt; $"string of {s.Length}",
    User { Email.Length: &gt; 5 }      =&gt; "user with real email",
    User u                          =&gt; $"user {u.Id}",
    [1, 2, .. var rest]             =&gt; $"list starting 1,2 with {rest.Length} more",
    (int x, int y)                  =&gt; $"tuple ({x},{y})",
    _                               =&gt; "something else"
};

Four pattern families: type patterns (x is int n — tests and binds), property patterns (User { Email.Length: &gt; 5 } — matches nested properties), relational patterns (n is &gt; 0 and &lt; 100), and list patterns ([1, .., 3] matches arrays/lists starting with 1 ending with 3). Combine with and, or, not. The compiler proves exhaustiveness on sealed hierarchies; add _ =&gt; throw new UnreachableException() to silence warnings when you have genuinely covered every case.

Good enough to ship
  • is patterns for null checks and casts
  • switch expressions for type discrimination
  • • Property patterns for nested matching
Expert tier
  • • List patterns for parsing
  • • Discriminated unions via sealed records + exhaustive switch
  • • Source-generated polymorphic deserialisation that switches on a Type discriminator

LINQ — query vs method syntax, common operators, deferred execution

LINQ has two equivalent forms: method syntax (preferred in 2026) and query syntax (rare in modern code). Both compile to the same calls.

var topUsers = users
    .Where(u =&gt; u.IsActive)
    .OrderByDescending(u =&gt; u.CreatedAt)
    .Take(10)
    .Select(u =&gt; new { u.Id, u.Email })
    .ToList();

Deferred execution is the trap that bites every junior. Where, Select, OrderBy are deferred — they return a new IEnumerable<T> describing the query but do nothing until you enumerate. ToList, ToArray, Sum, Count, First, Any, foreach force enumeration. Two consequences: (a) the source runs every time you enumerate, so .ToList() once and reuse if iteration is expensive; (b) capturing the loop variable in a closure inside a deferred query gives you the last value (modern C# 5+ fixed this for foreach but not for for).

IEnumerable<T> vs IQueryable<T>IEnumerable<T> is in-memory LINQ-to-Objects; predicates are Func<T, bool> and execute in your process. IQueryable<T> is remote LINQ (LINQ-to-Entities for EF Core); predicates are Expression<Func<T, bool>> and are inspected then translated to SQL/Cosmos/whatever. The big gotcha: once you call a non-translatable method inside an IQueryable predicate, EF Core throws at runtime or silently materialises everything client-side. Always materialise (.ToListAsync()) before doing in-memory work.

Collections — List, Dictionary, HashSet, IReadOnlyList, ImmutableArray

Pick the right collection: List<T> is the default — O(1) indexed access and add-at-end, O(N) insert/remove in the middle. Dictionary<TKey, TValue> is O(1) average lookup/insert/remove; requires GetHashCode + Equals on TKey (records and value types get this right; custom classes need overrides). HashSet<T> is O(1) Contains and supports set operations (UnionWith, IntersectWith, ExceptWith). Queue<T> / Stack<T> for FIFO/LIFO; rarely needed in app code. ConcurrentDictionary<TKey, TValue> when multiple threads write.

ImmutableArray<T> / ImmutableList<T> for true immutability — every "mutation" returns a new instance, sharing structure with the old one. Use in records for collections that should be value-equal. Slightly higher overhead than List<T>; worth it for hot DTOs you compare or hash.

In API surfaces, expose IReadOnlyList<T> / IReadOnlyDictionary<TKey, TValue> instead of List / Dictionary to prevent callers from mutating your internal collection by accident.

Exceptions — try/catch/finally, throw vs throw ex, custom types, when NOT to catch

Exceptions are for exceptional cases. They are expensive (stack walking, hot-path JIT optimisation killer). Do not use them for control flow. For expected failures (validation, not-found, conflict), return a result type or null. For genuinely-exceptional cases (network failure, OOM, invariant violation), throw.

try
{
    var user = await GetUserAsync(id, ct);
    return user;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    throw;  // cancellation — let it propagate
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    return null;  // filter clause — only catches matching predicates
}
catch (Exception ex)
{
    _logger.LogError(ex, "Failed to get user {Id}", id);
    throw;  // rethrow preserving stack
}
finally
{
    // always runs
}

throw; vs throw ex;throw; preserves the original stack trace. throw ex; resets it to the rethrow point, losing the original source location. Always throw; unless you are wrapping in a new exception (throw new MyException("...", ex);). Define custom exception types when callers need to discriminate; otherwise reuse framework types (ArgumentException, InvalidOperationException, TimeoutException).

When NOT to catch: never catch (Exception) and swallow — log and rethrow at minimum. Never catch OperationCanceledException and hide it. Never catch in code that could not handle the failure (let it propagate up to a layer that can).

IDisposable + using, IAsyncDisposable + await using

public async Task ProcessAsync()
{
    await using var conn = new SqlConnection(_connStr);
    await conn.OpenAsync();
    using var cmd = conn.CreateCommand();
    cmd.CommandText = "SELECT 1";
    var result = await cmd.ExecuteScalarAsync();
}   // cmd disposed first, then conn (LIFO)

Anything implementing IDisposable (file handles, DB connections, HTTP clients you own, cancellation token sources) must be disposed. using var x = ... (C# 8+) scopes disposal to the enclosing block. await using is for IAsyncDisposable (async cleanup — flushing a DB connection, closing a network stream).

Disposal order is LIFO — last declared is disposed first. Wrap in try/finally automatically; the using statement guarantees disposal even on exception. Never dispose a singleton (e.g., HttpClient from IHttpClientFactory) — the container owns its lifetime.

Equality and hashing

Override Equals and GetHashCode together — always — for any type you put in a HashSet, use as a Dictionary key, or compare with ==. Records do this automatically based on all positional/declared properties. For custom value types, prefer record struct. For custom reference types where you want value equality, prefer record class.

The GetHashCode contract: equal objects must return the same hash code; unequal objects should return different hash codes (collisions allowed but reduce performance). Use HashCode.Combine(a, b, c) rather than rolling your own. Hash codes must be immutable for the object's lifetime in the collection — never use mutable fields in GetHashCode.

File I/O basics

// Read all at once
string text = await File.ReadAllTextAsync(path, ct);
byte[] bytes = await File.ReadAllBytesAsync(path, ct);
string[] lines = await File.ReadAllLinesAsync(path, ct);

// Stream for large files
await using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
await foreach (var line in reader.ReadLineAsync(ct).ToAsyncEnumerable()) { /* ... */ }

// Write
await File.WriteAllTextAsync(path, text, ct);

Use Path.Combine to build paths cross-platform. Use Path.GetFullPath and validate against an expected root to defend against path traversal (../../etc/passwd). For large files, stream — never ReadAllBytes a 5 GB log.

Reflection (awareness only)

Reflection lets code inspect and invoke types at runtime: typeof(User).GetProperties(), Activator.CreateInstance(type), MethodInfo.Invoke(...). It is the basis of frameworks (DI, serialisation, ORM mapping) but slow and AOT-unfriendly. In app code, prefer source generators (compile-time codegen) over reflection — System.Text.Json's source generator produces zero-allocation, AOT-friendly serialisers. Reach for reflection only when you genuinely need late binding.

Worked examples

1. A Result<T, E> discriminated union with pattern matching

public abstract record Result&lt;T, E&gt;
{
    public sealed record Ok(T Value)    : Result&lt;T, E&gt;;
    public sealed record Err(E Error)   : Result&lt;T, E&gt;;

    public bool IsOk =&gt; this is Ok;

    public TOut Match&lt;TOut&gt;(Func&lt;T, TOut&gt; ok, Func&lt;E, TOut&gt; err) =&gt; this switch
    {
        Ok o   =&gt; ok(o.Value),
        Err e  =&gt; err(e.Error),
        _      =&gt; throw new UnreachableException()
    };
}

public enum UserError { InvalidEmail, AlreadyExists, RateLimited }

public static Result&lt;User, UserError&gt; CreateUser(string email)
    =&gt; !email.Contains('@')
        ? new Result&lt;User, UserError&gt;.Err(UserError.InvalidEmail)
        : new Result&lt;User, UserError&gt;.Ok(new User(Guid.NewGuid(), email, DateTimeOffset.UtcNow));

// Usage
var result = CreateUser("alice@example.com");
var message = result.Match(
    ok:  u =&gt; $"Created {u.Id}",
    err: e =&gt; $"Failed: {e}"
);

What to notice:

  • abstract record + sealed record subtypes give you a discriminated union with value equality.
  • Match forces both arms to be handled — exhaustive by construction.
  • No exceptions for expected failures; the type tells the caller "this can fail".
  • Pairs with switch on the result for ergonomic call sites.
  • Same shape as Rust's Result&lt;T, E&gt; and F#'s Result&lt;'T, 'TError&gt;.

2. An async repository with cancellation, NRT, and proper disposal

public interface IUserRepository
{
    Task&lt;User?&gt; GetAsync(Guid id, CancellationToken ct);
    Task&lt;Guid&gt;  CreateAsync(User user, CancellationToken ct);
    Task&lt;bool&gt;  DeleteAsync(Guid id, CancellationToken ct);
}

public sealed class SqlUserRepository(string connectionString) : IUserRepository
{
    public async Task&lt;User?&gt; GetAsync(Guid id, CancellationToken ct)
    {
        await using var conn = new SqlConnection(connectionString);
        await conn.OpenAsync(ct);
        return await conn.QuerySingleOrDefaultAsync&lt;User&gt;(
            "SELECT Id, Email, CreatedAt FROM Users WHERE Id = @id",
            new { id });
    }

    public async Task&lt;Guid&gt; CreateAsync(User user, CancellationToken ct)
    {
        await using var conn = new SqlConnection(connectionString);
        await conn.OpenAsync(ct);
        await conn.ExecuteAsync(
            "INSERT INTO Users (Id, Email, CreatedAt) VALUES (@Id, @Email, @CreatedAt)",
            user);
        return user.Id;
    }

    public async Task&lt;bool&gt; DeleteAsync(Guid id, CancellationToken ct)
    {
        await using var conn = new SqlConnection(connectionString);
        await conn.OpenAsync(ct);
        var rows = await conn.ExecuteAsync(
            "DELETE FROM Users WHERE Id = @id", new { id });
        return rows == 1;
    }
}

What to notice:

  • Primary constructor (SqlUserRepository(string connectionString)) — C# 12 — replaces a 3-line constructor.
  • await using ensures the connection is closed even on exception, asynchronously.
  • CancellationToken is the last parameter on every method — convention since .NET 6.
  • Return type User? makes "not found" explicit via NRT.
  • sealed lets the JIT devirtualise; cheap win for repositories that are not subclassed.

3. A parallel async pipeline with SemaphoreSlim and cancellation

public static async Task&lt;IReadOnlyList&lt;T&gt;&gt; ParallelMapAsync&lt;TIn, T&gt;(
    IEnumerable&lt;TIn&gt; inputs,
    Func&lt;TIn, CancellationToken, Task&lt;T&gt;&gt; project,
    int maxConcurrency,
    CancellationToken ct)
{
    using var sem = new SemaphoreSlim(maxConcurrency);
    var tasks = inputs.Select(async input =&gt;
    {
        await sem.WaitAsync(ct);
        try
        {
            return await project(input, ct);
        }
        finally
        {
            sem.Release();
        }
    });
    return await Task.WhenAll(tasks);
}

// Usage: fetch 1000 URLs, 8 at a time
using var client = new HttpClient();
var bodies = await ParallelMapAsync(
    urls,
    (url, ct) =&gt; client.GetStringAsync(url, ct),
    maxConcurrency: 8,
    ct);

What to notice:

  • SemaphoreSlim caps concurrency — without it, Task.WhenAll would issue all 1000 requests at once.
  • using on the semaphore disposes the underlying handle.
  • try/finally around Release guarantees the slot is freed even on exception.
  • CancellationToken propagates into both WaitAsync and the per-item work.
  • For modern .NET, consider Parallel.ForEachAsync(inputs, new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct }, async (input, ct) =&gt; { ... }) — built-in, slightly less flexible.

4. A LINQ pipeline with deferred execution that explains itself

public static IEnumerable&lt;T&gt; Log&lt;T&gt;(this IEnumerable&lt;T&gt; source, string stage)
{
    foreach (var item in source)
    {
        Console.WriteLine($"[{stage}] yielding {item}");
        yield return item;
    }
}

var numbers = Enumerable.Range(1, 5)
    .Log("source")
    .Where(n =&gt; { Console.WriteLine($"[where] {n}"); return n % 2 == 0; })
    .Select(n =&gt; { Console.WriteLine($"[select] {n}"); return n * 10; })
    .Log("after select");

// Nothing has run yet.
Console.WriteLine("--- about to enumerate ---");
foreach (var n in numbers)
    Console.WriteLine($"=&gt; {n}");

What to notice:

  • Output interleaves per item — every Where/Select is pull-driven by the foreach.
  • A second foreach re-runs the entire pipeline; materialise with .ToList() if you need to enumerate twice.
  • yield return is how you write your own lazy operators — same model the built-ins use.
  • This trick is the fastest way to teach deferred execution in code review.

Hands-on exercises

  1. Build a Result<T, E> type. Goal: model expected failures without exceptions.

    1. Define Result<T, E> with Ok and Err sealed record subtypes.
    2. Add Match, Map, Bind, MapErr extension methods.
    3. Rewrite an email-validation function to return Result<User, UserError>.
    4. Add tests that cover both arms and use exhaustive switch.
    5. Done when: the function never throws for invalid input and the call site reads naturally.
  2. NRT migration. Goal: enable NRT on a small project without using !.

    1. Pick a 5-10 file project, add <Nullable>enable</Nullable>.
    2. Fix every warning by making types honest (use ? for genuinely nullable, refactor where not).
    3. Add <WarningsAsErrors>nullable</WarningsAsErrors>.
    4. Confirm zero ! operators in the diff.
    5. Done when: the project builds with warnings-as-errors and nullability is fully expressed in the type system.
  3. Implement an in-memory repository. Goal: practice records + async + cancellation.

    1. Define User as a record.
    2. Implement IUserRepository with ConcurrentDictionary<Guid, User> backing.
    3. All methods return Task<T> with a CancellationToken and a 50 ms artificial delay.
    4. Write parallel tests via Task.WhenAll to confirm thread-safety.
    5. Done when: the test suite passes with 1000 concurrent ops and no data races.
  4. Pipeline<T> generic. Goal: chain async steps with cancellation.

    1. Build Pipeline<T> with .WithStep(Func<T, CancellationToken, Task<T>>) and .RunAsync(T, CancellationToken).
    2. Steps run sequentially, each consuming the previous result.
    3. Cancellation propagates through every step.
    4. Add a .WithRetry(int n) decorator that retries the wrapped step.
    5. Done when: you can chain 5 steps including retry without tangling control flow.
  5. Pattern-matching refactor. Goal: collapse an if/else chain into a switch expression.

    1. Find or write a shape-area calculator with Circle, Rectangle, Triangle as sealed records.
    2. Replace the dispatch with a single switch expression.
    3. Make the base type abstract record so the compiler enforces exhaustiveness.
    4. Add a fourth shape; observe the compile error guiding you.
    5. Done when: adding a new shape requires no edits outside the type itself plus the one switch arm.
  6. Parallel URL fetcher. Goal: async at scale with limits and cancellation.

    1. Build ParallelMapAsync from worked example 3.
    2. Fetch 1000 small JSON endpoints (use httpbin or a local mock).
    3. Cap concurrency at 8, measure wall time vs sequential.
    4. Add a CancellationTokenSource with a 5 s timeout; confirm in-flight requests are cancelled.
    5. Done when: you can hit 1000 endpoints in < 30 s with no thread starvation.
  7. BenchmarkDotNet on string building. Goal: feel allocation costs.

    1. Compare string.Concat, +=, string.Format, $"...", and StringBuilder for 100 segments.
    2. Read the allocation column (Allocated).
    3. Write a 2-line summary of when each wins.
    4. Done when: you can explain to a junior why interpolation is fine for ≤ 5 segments and StringBuilder wins past 100.

Self-check questions

  1. Explain the difference between Task and ValueTask and when each is appropriate.
  2. Why does .Result deadlock in some contexts, and why is the deadlock less common in ASP.NET Core?
  3. What does record class give you that a regular class does not? What does record struct give you over record class?
  4. When would you reach for IQueryable<T> instead of IEnumerable<T>, and what is the trap of mixing them?
  5. What does the where T : notnull constraint actually mean, and how is it different from where T : class?
  6. What is exhaustiveness in pattern matching, and how do you get the compiler to enforce it?
  7. Why is async void only for event handlers, and what happens if it throws?
  8. What is the difference between throw; and throw ex;, and when does each appear in real code?
  9. What is double-enumeration in LINQ, and how does it manifest with EF Core IQueryable<T>?
  10. What does the init accessor allow that a regular setter does not? What does required add on top?
  11. What is a captured variable in a lambda closure, and what is the classic loop-variable trap?
  12. When would you reach for ImmutableArray<T> over List<T>?

High-signal resources

Official docs

Books or courses

  • C# in Depth (4th edition) — Jon Skeet. The chapters after the first 10 are timeless on the language design.
  • Concurrency in C# Cookbook (2nd edition) — Stephen Cleary. Recipes for every concurrency pattern.

Practitioner posts

Weekly milestones

  1. Day 1 — Type system, records, NRT. Read the language tour. Do exercise 2 (NRT migration).
  2. Day 2 — Pattern matching + collection expressions. Do exercise 5 (shape refactor). Answer self-check questions 3, 6.
  3. Day 3 — LINQ + IQueryable. Read the LINQ docs end-to-end. Run worked example 4 to feel deferred execution.
  4. Day 4-5 — async/await + cancellation. Read 3 Stephen Cleary posts. Do exercises 3, 4, 6.
  5. Day 6-7 — Generics, exceptions, collections, disposal. Do exercise 1 (Result<T, E>) and exercise 7 (BenchmarkDotNet). Re-attempt every self-check question.

How it shows up in the capstone

The capstone API uses C# 12 idioms throughout. DTOs are sealed records (QueryRequest, QueryResult, ChartSeries, UserError); domain operations return Result<T, E> instead of throwing on validation failures; controller actions are async with CancellationToken propagated from the request all the way to the database; NRT is on with warnings-as-errors; LINQ over EF Core's IQueryable<T> is the only data-access pattern with one .ToListAsync() per request to avoid double-enumeration.

Pattern matching shows up in the QueryDispatcher (a switch expression that routes QueryRequest subtypes to handlers) and in the middleware (matching exception types to RFC 7807 problem details). SemaphoreSlim caps outbound HTTP fan-out when the dashboard refreshes multiple panels. await using ensures every DB connection and every HttpResponseMessage is disposed even on exception paths.

When you can read the capstone code and recognise every idiom — records, init-only setters, list patterns, exhaustive switches, await using, ConfigureAwait(false) in library projects — without consulting the docs, this chapter is done.

Previous chapter ← Ch 2 — Web fundamentals Next chapter → Ch 4 — .NET runtime + async