In-Depth Reference

C# Deep-Dive Tutorials

From delegates and expression trees to Span<T>, async channels, and INumber<T> generic math — practical, opinionated guides with production-ready code you can paste into a project today.

C# 9–13 OOP & Delegates Async / Concurrency Span<T> & Performance
01

C# Fundamentals

The building blocks every C# developer needs to know cold — types, strings, and control flow done the modern way.

Types

Value types, reference types & var

Value types live on the stack and copy on assignment. Reference types live on the heap — assigning them copies the reference, not the data.

// ── Value types ───────────────────────────────
int     count   = 42;
double  ratio   = 3.14;
decimal price   = 9.99m;     // m suffix = decimal literal
bool    isReady = true;
char    grade   = 'A';

// ── Reference types ──────────────────────────
string  name    = "Spyros";  // immutable reference type
object  any     = 42;        // boxes the int

// ── var — compiler infers the type ───────────
var items  = new List<string>();  // List<string>
var today  = DateTime.UtcNow;      // DateTime
var config = new { Host = "localhost", Port = 5432 };

// ── Nullable value types ──────────────────────
int?    maybe  = null;
double? ratio2 = null;
if (maybe.HasValue) Console.WriteLine(maybe.Value);
Console.WriteLine(maybe ?? 0);   // null-coalescing
Strings

Interpolation, formatting & raw literals

Prefer $"..." over string.Format — it's faster (span-based in .NET 6+) and far more readable.

string name = "Spyros";
decimal salary = 75_000.50m;
DateTime joined = new DateTime(2020, 1, 15);

// Interpolation
string msg = $"Hello, {name}!";

// Format specifiers inside {}
string money   = $"{salary:C0}";          // €75,001
string padded  = $"ID: {42:D6}";          // ID: 000042
string date    = $"Joined: {joined:d}";   // 15/01/2020
string percent = $"{0.875:P0}";           // 88%

// Multi-line interpolation
string json = $"""
    {{
        "name": "{name}",
        "salary": {salary}
    }}
    """;

// Span-based: no allocation when writing to a buffer
Span<char> buf = stackalloc char[64];
name.AsSpan().CopyTo(buf);

// StringBuilder for many concatenations in a loop
var sb = new System.Text.StringBuilder();
foreach (var item in Enumerable.Range(1, 10))
    sb.Append(item).Append(',');
string result = sb.ToString();
Control Flow

Switch expressions & pattern basics

The modern switch expression returns a value, has no fall-through, and forces exhaustiveness — prefer it over the old statement form.

// ── Switch expression (C# 8+) ─────────────────
string Classify(int score) => score switch {
    >= 90 => "A",
    >= 80 => "B",
    >= 70 => "C",
    _     => "F"   // discard = default arm
};

// ── Type + property patterns ──────────────────
static string Describe(object obj) => obj switch {
    int n when n < 0       => $"negative: {n}",
    int n                  => $"int: {n}",
    string { Length: 0 }   => "empty string",
    string s               => $"string: {s}",
    null                   => "null",
    _                      => obj.GetType().Name
};

// ── foreach with LINQ filter ──────────────────
var numbers = Enumerable.Range(1, 20);
foreach (var n in numbers.Where(x => x % 3 == 0))
    Console.Write($"{n} ");    // 3 6 9 12 15 18

// ── for loop with index-from-end ─────────────
int[] arr = [10, 20, 30, 40, 50];
Console.WriteLine(arr[^1]);   // 50
Console.WriteLine(arr[^2]);   // 40
02

Object-Oriented C#

C# is a class-based language at its core. Master these three pillars and the rest of the language makes sense.

Classes

Properties, constructors & primary constructors

C# 12 primary constructors let you declare parameters directly on the class, reducing boilerplate while keeping full control over validation.

// ── Classic style ─────────────────────────────
public class Product
{
    public int     Id    { get; init; }    // init = set once at construction
    public string  Name  { get; set; } = string.Empty;
    public decimal Price { get; private set; }

    public Product(int id, string name, decimal price)
    {
        if (price < 0) throw new ArgumentOutOfRangeException(nameof(price));
        Id = id; Name = name; Price = price;
    }

    public void ApplyDiscount(decimal pct) =>
        Price = Math.Round(Price * (1 - pct / 100), 2);
}

// ── Primary constructor (C# 12) ──────────────
public class OrderLine(int productId, int qty, decimal unitPrice)
{
    public int     ProductId  { get; } = productId;
    public int     Qty        { get; } = qty;
    public decimal LineTotal  => qty * unitPrice;

