Dinesh/ Blog
application engineering

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.

26 min readChecking device speech…
Lesson preparation & details

Level: intermediate

By the end, you should be able to

  • A struct contains List<int>; copy the struct, then append through the copy. Which list changes? What if a LINQ sequence is enumerated twice?
  • Explain the version and execution boundaries before applying the examples

Bring with you

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 38 sections

Review and execution boundary

C# 12 syntax and .NET 8 teaching baseline; Dapper 2.1.66 CommandDefinition source checked for the shown cancellation parameter. The 9 October 2026 follow-up reviewed all examples and answers, but no C# compiler/CLR, SQL Server, Dapper integration or BenchmarkDotNet run was available. Reference fragments require their named imports, enclosing types and dependencies; they are not one concatenated program.

Reviewed on 7 October 2026 against the official source snapshots linked below. The review is bounded editorial correction, not certification of every dependency, security property or cloud deployment. Historical setup commands and optional exercises were not executed. No cloud resources, third-party packages or external side effects were created. Old screenshots and unavailable private assets remain in the private recovery archive, not prerequisites for this lesson.

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 data-access models in chapter 5 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.

Type system fluency — Value vs reference, structs vs classes, NRT, generics, variance.

Records + with expressions — Value-oriented equality and shallow non-destructive copies; immutability depends on members.

Pattern matching — Type / property / relational / list patterns; exhaustive switch.

LINQ without traps — Deferred execution, double-enumeration, IQueryable vs IEnumerable.

async/await done right — Avoid sync-over-async deadlocks; reserve async void for event handlers; choose Task/ValueTask and propagate cancellation.

Collections + disposal — List/Dictionary/HashSet/ImmutableArray; using + await using.

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) have value-copy semantics. A value may be in a register, on a stack, embedded in a heap object or boxed; storage location is not the definition. Default struct equality compares fields, with implementation-dependent fast paths; implement suitable equality when profiling and semantics require it.

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, with storage determined by context rather than nullability. 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:

<PropertyGroup>
  <Nullable>enable</Nullable>
  <WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup>

Place that PropertyGroup inside the project file. It promotes nullable warnings only; use TreatWarningsAsErrors=true instead when the policy is to fail on all compiler warnings. Annotations still do not validate untrusted input at runtime.

Good enough to ship

• Pick struct vs class deliberately • NRT enabled, warnings clean (no stray !) • Avoid boxing in hot paths

Expert tier

• Measure readonly struct + in parameters: readonly references can avoid copies, but argument temporaries and access patterns still matter • 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-oriented equality, init-only positional properties, and a with expression for shallow non-destructive copying. record struct supplies equality over a value type, but positional properties are mutable by default; readonly record struct makes them init-only. Neither form recursively freezes referenced objects.

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
 
    private readonly List<DateTimeOffset> _logins = new();
    public int this[int year] => _logins.Count(l => 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) normally requires callers to initialize the member; a constructor marked SetsRequiredMembers can satisfy that compiler contract. It does not prove a meaningful/non-null runtime value or replace validation. Indexers (this[...]) let your type be used with obj[key] syntax — useful for collection-like types.

Generics — constraints, variance

Generics provide type-safe reuse and avoid common boxing paths such as object-based collections; boxing can still occur in generic code when converting a value to object or an interface. The tiny cache below is single-threaded; TryGet distinguishes a missing key from a stored default value.

public class Cache<TKey, TValue> where TKey : notnull
{
    private readonly Dictionary<TKey, TValue> _store = new();
    public void Set(TKey k, TValue v) => _store[k] = v;
    public bool TryGet(TKey k, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out TValue value)
        => _store.TryGetValue(k, out value);
}

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 (contains no managed references; unmanaged does not imply identical native representation or blittability). 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>. Variance conversions apply to reference-type arguments, not conversions such as IEnumerable<int> to IEnumerable<object>. 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 => 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<Func<T, bool>>) 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 => 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) => o switch
{
    null                            => "nothing",
    int n when n < 0                => $"negative int {n}",
    int n                           => $"int {n}",
    string { Length: 0 }            => "empty string",
    string s                        => $"string of {s.Length}",
    User { Email.Length: > 5 }      => "user with real email",
    User u                          => $"user {u.Id}",
    int[] a when a is [1, 2, ..]     => $"list starting 1,2 with {a.Length - 2} more",
    (int x, int y)                  => $"tuple ({x},{y})",
    _                               => "something else"
};