    // Guard in field initialiser
    private readonly decimal _unit =
        unitPrice > 0 ? unitPrice
        : throw new ArgumentException("Price must be positive");
}

// Object initialisers (works with init setters)
var p = new Product(1, "Coffee", 3.50m);
var p2 = p with { };  // records only — see Section 5
Inheritance

Abstract classes, virtual & sealed

Prefer composition over inheritance for behaviour reuse. Use abstract classes only when you need shared state and a polymorphic API.

public abstract class Notification
{
    public string  Recipient { get; init; } = string.Empty;
    public string  Subject   { get; init; } = string.Empty;

    // Template method — subclasses provide the body
    public abstract string BuildBody();

    // Virtual — subclasses may override
    public virtual async Task SendAsync(CancellationToken ct = default)
    {
        var body = BuildBody();
        await DeliverAsync(Recipient, Subject, body, ct);
    }

    protected abstract Task DeliverAsync(
        string to, string subject, string body, CancellationToken ct);
}

public sealed class EmailNotification : Notification
{
    public string HtmlTemplate { get; init; } = string.Empty;

    public override string BuildBody() =>
        HtmlTemplate.Replace("{{subject}}", Subject);

    protected override Task DeliverAsync(
        string to, string subject, string body, CancellationToken ct) =>
        _smtp.SendAsync(to, subject, body, ct);

    private readonly ISmtpClient _smtp;
    public EmailNotification(ISmtpClient smtp) => _smtp = smtp;
}

// sealed prevents further subclassing — express intent clearly
Interfaces

Interface segregation & explicit implementation

Split interfaces by capability, not by entity. A class that reads and writes should implement two interfaces — IReader<T> and IWriter<T> — so callers can depend only on what they use.

// ── ISP: segregate read vs write ─────────────
public interface IReader<T>
{
    Task<T?>        GetByIdAsync(int id,  CancellationToken ct = default);
    Task<IList<T>>  GetAllAsync(CancellationToken ct = default);
}

public interface IWriter<T>
{
    Task AddAsync(T entity,    CancellationToken ct = default);
    Task DeleteAsync(int id,   CancellationToken ct = default);
}

// Full repository = composition of both
public interface IRepository<T> : IReader<T>, IWriter<T> { }

// ── Explicit implementation ───────────────────
// Hides the method unless cast to the interface — useful when two
// interfaces have the same member name with different meanings.
public class DataExporter : IReader<Order>, IWriter<Order>
{
    Task<Order?> IReader<Order>.GetByIdAsync(int id, CancellationToken ct) =>
        _db.Orders.FindAsync([id], ct).AsTask();

    Task<IList<Order>> IReader<Order>.GetAllAsync(CancellationToken ct) =>
        _db.Orders.AsNoTracking().ToListAsync(ct)
            .ContinueWith(t => (IList<Order>)t.Result, ct);

    Task IWriter<Order>.AddAsync(Order o, CancellationToken ct)
    { _db.Orders.Add(o); return _db.SaveChangesAsync(ct); }

    Task IWriter<Order>.DeleteAsync(int id, CancellationToken ct) =>
        _db.Orders.Where(o => o.Id == id).ExecuteDeleteAsync(ct);

    private readonly AppDbContext _db;
    public DataExporter(AppDbContext db) => _db = db;
}
Delegates

Func, Action, events & expression trees

Func<T,TResult> and Action<T> are the built-in delegate forms. Use events for publisher/subscriber notification with +=/-=. Expression<Func<T>> is an inspectable AST — how EF Core converts a lambda to SQL.

// ── Func / Action / Predicate ─────────────────
Func<int, int, int>  add    = (a, b) => a + b;
Action<string>       log    = msg    => Console.WriteLine(msg);
Predicate<int>       isEven = n      => n % 2 == 0;

Console.WriteLine(add(3, 4));    // 7
log("Hello");                     // Hello
Console.WriteLine(isEven(6));    // True

// ── Multicast delegate — invokes all subscribers ─
Action<string> notify = Console.WriteLine;
notify += s => File.AppendAllText("log.txt", s + "\n");
notify("order placed");   // writes to console AND file

// ── Custom event ──────────────────────────────
public class OrderService
{
    public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;

    protected virtual void OnOrderPlaced(Order o) =>
        OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(o));

    public Order Place(PlaceOrderRequest req)
    {
        var order = BuildOrder(req);
        OnOrderPlaced(order);
        return order;
    }
}

public record OrderPlacedEventArgs(Order Order) : EventArgs;

// Subscribe / unsubscribe
var svc = new OrderService();
svc.OrderPlaced += (_, e) =>
    Console.WriteLine($"Order {e.Order.Id} placed");

// ── Expression<Func<T>> — inspectable lambda AST ─
Expression<Func<Product, bool>> expr = p => p.Price > 10;

// EF Core / LINQ providers translate this to SQL:
//   WHERE "Price" > 10
var hits = await _db.Products.Where(expr).ToListAsync();

// Read the expression tree at runtime
var body = (BinaryExpression)expr.Body;
var left = (MemberExpression)body.Left;
Console.WriteLine($"{left.Member.Name} {body.NodeType}");
// Price GreaterThan

// Compile to an in-memory delegate when needed
var fn    = expr.Compile();
bool ok   = fn(new Product(1, "Coffee", 3.50m));  // false
03

Collections & LINQ

Pick the right collection for the job, then let LINQ express your intent — not a hand-rolled loop.

Collections

List, Dictionary, HashSet & when to use each

Choose by access pattern: ordered/indexed → List<T>, key lookup → Dictionary<K,V>, uniqueness → HashSet<T>.

// ── List<T> — ordered, O(1) index, O(n) search ──
var tags = new List<string> { "dotnet", "csharp", "blazor" };
tags.Add("efcore");
tags.Remove("blazor");
tags.Sort();
int idx = tags.BinarySearch("csharp");  // O(log n) on sorted list

// ── Dictionary<K,V> — O(1) lookup by key ────
var stockById = new Dictionary<int, int>
{
    [1] = 100,
    [2] = 50,
};
stockById.TryAdd(3, 75);
if (stockById.TryGetValue(99, out var qty))
    Console.WriteLine(qty);   // won't execute — key missing

// Safe update: GetValueOrDefault + []
stockById[1] = stockById.GetValueOrDefault(1) + 10;

// ── HashSet<T> — O(1) contains, no duplicates ─
var seen = new HashSet<int>();
foreach (var n in new[] { 1, 2, 2, 3, 1 })
    if (!seen.Add(n))
        Console.WriteLine($"Duplicate: {n}");

// Set operations
var a = new HashSet<int> { 1, 2, 3 };
var b = new HashSet<int> { 2, 3, 4 };
a.IntersectWith(b);  // a = { 2, 3 }

// ── Immutable collections (read-only snapshots) ─
var frozen = tags.ToFrozenSet();   // .NET 8 — zero-alloc lookup
LINQ

LINQ fundamentals — filter, project, aggregate

LINQ is lazy — the query doesn't execute until you materialise it with ToList(), First(), Count(), etc. Keep queries deferred as long as possible when hitting a database.

record Product(int Id, string Name, string Category, decimal Price);

var products = new List<Product>
{
    new(1, "Coffee",      "Beverages",   3.50m),
    new(2, "Tea",         "Beverages",   2.00m),
    new(3, "Laptop",      "Electronics", 999m),
    new(4, "Headphones",  "Electronics", 149m),
    new(5, "Notebook",    "Stationery",  5.99m),
};

// Filter + order + project (deferred)
var cheapBev = products
    .Where(p => p.Category == "Beverages" && p.Price < 3m)
    .OrderBy(p => p.Price)
    .Select(p => new { p.Name, p.Price });  // anonymous type

// Aggregate operators — execute immediately
decimal total    = products.Sum(p => p.Price);
decimal avg      = products.Average(p => p.Price);
var     priciest = products.MaxBy(p => p.Price)!;   // .NET 6+

// Quantifiers
bool hasElec    = products.Any(p => p.Category == "Electronics");
bool allPositive = products.All(p => p.Price > 0);
int  bevCount   = products.Count(p => p.Category == "Beverages");

// Safe first/single
var item = products.FirstOrDefault(p => p.Id == 3);
if (item is not null) Console.WriteLine(item.Name);
LINQ

GroupBy, Join & SelectMany

GroupBy, Join and SelectMany cover 95% of the "harder" LINQ queries. Master them and you rarely need raw SQL for reporting.

// ── GroupBy ───────────────────────────────────
var byCategory = products
    .GroupBy(p => p.Category)
    .Select(g => new {
        Category = g.Key,
        Count    = g.Count(),
        Total    = g.Sum(p => p.Price),
        Cheapest = g.MinBy(p => p.Price)!.Name
    })
    .OrderByDescending(x => x.Total);