Four pattern families: type patterns (x is int n — tests and binds), property patterns (User { Email.Length: > 5 } — matches nested properties), relational patterns (n is > 0 and < 100), and list patterns ([1, .., 3] matches arrays/lists starting with 1 ending with 3). Combine with and, or, not. C# 12 does not treat a record inheritance hierarchy as a closed union. List patterns need a statically compatible list/array type (not plain object); first narrow the object as shown. Keep a deliberate fallback and test every intended case; adding a sealed subtype does not force a compile error.

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 • Union-like models via records with explicit fallback and variant tests • Source-generated polymorphic deserialisation that switches on a Type discriminator

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

LINQ offers method and query syntax. Query expressions translate to method calls; not every operator has a query keyword. Choose the clearer form for the query, rather than treating a date-based style preference as a language rule.

var topUsers = users
    .Where(u => u.IsActive)
    .OrderByDescending(u => u.CreatedAt)
    .Take(10)
    .Select(u => 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 and ToArray materialize; scalar operators execute immediately but need not visit every element (Any can stop early and Count can use a collection count). foreach pulls elements as needed. 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 describes enumeration, not where data is stored: a lazy source can read files or a database. Enumerable operators accept delegates and run in-process. IQueryable exposes an expression tree to a provider, which may translate operations to SQL or evaluate locally; remote execution is provider-dependent. AsEnumerable() changes subsequent operator selection without materializing the source. The big gotcha: once you call a non-translatable method inside an IQueryable predicate, EF Core 3.0 and later throw for non-translatable expressions outside the top-level projection. Client evaluation is permitted in the top-level projection. Filter and project as far as possible on the server before explicitly materializing; do not pull an unbounded table merely to silence a translation error.

title: Double-enumeration is the silent perf killer

var q = db.Users.Where(u => u.Active); q.Count(); foreach (var u in q) { ... } runs the SQL twice. var q = await db.Users.Where(...).ToListAsync(); then q.Count and foreach (var u in q) runs it once. The same applies to in-memory IEnumerable<T> over expensive sources like file reads or web requests.

async/await — completion, contexts and cancellation

An async method begins synchronously and runs until an incomplete await yields control. Await does not itself create a thread. For I/O, await the operation directly; for CPU-heavy work that must leave a UI thread, Task.Run is one option, not a way to create extra server capacity.

A Task represents eventual completion, cancellation or failure and can be awaited more than once. Use Task/Task<T> by default. ValueTask/ValueTask<T> can avoid some allocations in measured hot paths, but carry a stricter consumption contract: normally await once, or call AsTask once and reuse that Task. Do not assume repeated awaits or mixed consumption are safe.

Blocking on .Result or .Wait() can deadlock when a continuation needs the synchronization context whose thread is blocked. ASP.NET Core normally has no custom synchronization context, so that particular cycle is less common, but blocking still wastes threads and can cause starvation. Prefer async all the way; ConfigureAwait(false) is a context choice in library code, not a universal cure for blocking callers.

Return Task rather than async void except for event-handler signatures that require void. The caller cannot await an async-void completion or catch its asynchronous exception through a returned task. A cancellation token requests cooperation: pass it to the actual wait/I/O, handle OperationCanceledException at the responsible boundary, and do not interpret cancellation as rollback of a committed write.

Collections — List, Dictionary, HashSet, IReadOnlyList, ImmutableArray

Pick the right collection: List<T> is the default — O(1) indexed access and amortized O(1) add-at-end, O(N) insert/remove in the middle. Dictionary<TKey, TValue> is O(1) average lookup/insert/remove; uses an equality comparer for TKey (default or supplied); reference identity is valid when that is your intended key meaning. HashSet<T> has average 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> prevent structural mutation of the collection, not mutation of referenced elements. ImmutableList can share tree nodes; changing ImmutableArray generally copies array storage. Neither gives records automatic element-by-element sequence equality: define and test that explicitly (for example with SequenceEqual and a matching hash policy).

In API surfaces, IReadOnlyList<T> / IReadOnlyDictionary<TKey, TValue> remove mutation methods from that view, not from the underlying object. Another alias (or a cast to a mutable implementation) can still mutate it. Copy or use an immutable collection when the API promises a stable snapshot; referenced elements can still be mutable.

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
{
    // runs on ordinary scope exit, including exceptions; not guaranteed on process termination
}

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. Do not dispose a borrowed DI-owned singleton. A client returned by IHttpClientFactory.CreateClient is a new client, not a singleton; disposing it is safe because the factory manages pooled handler lifetimes. Follow the ownership contract rather than treating every HttpClient identically.

Equality and hashing

When defining custom value equality, keep Equals and GetHashCode consistent (or provide a matching comparer). You do not need overrides merely to use reference identity as a key, and overriding Equals does not automatically overload ==. Records synthesize equality over instance fields, including auto-property backing fields; a computed property is not independently compared. 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; close the reader before the later write.
{
    await using var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    while (await reader.ReadLineAsync(ct) is { } line)
    {
        // Process this line; null ends the loop.
        Console.WriteLine(line);
    }
}
 
// Write
await File.WriteAllTextAsync(path, text, ct);

Use Path.Combine to build paths cross-platform. Resolve with Path.GetFullPath against a trusted base and enforce a path-component-aware root boundary, not a string-prefix check (/data-evil is not inside /data). This lexical check does not prevent symlink escapes or races; constrain filesystem permissions and link handling separately. 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 can reduce reflection and support AOT-friendly serialization; output strings, buffers and object graphs can still allocate. Reach for reflection only when you genuinely need late binding.

Worked examples

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

Use the positional User record from the records example, not the separate properties-demo class. Add using System.Diagnostics; for UnreachableException; place CreateUser inside an enclosing class and keep top-level usage before type declarations.

public abstract record Result<T, E>
{
    private Result() { } // hide the ordinary base constructor; this is not a native closed union
    public sealed record Ok(T Value)    : Result<T, E>;
    public sealed record Err(E Error)   : Result<T, E>;
 
    public bool IsOk => this is Ok;
 
    public TOut Match<TOut>(Func<T, TOut> ok, Func<E, TOut> err) => this switch
    {
        Ok o   => ok(o.Value),
        Err e  => err(e.Error),
        _      => throw new UnreachableException()
    };
}
 
public enum UserError { InvalidEmail, AlreadyExists, RateLimited }
 
public static Result<User, UserError> CreateUser(string email)
    => !email.Contains('@')
        ? new Result<User, UserError>.Err(UserError.InvalidEmail)
        : new Result<User, UserError>.Ok(new User(Guid.NewGuid(), email, DateTimeOffset.UtcNow));
 
// Usage
var result = CreateUser("alice@example.com");
var message = result.Match(
    ok:  u => $"Created {u.Id}",
    err: e => $"Failed: {e}"
);

What to notice:

  • The abstract base and sealed variants provide a union-like model with value-oriented equality, not a native closed discriminated union.
  • Match requires callbacks for the two shown variants; C# record inheritance is not a compiler-enforced closed union and the fallback remains necessary.
  • No exceptions for expected failures; the type tells the caller "this can fail".
  • Pairs with switch on the result for ergonomic call sites.
  • Compare the success/error API shape with Rust or F# Result, but do not import their closed-variant exhaustiveness guarantee into C# 12.

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

This dependency-bound fragment needs using Dapper; and using Microsoft.Data.SqlClient;, the positional User record, and an application-owned Users table whose Id is unique and whose Email/CreatedAt types match the record (for example SQL Server uniqueidentifier, nvarchar and datetimeoffset). Connection configuration, schema migration, authorization and integration tests are outside this fragment.

public interface IUserRepository
{
    Task<User?> GetAsync(Guid id, CancellationToken ct);
    Task<Guid>  CreateAsync(User user, CancellationToken ct);
    Task<bool>  DeleteAsync(Guid id, CancellationToken ct);
}
 
public sealed class SqlUserRepository(string connectionString) : IUserRepository
{
    public async Task<User?> GetAsync(Guid id, CancellationToken ct)
    {
        await using var conn = new SqlConnection(connectionString);
        await conn.OpenAsync(ct);
        return await conn.QuerySingleOrDefaultAsync<User>(
            new CommandDefinition("SELECT Id, Email, CreatedAt FROM Users WHERE Id = @id", new { id }, cancellationToken: ct));
    }
 
    public async Task<Guid> CreateAsync(User user, CancellationToken ct)
    {
        await using var conn = new SqlConnection(connectionString);
        await conn.OpenAsync(ct);
        await conn.ExecuteAsync(
            new CommandDefinition("INSERT INTO Users (Id, Email, CreatedAt) VALUES (@Id, @Email, @CreatedAt)", user, cancellationToken: ct));
        return user.Id;
    }
 
    public async Task<bool> DeleteAsync(Guid id, CancellationToken ct)
    {
        await using var conn = new SqlConnection(connectionString);
        await conn.OpenAsync(ct);
        var rows = await conn.ExecuteAsync(
            new CommandDefinition("DELETE FROM Users WHERE Id = @id", new { id }, cancellationToken: ct));
        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 by convention; all three Dapper commands receive it as well as OpenAsync. SQL Server/provider cancellation is cooperative, not a guarantee that a write did not commit.
  • Return type User? makes "not found" explicit via NRT.
  • sealed prevents subclassing and can enable optimizations; measure performance instead of assuming a gain.

3. A parallel async pipeline with SemaphoreSlim and cancellation

public static async Task<IReadOnlyList<T>> ParallelMapAsync<TIn, T>(
    IEnumerable<TIn> inputs,
    Func<TIn, CancellationToken, Task<T>> project,
    int maxConcurrency,
    CancellationToken ct)
{
    if (maxConcurrency <= 0) throw new ArgumentOutOfRangeException(nameof(maxConcurrency));
    // Finite-input teaching contract: finish enumeration before starting work.
    // A throwing input iterator must not leave launched tasks using a disposed semaphore.
    var snapshot = inputs.ToArray();
    using var sem = new SemaphoreSlim(maxConcurrency);
    var tasks = snapshot.Select(async input =>
    {
        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) => client.GetStringAsync(url, ct),
    maxConcurrency: 8,
    ct);

What to notice:

  • SemaphoreSlim caps active projections, not queued tasks or result memory. This finite-input version snapshots inputs before launch and creates one task per item; use bounded workers/channels for a large or unbounded source.
  • 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, which must cooperate. Waiting occurs outside the try/finally so a canceled wait does not release an unacquired slot. Task.WhenAll waits for every launched task; one fault does not automatically cancel siblings.
  • For modern .NET, consider Parallel.ForEachAsync(inputs, new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct }, async (input, ct) => { ... }) — built-in, slightly less flexible.

4. A LINQ pipeline with deferred execution that explains itself

public static IEnumerable<T> Log<T>(this IEnumerable<T> 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 => { Console.WriteLine($"[where] {n}"); return n % 2 == 0; })
    .Select(n => { 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($"=> {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 bounded retries only for classified transient failures of replay-safe steps; propagate cancellation and do not retry validation errors.
    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 and keep a fallback for unexpected variants; this is not a closed union in C# 12.
    4. Add a fourth shape; write a test exposing the missing dispatch arm. Do not expect the compiler to identify all derived types.
    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. Use a fake transport or isolated local mock with 1000 predetermined responses; do not load-test a public service.
    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: measured active operations never exceed 8, cancellation completes and failures propagate; elapsed time depends on the fixture.
  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 the measured crossover for your runtime and workload, without promoting a fixed segment count to a universal rule.

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>?

Self-check answers

  1. Task/ValueTask: Task is the reusable completion handle and default API choice. ValueTask is a value-type handle useful for measured allocation-sensitive paths; consume once unless converting once to a Task whose result you then reuse.
  2. Blocking: if the blocked thread owns a context needed by the continuation, .Result can deadlock. ASP.NET Core normally lacks that custom context, but blocking can still starve its thread pool.
  3. Records: record class adds synthesized value-oriented equality and shallow with copying over a reference type. Record struct uses value-copy semantics and mutable positional properties by default; readonly record struct makes those properties init-only. Neither deep-freezes a list or array.
  4. Query provider: use IQueryable to keep supported filtering/projection with a provider. Enumerable delegates execute locally; switching with AsEnumerable does not fetch a snapshot. EF Core 3+ rejects non-translatable predicates rather than silently downloading the entire table.
  5. notnull: permits nonnullable value or reference type arguments; class restricts arguments to reference types. Nullable analysis/constraint warnings are not a runtime null guard.
  6. Exhaustiveness: every possible input should be handled. C# diagnoses many incomplete switch expressions, but a class hierarchy is not a closed union in C# 12. Keep an intentional fallback and tests for every variant, including unexpected/null input where permitted.
  7. async void: required by some event-handler signatures, but callers cannot await it or observe failure through a Task. Handle relevant failures in the handler; an unhandled asynchronous exception may terminate the application.
  8. Rethrow: throw; preserves the original stack inside a catch; throw ex; restarts it at the rethrow location. Wrap with an inner exception when adding meaningful context rather than discarding the original.
  9. Double enumeration: Count and foreach over an EF query can issue two database commands. A materialized list uses its Count property and stored elements; decide snapshot freshness and memory cost explicitly.
  10. Initialization: init limits when a setter is usable; required normally makes supplying the member a compile-time obligation. Neither validates business meaning nor makes nested references immutable.
  11. Closure: a lambda captures a variable, not its value at declaration. Deferred lambdas over a for-loop counter can all observe its final value; copy it to a fresh per-iteration local. Modern foreach iteration variables are separate.
  12. ImmutableArray: use it for a structurally immutable snapshot when copying/update cost fits. It does not make mutable elements immutable or automatically give records elementwise sequence equality.

Exercise solution checkpoints

  1. Result: Map transforms Ok and passes Err through; Bind calls the next operation only for Ok; MapErr transforms Err. Test both branches and fallback behavior. The email example checks only the presence of @, not email validity or deliverability; validate nullable external input before calling its nonnullable signature.
  2. NRT: represent missing data with nullable types and guard before dereferencing. Enable nullable warnings-as-errors and retain runtime checks at external boundaries; a clean compile is not proof that input cannot be null.
  3. Repository: await Task.Delay(50, ct), then use TryAdd/TryGetValue/TryRemove for the dictionary operations. One operation can be thread-safe while a check-then-act sequence is not. Define duplicate-ID behavior and test cancellation before mutation; timing tests alone do not prove race freedom.
  4. Pipeline: await each step with the token, assigning its output to the next input. Retry only a bounded number of classified transient, replay-safe failures; pass cancellation to backoff and propagate it, rather than retrying a canceled operation.
  5. Shapes: dispatch Circle to πr², Rectangle to width×height, Triangle to base×height/2, with dimension validation. Adding a fourth variant requires a test and dispatch arm, not an assumed inheritance-exhaustiveness diagnostic.
  6. Parallel map: assert an instrumented peak of at most eight active projections, ordered results for successful inputs, empty-input success, rejection of zero concurrency, failure propagation and cooperative cancellation. A throwing input iterator must fail before any project call; large inputs need a bounded-work design instead.
  7. Benchmark: isolate equivalent operations in a release-mode BenchmarkDotNet fixture, consume their results, inspect allocations and time, and report runtime/hardware/input size. No string-segment crossover is universal; this review did not execute the benchmark.

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 reference capstone design uses these C# 12 idioms; this chapter does not certify an implemented capstone. 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; choose EF Core's IQueryable or the Dapper repository shown here according to the application's needs. Materialize a particular result once when reusing it; one ToListAsync per request is not a universal query budget.

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 9457 problem details). SemaphoreSlim caps outbound HTTP fan-out when the dashboard refreshes multiple panels. Use await using for async-disposable DB connections and ordinary using for HttpResponseMessage, which implements IDisposable rather than IAsyncDisposable.

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

Corrected contracts and failure analysis

Value/reference describes copying semantics, not a rule that every struct lives on the stack. A struct embedded in a heap object is part of that object, and a struct containing a list copies the list reference. Nullable reference annotations are compiler analysis, not runtime input validation. Records provide value-oriented equality but do not recursively freeze referenced collections.

The repository now passes cancellation through Dapper CommandDefinition, not just OpenAsync. A cancellation token on a method signature is ineffective if the operation never receives it. The semaphore example bounds simultaneous work but still allocates one task per input; reject nonpositive concurrency and use bounded channels for unbounded streams. The record Result example illustrates two variants but is not a compiler-enforced closed union; unexpected subclasses still reach the Match fallback.

Boundary exercise with solution

A struct contains List<int>; copy the struct, then append through the copy. Which list changes? What if a LINQ sequence is enumerated twice?

Solution and reasoning

Both struct values reference the same list, so both observe the append. A deferred LINQ query executes again on each enumeration; materialize deliberately if the result must be a stable snapshot, and account for the memory cost.

Source-backed review notes

  • Value types - C# reference | Microsoft Learn — accessed 2026-10-07. Exact supporting passage: “Value types and reference types are the two main categories of C# types. A variable of a value type contains an instance of the type. This behavior differs from a variable of a reference type, which contains a reference to an instance of the type. By default, on assignment, passing an argument to a method, and returning a method result, you copy variable values. In the case of value-type variables, you copy the corresponding type instances. The following example demonstrates that behavior:”
  • Asynchronous programming scenarios - C# | Microsoft Learn — accessed 2026-10-07. Exact supporting passage: “CPU-bound code starts an operation on a background thread with the Task.Run method.”

Solved ownership and equality drill

C# 12 / .NET 8 reference fragments are separate examples, not one combined source file: place type declarations in their own files (or below top-level statements), put extension methods in a static class, and supply the named Dapper and Microsoft.Data.SqlClient dependencies for repository examples.

Predict before running. A positional record R(int[] Values) is copied with var b = a with { };. Then b.Values[0] = 9. Does a change? Is new R(new[] { 9 }) == new R(new[] { 9 }) true?

Solution. The shallow copy retains the same array reference, so a.Values[0] is also 9. The two independently constructed arrays do not compare by their elements through synthesized record equality, so the two separately constructed records compare unequal. If element equality is part of the domain contract, define it explicitly and ensure equal values produce equal hashes. Keep hash-relevant state immutable while used as a dictionary key.

Streaming check. For lines alpha, beta, EOF, the ReadLineAsync loop processes exactly two lines. A single call to ReadLineAsync yields only one line; converting that one task to an enumerable would not turn it into a whole-file stream. Passing cancellation to each read bounds responsiveness between reads; it is not a rollback of already processed lines.

Current contract references

Rechecked 9 October 2026: async scenarios, ValueTask consumption rules (.NET 8), Task.WhenAll completion (.NET 8), Dapper 2.1.66 CommandDefinition, and the records, EF Core and HttpClientFactory references below. Source inspection is not CLR or database execution.

Recovery source checks

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.