// ── Join ──────────────────────────────────────
record OrderLine(int OrderId, int ProductId, int Qty);

var lines = new[] {
    new OrderLine(1, 1, 3),
    new OrderLine(2, 3, 1),
    new OrderLine(3, 2, 5),
};

var orderDetails = lines.Join(
    products,
    l => l.ProductId,
    p => p.Id,
    (l, p) => new {
        l.OrderId,
        p.Name,
        l.Qty,
        LineTotal = p.Price * l.Qty
    });

// ── SelectMany — flatten nested sequences ─────
var categories = new[] {
    new { Name = "Beverages",   Items = new[]{"Coffee","Tea"} },
    new { Name = "Electronics", Items = new[]{"Laptop","Headphones"} },
};

IEnumerable<string> allItems = categories
    .SelectMany(c => c.Items);   // "Coffee","Tea","Laptop","Headphones"

// With result selector (item + parent)
var withCategory = categories
    .SelectMany(c => c.Items, (c, item) => $"{c.Name}/{item}");
Performance

Span<T>, Memory<T> & ArrayPool — zero-alloc patterns

Span<T> is a zero-allocation window into any contiguous memory — array, stack, or native. Use Memory<T> when you need to cross an await boundary. Rent from ArrayPool<T>.Shared to stop hammering the GC in hot paths.

// ── ReadOnlySpan<char> — parse without allocating ─
string csv = "10,20,30,40,50";
ReadOnlySpan<char> span = csv.AsSpan();

int total = 0, start = 0;
for (int i = 0; i <= span.Length; i++)
{
    if (i == span.Length || span[i] == ',')
    {
        total += int.Parse(span[start..i]);  // no ToString()
        start = i + 1;
    }
}
// 110 — zero intermediate string allocations

// ── stackalloc — allocate a buffer on the stack ─
Span<int> buf = stackalloc int[16];
for (int i = 0; i < buf.Length; i++) buf[i] = i * i;
// automatically freed when the method returns

// ── Memory<T> — Span that survives await ──────
// Span is a ref struct — cannot be captured in async state machines
// Memory<T> wraps the same memory but is a plain struct

async Task CopyAsync(Memory<byte> data, Stream dest, CancellationToken ct)
    => await dest.WriteAsync(data, ct);   // WriteAsync accepts Memory<byte>

// ── ArrayPool<T> — rent and return ────────────
var pool   = System.Buffers.ArrayPool<byte>.Shared;
byte[] rented = pool.Rent(4096);   // may return > 4096 bytes
try
{
    int n = await _stream.ReadAsync(rented.AsMemory(0, 4096), ct);
    ProcessBytes(rented.AsSpan(0, n));
}
finally
{
    pool.Return(rented, clearArray: false);  // must always return!
}

// ── string.Create — build a string in-place ───
int id  = 42;
string tag = string.Create(7, id, (span, state) =>
{
    "item-".AsSpan().CopyTo(span);
    state.TryFormat(span[5..], out _);
});
Console.WriteLine(tag);  // "item-42" — zero extra allocations
04

Async & Concurrency

Async is not about threads — it's about not blocking. A well-written async method releases the thread back to the pool while waiting for I/O, making your app scale to far more concurrent users.

Async

async / await — the golden rules

Return Task/Task<T> (never void except for event handlers). Always propagate CancellationToken. Never block with .Result or .Wait().

// ✅ Correct async method signature
public async Task<User?> GetUserAsync(int id, CancellationToken ct = default)
{
    var user = await _db.Users.FindAsync([id], ct);
    if (user is null) return null;

    // Sequential: each line waits for the one above
    var profile = await _profileSvc.GetAsync(user.Id, ct);
    user.Profile = profile;
    return user;
}

// ❌ Never block — causes deadlock on ASP.NET classic / UI thread
// var user = GetUserAsync(1).Result;   // WRONG
// GetUserAsync(1).Wait();              // WRONG

// ✅ ConfigureAwait(false) in library code
// (not needed in ASP.NET Core — no sync context — but required in
// class libraries that may run inside UI / classic ASP.NET apps)
public async Task<string> ReadFileAsync(string path)
{
    using var sr = new StreamReader(path);
    return await sr.ReadToEndAsync().ConfigureAwait(false);
}

// ValueTask — avoid allocation when the result is cached / sync
private User? _cached;
public ValueTask<User?> GetCachedAsync(int id) =>
    _cached is not null
        ? ValueTask.FromResult(_cached)          // sync path — no allocation
        : new ValueTask<User?>(FetchAsync(id));  // async path
Async

Task.WhenAll & Parallel.ForEachAsync

Fire independent tasks together with Task.WhenAll — it's the simplest concurrency primitive and composes cleanly with await.

// ── Task.WhenAll — fan-out, then collect ──────
public async Task<DashboardDto> GetDashboardAsync(
    int userId, CancellationToken ct)
{
    // All three launch at once
    var statsTask  = _statsSvc.GetAsync(userId, ct);
    var ordersTask = _orderSvc.GetRecentAsync(userId, 5, ct);
    var notifTask  = _notifSvc.GetUnreadAsync(userId, ct);

    await Task.WhenAll(statsTask, ordersTask, notifTask);

    // .Result is safe here — all tasks are already complete
    return new DashboardDto(
        Stats:         statsTask.Result,
        RecentOrders:  ordersTask.Result,
        Notifications: notifTask.Result);
}

// ── Task.WhenAny — first one wins ────────────
var fastest = await Task.WhenAny(
    FetchFromPrimaryAsync(ct),
    FetchFromReplicaAsync(ct));
var data = await fastest;   // unwrap the winning task

// ── Parallel.ForEachAsync (.NET 6+) ──────────
// Process a batch with bounded concurrency (not Task.WhenAll — that
// fires ALL tasks at once, which can overwhelm downstream services)
var urls = Enumerable.Range(1, 100).Select(i => $"/api/item/{i}");

await Parallel.ForEachAsync(
    urls,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
    async (url, token) =>
    {
        var result = await _http.GetStringAsync(url, token);
        await _store.SaveAsync(result, token);
    });
Async

CancellationToken & IAsyncEnumerable

Always accept and forward CancellationToken. Use IAsyncEnumerable<T> (async streams) to stream results without buffering the whole collection in memory.

// ── CancellationToken ─────────────────────────
// ASP.NET Core injects the HTTP abort token automatically
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id, CancellationToken ct)
{
    var product = await _repo.GetByIdAsync(id, ct);
    return product is null ? NotFound() : Ok(product);
}

// Create your own timeout token
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
    var result = await _externalApi.FetchAsync(cts.Token);
}
catch (OperationCanceledException)
{
    _logger.LogWarning("Request timed out after 5s");
}

// Combine: cancel on timeout OR caller disconnect
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
    HttpContext.RequestAborted, cts.Token);

// ── IAsyncEnumerable<T> — stream rows as they arrive ─
public async IAsyncEnumerable<OrderDto> StreamOrdersAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var order in _db.Orders.AsAsyncEnumerable()
                                          .WithCancellation(ct))
    {
        yield return new OrderDto(order.Id, order.Total);
    }
}

// Consume the stream without loading everything into memory
await foreach (var dto in StreamOrdersAsync(ct))
    Console.WriteLine(dto.Id);
05

Modern C# (9–13)

Each C# version since 9 has added features that let you write less boilerplate and express intent more clearly. Here are the ones worth knowing cold.

C# 9+

Records, init-only & with-expressions

Records give you immutability, value equality, and a concise with copy syntax for free — ideal for DTOs, domain value objects, and event payloads.

// Positional record — compiler generates constructor,
// ToString, Equals, GetHashCode, Deconstruct
public record Product(int Id, string Name, decimal Price);

var coffee = new Product(1, "Coffee", 3.50m);

// with-expression — non-destructive mutation
var latte = coffee with { Name = "Latte", Price = 4.50m };

// Value equality (not reference equality)
Console.WriteLine(coffee == latte);                         // false
Console.WriteLine(coffee == new Product(1, "Coffee", 3.50m)); // true

// ── record class vs record struct ─────────────
public record struct Point(double X, double Y);  // stack-allocated, mutable by default

// ── init-only setters (C# 9) ─────────────────
// Available on plain classes too, not just records
public class OrderRequest
{
    public required int     ProductId { get; init; }
    public required int     Quantity  { get; init; }
    public string? Note { get; init; }
}

var req = new OrderRequest { ProductId = 42, Quantity = 3 };
// req.ProductId = 99;  // ❌ compile error — init-only after construction
C# 8–11

Pattern matching — type, property & list patterns

Pattern matching eliminates chains of if/is/as casts and makes branching logic exhaustive and readable in a single expression.

// ── Type pattern ─────────────────────────────
static string Describe(object obj) => obj switch {
    int n when n < 0   => $"negative int: {n}",
    int n              => $"positive int: {n}",
    string { Length:0 }=> "empty string",
    string s           => $"string: \"{s}\"",
    null               => "null",
    _                  => obj.GetType().Name
};

// ── Property pattern ─────────────────────────
record Order(int Id, decimal Total, string Status);

static decimal Discount(Order o) => o switch {
    { Status: "VIP",  Total: >= 500 } => 0.20m,
    { Status: "VIP"                  } => 0.10m,
    { Total:  >= 1000               } => 0.05m,
    _                                  => 0m
};

// ── Positional pattern (Deconstruct) ─────────
var point = new Point(3, 4);
if (point is (0, 0)) Console.WriteLine("Origin");
if (point is ( > 0, > 0)) Console.WriteLine("Q1");

// ── List pattern (C# 11) ─────────────────────
static string Head(int[] data) => data switch {
    []             => "empty",
    [var x]        => $"single: {x}",
    [var f, var s] => $"pair: {f}, {s}",
    [var f, ..]    => $"starts with {f}"
};
C# 11–13

required members & collection expressions

required enforces object-initializer discipline at compile time. Collection expressions ([...]) give you a unified syntax across arrays, lists, spans, and more.

// ── required (C# 11) — must set in object initialiser ─
public class UserProfile
{
    public required string FirstName { get; init; }
    public required string LastName  { get; init; }
    public string? MiddleName { get; init; }

    public string FullName => MiddleName is null
        ? $"{FirstName} {LastName}"
        : $"{FirstName} {MiddleName} {LastName}";
}

// ❌ Compile error — required members missing
// var bad = new UserProfile();

// ✅ Required members satisfied
var user = new UserProfile { FirstName = "Spyros", LastName = "Ponaris" };

// ── Collection expressions (C# 12) ───────────
// Works for arrays, List<T>, Span<T>, HashSet<T> etc.
int[]       arr  = [1, 2, 3, 4, 5];
List<int>   list = [10, 20, 30];
Span<char>  span = ['a', 'b', 'c'];

// Spread operator — combine collections inline
int[] combined = [..arr, ..list];   // [1,2,3,4,5,10,20,30]

// ── Inline arrays (C# 12) ────────────────────
[System.Runtime.CompilerServices.InlineArray(4)]
public struct Buffer4<T> { private T _element; }

// ── params IEnumerable (C# 13) ───────────────
void Print(params IEnumerable<string> items)
{
    foreach (var s in items) Console.WriteLine(s);
}
06

Error Handling

Exceptions are for exceptional situations — not for expected failures like "user not found". Model expected outcomes with a Result<T> type; throw only when the system is in a state it cannot recover from.

Exceptions

Exception filters, custom exceptions & anti-patterns

Catch the most specific exception first. Use when filters to branch without swallowing. Never catch-and-swallow silently — always log or re-throw.

try
{
    var data = await _client.GetAsync(url, ct);
    await _repo.SaveAsync(data, ct);
}
catch (HttpRequestException ex)
    when (ex.StatusCode == HttpStatusCode.NotFound)
{
    // exception filter — only catches 404, not other HTTP errors
    _logger.LogWarning("Resource not found: {Url}", url);
    return null;
}
catch (DbUpdateConcurrencyException ex)
{
    _logger.LogError(ex, "Concurrency conflict");
    throw;   // re-throw — preserves original stack trace
}
// ❌ Never do this — swallows everything, hides bugs
// catch (Exception) { }

// ── Custom exception — carry domain context ──
public sealed class OrderNotFoundException(int orderId)
    : Exception($"Order {orderId} was not found.")
{
    public int OrderId { get; } = orderId;
}

// ── finally is always executed ───────────────
FileStream? fs = null;
try
{
    fs = File.OpenRead("data.bin");
    Process(fs);
}
finally
{
    fs?.Dispose();   // or use: using var fs = File.OpenRead(...)
}

// ── ExceptionDispatchInfo — rethrow from a different context ─
var edi = ExceptionDispatchInfo.Capture(ex);
// ... later ...
edi.Throw();   // re-throw with original stack trace preserved
Patterns

Result<T> — model failure without exceptions

The Result<T> pattern makes expected failures explicit in the return type — no try/catch on the caller side, no hidden control flow, and the compiler forces you to check the outcome.

// ── Minimal Result<T> implementation ─────────
public readonly record struct Result<T>
{
    public T?     Value { get; }
    public string? Error { get; }
    public bool    IsOk  => Error is null;

    private Result(T value)      { Value = value; }
    private Result(string error) { Error = error; }

    public static Result<T> Ok(T value)        => new(value);
    public static Result<T> Fail(string error)  => new(error);

    // Optional: monadic map — transforms Value if IsOk
    public Result<TOut> Map<TOut>(Func<T, TOut> f) =>
        IsOk ? Result<TOut>.Ok(f(Value!)) : Result<TOut>.Fail(Error!);
}

// ── Service layer ─────────────────────────────
public async Task<Result<Order>> PlaceOrderAsync(
    PlaceOrderRequest req, CancellationToken ct)
{
    if (req.Quantity <= 0)
        return Result<Order>.Fail("Quantity must be positive.");

    var product = await _repo.GetByIdAsync(req.ProductId, ct);
    if (product is null)
        return Result<Order>.Fail($"Product {req.ProductId} not found.");

    if (product.Stock < req.Quantity)
        return Result<Order>.Fail("Insufficient stock.");

    var order = new Order { ProductId = req.ProductId, Qty = req.Quantity };
    await _orderRepo.AddAsync(order, ct);
    return Result<Order>.Ok(order);
}

// ── Controller — no try/catch needed ─────────
var result = await _orderSvc.PlaceOrderAsync(req, ct);
if (!result.IsOk)
    return BadRequest(new { error = result.Error });
return CreatedAtAction(nameof(GetOrder), new { id = result.Value!.Id }, result.Value);
ASP.NET Core

IExceptionHandler & ProblemDetails (ASP.NET Core 8+)

Centralise exception-to-HTTP mapping with IExceptionHandler. It produces RFC 7807-compliant ProblemDetails JSON and keeps controllers completely clean — throw domain exceptions, the middleware handles the rest.

// ── Domain exception hierarchy ─────────────────
public sealed class NotFoundException(string msg)   : Exception(msg);
public sealed class ConflictException(string msg)   : Exception(msg);
public sealed class ValidationException(string msg) : Exception(msg);

// ── Global handler ─────────────────────────────
public sealed class AppExceptionHandler(ILogger<AppExceptionHandler> log)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext ctx, Exception ex, CancellationToken ct)
    {
        var (status, title) = ex switch
        {
            NotFoundException   e => (404, e.Message),
            ValidationException e => (422, e.Message),
            ConflictException   e => (409, e.Message),
            UnauthorizedAccessException => (403, "Forbidden"),
            _                           => (500, "An unexpected error occurred")
        };

        if (status >= 500)
            log.LogError(ex, "Unhandled exception on {Path}",
                ctx.Request.Path);

        ctx.Response.StatusCode = status;
        await ctx.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status   = status,
            Title    = title,
            Instance = ctx.Request.Path
        }, ct);

        return true;   // handled — short-circuit middleware pipeline
    }
}

// ── Program.cs ────────────────────────────────
builder.Services.AddExceptionHandler<AppExceptionHandler>();
builder.Services.AddProblemDetails();
// ...
app.UseExceptionHandler();   // must be before UseRouting

// ── Controller — clean, no try/catch ──────────
public async Task<ProductDto> GetProduct(int id, CancellationToken ct)
{
    var product = await _repo.GetByIdAsync(id, ct)
        ?? throw new NotFoundException($"Product {id} not found.");
    return _mapper.Map<ProductDto>(product);
}
// Throws → AppExceptionHandler → 404 ProblemDetails JSON
07

Generics & the Type System

Generics let you write type-safe, allocation-efficient code once and use it with any type. Constraints tell the compiler what you can do with a type parameter.

Generics

Generic classes, methods & constraints

Constraints narrow what you can do with T inside the generic body — and what the compiler lets callers pass in.

// ── Generic class ─────────────────────────────
public class Stack<T>
{
    private readonly List<T> _items = [];

    public void Push(T item) => _items.Add(item);
    public T    Pop()        => _items.Count > 0
        ? _items[^1].Also(x => _items.RemoveAt(_items.Count - 1))
        : throw new InvalidOperationException("Stack is empty.");
    public bool IsEmpty => _items.Count == 0;
}

// ── Generic method ────────────────────────────
public static T Clamp<T>(T value, T min, T max)
    where T : IComparable<T>
    => value.CompareTo(min) < 0 ? min
     : value.CompareTo(max) > 0 ? max
     : value;

var x = Clamp(150, 0, 100);       // 100
var y = Clamp(3.14, 0.0, 2.0);    // 2.0

// ── Common constraints ────────────────────────
// where T : class         — reference type (no null)
// where T : struct        — value type
// where T : new()         — has parameterless constructor
// where T : IRepository   — implements interface
// where T : notnull       — non-nullable (class or struct)
// where T : unmanaged     — blittable (stackalloc, pointers)

public static T Create<T>()
    where T : class, new()
    => new T();

// ── Static abstract interface members (C# 11) ─
// Let generics call operators and static methods on T
public interface IAddable<T> where T : IAddable<T>
{
    static abstract T operator +(T a, T b);
    static abstract T Zero { get; }
}

public static T Sum<T>(IEnumerable<T> items)
    where T : IAddable<T>
    => items.Aggregate(T.Zero, (acc, x) => acc + x);
Generics

Covariance, contravariance & variance on interfaces

out (covariant) means "I only produce T" — safe to treat IEnumerable<Dog> as IEnumerable<Animal>. in (contravariant) means "I only consume T" — safe in reverse.

// ── Covariance: out T ─────────────────────────
// IEnumerable<T> is covariant — it only gives you Ts, never takes them
IEnumerable<string>  strings  = new List<string> { "hello", "world" };
IEnumerable<object>  objects  = strings;   // ✅ safe upcast

// Your own covariant interface:
public interface IProducer<out T>
{
    T Produce();
}

IProducer<string>  strProducer = new StringProducer();
IProducer<object>  objProducer = strProducer;  // ✅ covariant

// ── Contravariance: in T ──────────────────────
// Action<T> is contravariant — it only consumes Ts
Action<object>  logObject = o => Console.WriteLine(o);
Action<string>  logString = logObject;   // ✅ safe: string IS-A object

// Your own contravariant interface:
public interface IConsumer<in T>
{
    void Consume(T item);
}

IConsumer<object> objConsumer = new ObjectConsumer();
IConsumer<string> strConsumer = objConsumer;  // ✅ contravariant

// ── Invariant: no annotation ──────────────────
// List<T> is invariant — it both reads AND writes T
List<string> strList = new();
// List<object> objList = strList;  // ❌ would allow objList.Add(42)

// ── INumber<T> — generic math (.NET 7+) ──────
public static T Square<T>(T x)
    where T : System.Numerics.INumber<T>
    => x * x;

Console.WriteLine(Square(5));     // 25   (int)
Console.WriteLine(Square(3.14));  // 9.8596 (double)
Generics

Func pipeline composition & INumber<T> generic math

Compose Func<T> delegates into reusable, testable pipelines. Use INumber<T> (.NET 7+) to write arithmetic algorithms once that work for int, double, decimal — no more copy-pasted overloads.

// ── Func pipeline composition ─────────────────
Func<decimal, decimal> applyVat     = p => p * 1.24m;
Func<decimal, decimal> roundCents   = p => Math.Round(p, 2);
Func<decimal, string>  format       = p => p.ToString("C");

// Extension: chain any two compatible Funcs
public static Func<TIn, TOut> Then<TIn, TMid, TOut>(
    this Func<TIn, TMid> first,
    Func<TMid, TOut> next) => x => next(first(x));

var pricePipeline = applyVat.Then(roundCents).Then(format);
Console.WriteLine(pricePipeline(100m));  // "€124.00"

// ── Generic math — INumber<T> (.NET 7+) ──────
// One implementation, every numeric type:
public static T Sum<T>(IEnumerable<T> values)
    where T : System.Numerics.INumber<T>
{
    T total = T.Zero;
    foreach (var v in values) total += v;
    return total;
}

public static T Mean<T>(IEnumerable<T> values)
    where T : System.Numerics.INumber<T>
{
    var list = values.ToList();
    return Sum(list) / T.CreateChecked(list.Count);
}

Console.WriteLine(Sum(new[]  { 1, 2, 3, 4, 5 }));       // 15      (int)
Console.WriteLine(Sum(new[]  { 1.5, 2.5, 3.5 }));       // 7.5     (double)
Console.WriteLine(Mean(new[] { 10m, 20m, 30m }));        // 20.0    (decimal)

// ── Generic clamp — IComparable<T> ───────────
public static T Clamp<T>(T value, T min, T max)
    where T : IComparable<T>
    => value.CompareTo(min) < 0 ? min
     : value.CompareTo(max) > 0 ? max
     : value;

Console.WriteLine(Clamp(150,  0,   100));    // 100  (int)
Console.WriteLine(Clamp(3.14, 0.0, 2.0));   // 2.0  (double)