Skip to content

C# Fundamentals — Session Recap

Session type: Concept coverage + drilling
Pillars covered: C# fundamentals — Type System, OOP Mechanics, Memory & Lifecycle, Async/Await, Generics & Collections, Delegates & Events (partial — LINQ internals, records, pattern matching still pending)


Starting Point

No prior C# fundamentals session. Starting cold across six areas:

  1. Type system
  2. OOP mechanics
  3. Memory and lifecycle
  4. Async/await
  5. Generics and collections
  6. Language features (delegates, events, LINQ, records, pattern matching)

Goal: interview-ready understanding with depth, not surface definitions. Each area covered concept first, then drilled with questions tied to real production code where possible.


What We Did

Went through areas 1–5 fully and covered delegates and events in depth under area 6. LINQ internals, records, and pattern matching are pending for the next session.


Concepts Covered


1. Type System

Value Types vs Reference Types

Value types store the actual value directly. Examples: int, double, float, bool, struct, enum. When a value type is passed to a method, a copy of the value is passed. Mutations inside the method do not affect the original.

Reference types store a reference (pointer) to data on the heap. Examples: class, string, array, delegate. When a reference type is passed to a method, a copy of the reference is passed — both the caller and the method point to the same underlying object. Mutations to the object's fields reflect back on the caller.

Where value types are stored — the nuance:

  • Local variable in a method → stored on the stack frame
  • Field inside a class object → stored on the heap, inline with the object

The common answer "value types live on the stack" is mostly right but incomplete. The actual rule is: value types live wherever their containing scope lives.

Reassignment vs mutation — the critical distinction:

void Reassign(List<int> list)
{
    list = new List<int>(); // does NOT affect the caller
}

void Mutate(List<int> list)
{
    list[0] = 99; // DOES affect the caller
}

Reassignment replaces the local copy of the reference. The caller's reference still points to the original object. Mutation modifies the object both references point to.

ref keyword:

Without ref — copy of the value (or copy of the reference) is passed.
With ref — a reference to the variable itself is passed. This means reassignment inside the method affects the caller's variable.

Mechanically: ref passes a pointer to the pointer. The method can now change what the caller's variable points to.

void Reassign(ref List<int> list)
{
    list = new List<int>(); // NOW affects the caller
}

Boxing and Unboxing

Boxing — wrapping a value type in an object on the heap.

int x = 42;
object o = x; // boxes x — allocates on heap, copies value

Unboxing — extracting the value back out:

int y = (int)o; // unboxes — copies value back from heap

Why it matters in production:

Each box is a heap allocation. The GC has to track and collect it. In a tight loop — thousands of boxes = GC pressure = pauses.

ArrayList list = new ArrayList();
for (int i = 0; i < 100000; i++)
{
    list.Add(i); // boxes every int — heap allocation per iteration
}

This is exactly why List<int> exists. Generics hold the value type directly — no boxing, no heap allocation per element. This is the real answer to "why use generics" — not just type safety but avoiding boxing overhead.


Structs vs Classes

Structs are value types. Classes are reference types.

When to use a struct — all of these should hold:

  • Small size — typically 16 bytes or less
  • Logically a value — copying makes sense, two instances with the same data are equal
  • Immutable or close to it
  • Will not be boxed frequently

Classic example: Point with X and Y.

The mutable struct gotcha:

struct Point { public int X; public int Y; }

Point a = new Point { X = 1, Y = 2 };
Point b = a;
b.X = 99;

Console.WriteLine(a.X); // prints 1 — a is unchanged

b is a full copy. Mutating b has zero effect on a. This surprises people who treat structs like classes. Mutable structs are dangerous — pass them to a method, mutate inside, expect the original to change — it won't.


2. OOP Mechanics

Abstract vs Interface

Textbook version:

  • Interface: fully abstract, no concrete members (pre C# 8), allows multiple inheritance
  • Abstract class: can have concrete members, single inheritance only

The actual intent:

Interface = contract from the consumer's perspective. The consumer knows what behaviour to expect. The implementer can change the implementation entirely without breaking the consumer. This is Dependency Inversion in practice.

Real example: IDocumentStore. The consumer (application layer, use case) has no idea if the underlying storage is Azure Blob, S3, or MinIO. It just calls Store() and Retrieve(). The abstraction is the contract.

Abstract class = shared behaviour across a hierarchy. Use when classes share an IS-A relationship and need common implementation. The base defines the skeleton, subclasses fill in the specifics.

Real example: RabbitMQ consumer base class. The base class defines the retry loop, the channel setup, the ACK/NACK logic. It declares Handle as abstract — every consumer has its own flavour of handling but the infrastructure is shared. This is the Template Method pattern.

Default Interface Methods (C# 8+)

Adds a default method body to an interface. Looks like it's doing the job of an abstract class but the intent is different — backwards compatibility. When you need to add a new method to an interface without breaking every existing implementor, you provide a default implementation.

Should never be used for complex shared logic. That's what abstract classes are for.

One valid use: throwing NotImplementedException as the default — signals "this method exists on the contract but this implementor doesn't support it."

virtual / override vs new

override — participates in runtime polymorphism. The CLR looks at the actual type of the object at runtime and dispatches to the correct method.

new — breaks the virtual dispatch chain. Method resolution happens at compile time based on the declared type of the variable, not the actual type of the object.

class Base { public virtual void Print() => Console.WriteLine("Base"); }
class Derived : Base { public override void Print() => Console.WriteLine("Derived"); }
class AnotherDerived : Base { public new void Print() => Console.WriteLine("AnotherDerived"); }

Base a = new Derived();
Base b = new AnotherDerived();

a.Print(); // "Derived" — runtime dispatch, actual type is Derived
b.Print(); // "Base" — compile time dispatch, declared type is Base

new is dangerous because behaviour changes silently depending on the declared type of the variable. Almost never legitimate. The one edge case: third-party base class adds a method with the same name as yours in a future version — new makes the hiding explicit rather than accidental.


3. Memory and Lifecycle

GC Generations

Every new object starts in Gen0. The GC operates on the premise that most objects die young.

The mechanism:

  1. Gen0 fills up → Gen0 collection runs
  2. GC traces from roots (local variables on active stack frames, static fields, CPU registers, GC handles)
  3. Objects reachable from roots → alive → promoted to Gen1
  4. Objects not reachable → dead → memory reclaimed
  5. Gen1 fills up → Gen1 collection → survivors promoted to Gen2
  6. Gen2 fills up → full collection — most expensive, scans everything

Gen0 is small, collected frequently, cheap. Gen2 is large, collected rarely, expensive.

Large Object Heap (LOH): Objects ≥ 85,000 bytes go to the LOH. Collected with Gen2 (infrequently). Historically not compacted by default — gaps build up over time. Repeatedly allocating and releasing large arrays causes fragmentation even when memory looks freed.

JIT lifetime optimisation:

In Release builds, the JIT can determine an object is no longer reachable after its last use, even if the enclosing method hasn't returned. The object can be collected early.

This causes a specific production bug:

var handle = new SafeFileHandle(...);
var data = ReadFile(handle);
// handle not used after here — GC can collect it in Release
Process(data); // file operation still in progress underneath
// finalizer runs on handle while resource is still in use

Works in Debug (JIT optimization suppressed), breaks in Release. Fix: GC.KeepAlive(handle) at the end — a no-op call that exists purely to extend the object's reachable lifetime.

IDisposable

The GC manages managed resources. It has no visibility into unmanaged resources — file handles, DB connections, network sockets, OS handles.

IDisposable.Dispose() is where you release unmanaged resources explicitly. using guarantees Dispose() is called even if an exception is thrown.

using (var conn = new SqlConnection(connectionString))
{
    // conn.Dispose() called automatically at end of block
}

Finalizers

A finalizer is a safety net — if the consumer forgets to call Dispose(), the GC will eventually call the finalizer before reclaiming the object's memory.

Problems with relying on finalizers:

  1. Non-deterministic — you don't know when the GC will call it. Could be milliseconds, could be never before process exit.
  2. Performance cost — objects with finalizers are not collected in Gen0. They get promoted to a finalizer queue, processed by the finalizer thread, then collected in the next GC cycle. An object that should have died in Gen0 survives to Gen2.

The correct pattern — both together:

class ResourceHolder : IDisposable
{
    private bool _disposed = false;

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this); // tell GC: skip the finalizer
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;
        if (disposing) { /* free managed resources */ }
        /* free unmanaged resources */
        _disposed = true;
    }

    ~ResourceHolder()
    {
        Dispose(false); // safety net only
    }
}

GC.SuppressFinalize(this) is the critical line. If Dispose() was called properly, the finalizer is skipped entirely — no promotion to finalizer queue, no GC cost.

Interview answer: Finalizer is a safety net for when Dispose isn't called. Never rely on it as primary cleanup — it's non-deterministic and has a GC cost. Always implement IDisposable properly and suppress the finalizer when Dispose runs.


4. Async/Await

What the compiler generates

async/await is compiler sugar. The compiler transforms an async method into a state machine — a class that tracks:

  • The current state (which await point to resume from)
  • All local variables that need to survive across await points
  • The continuation to invoke when the awaited task completes

The state machine is heap allocated — not on the stack. When an await is hit and the thread is released, the stack frame is gone. The state machine on the heap is what remembers where to resume.

The thread is released back to the thread pool — not blocked. This is the entire point. A single thread can handle thousands of concurrent requests because threads aren't held hostage during IO.

SynchronizationContext

Controls where continuations run after an await completes.

Environment SynchronizationContext After await
WinForms / WPF UI SynchronizationContext Continuation marshalled back to UI thread
ASP.NET classic Request SynchronizationContext Continuation marshalled back to request context
ASP.NET Core None Any available thread pool thread
Console app None Any available thread pool thread

Thread ID before and after an await in ASP.NET Core — will likely differ. Any free thread picks up the continuation.

ConfigureAwait(false)

Tells the awaiter: don't attempt to resume on the captured SynchronizationContext. Resume on any available thread pool thread.

In ASP.NET Core it makes no functional difference — there's no SynchronizationContext to capture. But library code uses it because libraries don't know who's calling them. If called from WinForms without ConfigureAwait(false), every await in the library tries to marshal back to the UI thread — unnecessary overhead and potential deadlock.

The classic deadlock

// ASP.NET classic or WinForms
public async Task<string> GetDataAsync()
{
    await Task.Delay(1000); // captures SynchronizationContext
    return "done";
}

public string Deadlock()
{
    return GetDataAsync().Result; // blocks the UI/request thread
}
  1. GetDataAsync() hits await, captures the SynchronizationContext
  2. .Result blocks the current thread — UI thread is now blocked
  3. Delay completes, continuation tries to resume on the captured SynchronizationContext — needs the UI thread
  4. UI thread is blocked waiting for the task
  5. Task is waiting for the UI thread
  6. Neither moves — deadlock

Fix: ConfigureAwait(false) in GetDataAsync() so the continuation doesn't need the UI thread. Or never block with .Result on async code.

CancellationToken

A struct that carries a cancellation signal. Cooperative — the token doesn't forcibly kill anything. Every operation in the chain has to observe it and respond.

Three pieces:

var cts = new CancellationTokenSource(); // owns the cancel button
CancellationToken token = cts.Token;     // passed around, read-only
cts.Cancel();                            // fire the signal

ASP.NET Core provides one for free on every request. When the client disconnects, ASP.NET Core cancels the token automatically:

[HttpGet]
public async Task<IActionResult> GetInvoices(CancellationToken cancellationToken)
{
    var results = await _repository.GetInvoicesAsync(cancellationToken);
    return Ok(results);
}

Propagate through every async call in the chain. Check manually in long loops:

foreach (var invoice in invoices)
{
    ct.ThrowIfCancellationRequested(); // throws OperationCanceledException
    await ProcessAsync(invoice, ct);
}

If you pass the token to EF Core but forget to check it in your own loop — your loop runs to completion regardless. Cancellation is only as deep as you wire it.


5. Generics and Collections

What generics solve

Before generics, flexible code meant object:

public class Stack
{
    private object[] _items;
    public void Push(object item) { }
    public object Pop() { }
}

Problems:

  1. No type safety — push a string, pop as int, compiler doesn't stop you, runtime explodes
  2. Boxing overhead — every value type gets boxed going in, unboxed coming out

Generics solve both:

public class Stack<T>
{
    private T[] _items;
    public void Push(T item) { }
    public T Pop() { }
}

Stack<int> only accepts ints. Compiler enforces it. No boxing. No casting.

Generic Constraints

Inside a generic class, the compiler knows nothing about T. You can't call any methods on it. Constraints tell the compiler what T is guaranteed to be:

public class Repository<T> where T : class, IEntity, new()

This says:

  • T must be a reference type
  • T must implement IEntity
  • T must have a parameterless constructor

Now inside the class you can call IEntity methods on T, instantiate it with new T(), and treat it as a reference type.

Constraint Meaning
where T : class T must be a reference type
where T : struct T must be a value type
where T : new() T must have parameterless constructor
where T : SomeClass T must inherit from SomeClass
where T : IInterface T must implement IInterface

Covariance and Contravariance

The problem:

string inherits from object. So object x = "hello" works. But:

List<string> strings = new List<string>();
List<object> objects = strings; // compiler error

If this were allowed, you could do objects.Add(42) — writing an int into what is actually a list of strings. Type safety destroyed. The compiler blocks it entirely.

Covariance — out T:

IEnumerable<T> is read-only. You can only get items out of it, never put items in. Because T only appears in output position, assigning IEnumerable<string> to IEnumerable<object> is safe — a string IS an object.

IEnumerable<string> strings = new List<string> { "a", "b" };
IEnumerable<object> objects = strings; // compiles — covariant

The out keyword in IEnumerable<out T> is declared in the BCL source — you see it if you go to definition in Rider. It tells the compiler "T only appears in output position, covariance is safe."

Covariance follows the natural inheritance direction — specific to general (stringobject).

Contravariance — in T:

Action<T> only uses T in input position. A method that handles any object can certainly handle a string. So assignment goes the other way:

Action<object> objectAction = x => Console.WriteLine(x);
Action<string> stringAction = objectAction; // compiles — contravariant

Contravariance flips the inheritance direction — general to specific (objectstring).

One-line mental model:

  • out T — covariant — T flows out — specific → general
  • in T — contravariant — T flows in — general → specific

6. Delegates and Events

The problem delegates solve

Without delegates, passing different behaviours to the same method means a switch statement on a string key:

void Sort(List<Invoice> invoices, string sortKey)
{
    switch(sortKey)
    {
        case "date": ...
        case "amount": ...
    }
}

Problems:

  1. Closed for extension — every new sort strategy requires modifying the switch
  2. The method owns logic it shouldn't own
  3. Verbose, growing indefinitely

With delegates, you pass the behaviour itself:

void Sort(List<Invoice> invoices, Func<Invoice, object> sortBy)
{
    // applies whatever logic was passed in
}

Sort(invoices, invoice => invoice.Date);
Sort(invoices, invoice => invoice.Amount);
Sort(invoices, invoice => invoice.CustomerName);

Sort no longer knows or cares about sorting strategies. Adding a new sort strategy requires zero changes to Sort. This is the core problem delegates solve — passing behaviour as a parameter.

What a delegate is

A type that holds a reference to a method with a specific signature. Type-safe function pointer.

public delegate bool Filter(Invoice invoice); // declare the type
Filter myFilter = invoice => invoice.Amount > 1000; // assign
bool result = myFilter(someInvoice); // invoke

Built-in delegate types

Custom delegate declarations are rare. Three built-in types cover almost everything:

Type Signature Use when
Action<T> takes T, returns void side effects, no return value
Func<T, TResult> takes T, returns TResult transformations, queries, filtering
Predicate<T> takes T, returns bool filtering, validation — same as Func<T, bool> but named for intent

Predicate<T> is just a pre-declared delegate for T → bool. Identical to Func<T, bool> — exists purely for readability.

The one reason to declare a custom delegate: domain readability at important boundaries. InvoiceValidator communicates intent better than Func<Invoice, bool> when it's passed around everywhere in a large codebase. Functionally identical.

Real examples from production code

You use delegates constantly without labelling them:

// generic repository — Func<Invoice, bool> as filter
var invoices = await _repo.FindAsync(x => x.Status == "pending");

// startup configuration — Action<RabbitMQOptions>
services.AddRabbitMQ(options =>
{
    options.Host = "localhost";
    options.Port = 5672;
});

x => x.Status == "pending" is a lambda — compiler shorthand for an anonymous method matching the delegate signature.

Multicast delegates

A delegate variable can hold a chain of method references. += adds to the chain. -= removes from it. When invoked, all methods in the chain execute.

Action<string> pipeline = null;
pipeline += message => Console.WriteLine($"Log: {message}");
pipeline += message => SaveToDatabase(message);
pipeline += message => PublishToQueue(message);

pipeline("Invoice processed"); // all three run

Execution order: methods run in the order they were added. First in, first called.

Exception behaviour: if one method throws, the remaining methods do not execute. The exception propagates immediately. No isolation between methods in the chain.

Return values with multicast: if the delegate has a return type (Func not Action), only the last method's return value is kept. All others are silently discarded. This is why multicast delegates are almost always Action — return values from multicast don't make sense.

Multicast with Func<Invoice, bool> as a filter:

Technically allowed by the compiler. But only the last condition's result is used by Where. The earlier conditions are silently discarded. No error, no warning, just wrong results. Never do this. Combine conditions inside a single lambda instead:

// correct
invoices.Where(invoice => invoice.Amount > 1000 && invoice.Status == "pending")

// or chain Where calls — effectively &&
invoices.Where(x => x.Amount > 1000).Where(x => x.Status == "pending")

The -= gotcha:

pipeline += message => Console.WriteLine($"Log: {message}");
pipeline -= message => Console.WriteLine($"Log: {message}"); // does NOT remove it

Each lambda is a new anonymous method instance — a different object in memory. The comparison fails. Nothing is removed. To make -= work, store the delegate in a variable and use the same reference:

Action<string> logHandler = message => Console.WriteLine($"Log: {message}");
pipeline += logHandler;
pipeline -= logHandler; // works — same reference

Events

A public delegate field has three problems — external code can:

  1. Invoke it directly
  2. Reassign it — wiping all existing subscribers
  3. Null it — destroying the subscription list entirely

The event keyword fixes all three. From outside the declaring class:

Operation Public delegate field Event
+= handler allowed allowed
-= handler allowed allowed
= handler (reassign) allowed blocked
= null (wipe) allowed blocked
Invoke(...) (fire) allowed blocked

Inside the declaring class — all operations are still allowed.

Subscribe = += on an event. "When this fires, also run my method."
Unsubscribe = -= on an event. "Stop calling my method when this fires."

The standard event pattern

public class InvoiceProcessor
{
    public event EventHandler<InvoiceProcessedArgs> InvoiceProcessed;

    private void Process(Invoice invoice)
    {
        // do work
        InvoiceProcessed?.Invoke(this, new InvoiceProcessedArgs(invoice));
    }
}

Why EventHandler<T> instead of Action<T>:

EventHandler<T> is a pre-declared delegate: void EventHandler<TEventArgs>(object sender, TEventArgs e). The sender parameter tells the subscriber which object fired the event — useful when subscribed to multiple instances. Convention across the entire .NET BCL — every event in WinForms, ASP.NET classic, timers follows this same (sender, args) shape.

Why ?.Invoke instead of direct invocation:

If nobody has subscribed, the event delegate is null. InvoiceProcessed(this, args) throws NullReferenceException. ?.Invoke is null-conditional — skips the call entirely if null. Also thread-safe — reads the reference once atomically, avoiding the race condition between a null check and invocation.

Custom event args by convention inherit from EventArgs:

public class InvoiceProcessedArgs : EventArgs
{
    public Invoice Invoice { get; }
    public InvoiceProcessedArgs(Invoice invoice) => Invoice = invoice;
}

Where C# event mechanics are actually used

Scenario Mechanism
Button click in WinForms/Blazor C# event + EventHandler<T>
Simple callback between two objects Action<T> delegate
Domain events in DDD Plain class + dispatcher + DI
Cross-service events RabbitMQ

Domain Events — the correct DDD approach

C# event mechanics are not used for domain events in a DDD application. The pattern is completely different.

Domain layer — event is plain data:

public class OrderCreatedEvent
{
    public int OrderId { get; }
    public string CustomerId { get; }
    public DateTime CreatedAt { get; }

    public OrderCreatedEvent(int orderId, string customerId)
    {
        OrderId = orderId;
        CustomerId = customerId;
        CreatedAt = DateTime.UtcNow;
    }
}

No EventArgs. No EventHandler. Just a plain class. The domain defines what happened — nothing more.

Domain entity — records the event, doesn't fire it:

public class Order
{
    private List<object> _domainEvents = new();
    public IReadOnlyList<object> DomainEvents => _domainEvents;

    public void Create(string customerId)
    {
        // enforce invariants, set state
        _domainEvents.Add(new OrderCreatedEvent(Id, customerId));
    }

    public void ClearDomainEvents() => _domainEvents.Clear();
}

The entity does not fire anything. It records that the event happened. A list of things that occurred during this operation.

Application layer — dispatches after save:

public class CreateOrderUseCase
{
    public async Task Execute(CreateOrderCommand command)
    {
        var order = new Order();
        order.Create(command.CustomerId);

        await _repo.SaveAsync(order); // save first

        foreach (var domainEvent in order.DomainEvents)
        {
            await _dispatcher.Dispatch(domainEvent); // then dispatch
        }

        order.ClearDomainEvents();
    }
}

Save first, dispatch after. If you dispatch first and the save fails — you've notified subscribers about an order that doesn't exist in the database. Dual-write consistency problem.

The dispatcher abstraction:

public interface IEventDispatcher
{
    Task Dispatch(object domainEvent);
}

public interface IEventHandler<TEvent>
{
    Task Handle(TEvent evt);
}

Correct layering:

Layer Responsibility
Domain Defines the event class, entity records it
Application Dispatches events after save, handlers live here
Infrastructure IEventDispatcher implementation

In-process dispatch — MediatR

Don't build your own dispatcher. Use MediatR.

// event becomes a notification
public class OrderCreatedEvent : INotification
{
    public int OrderId { get; }
    public string CustomerId { get; }
}

// handlers
public class SendConfirmationEmailHandler : INotificationHandler<OrderCreatedEvent>
{
    public async Task Handle(OrderCreatedEvent notification, CancellationToken ct)
    {
        await _emailService.SendConfirmation(notification.CustomerId);
    }
}

public class ReserveInventoryHandler : INotificationHandler<OrderCreatedEvent>
{
    public async Task Handle(OrderCreatedEvent notification, CancellationToken ct)
    {
        await _inventoryService.Reserve(notification.OrderId);
    }
}

// dispatch
await _mediator.Publish(new OrderCreatedEvent(order.Id, order.CustomerId));

// registration — scans assembly, finds all INotificationHandler<T> automatically
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

MediatR also handles commands (one handler, returns result):

// Publish — fires to all handlers, no return value — for events
await _mediator.Publish(new OrderCreatedEvent(...));

// Send — fires to exactly one handler, returns result — for commands
int orderId = await _mediator.Send(new CreateOrderCommand(...));

Cross-service dispatch — RabbitMQ

The IEventDispatcher abstraction is what keeps the application layer clean. Swap the implementation:

public class RabbitMqEventDispatcher : IEventDispatcher
{
    public async Task Dispatch(object domainEvent)
    {
        await _messageBus.Publish(domainEvent);
    }
}

Application layer code does not change. DI registration determines which transport runs.

Full picture:

Scenario Tool
In-process, same service MediatR Publish
Cross-service, different process RabbitMQ via IEventDispatcher
Both behind IEventDispatcher abstraction

What We Messed Up

1. "Value types stored in memory by value" — imprecise

What went wrong: Initial answer said value types are "stored in memory by value" without specifying where.
Why it matters: Stack vs heap distinction is a frequent interview follow-up.
The fix: Value types live wherever their containing scope lives. Local variable → stack. Field inside a class → heap alongside the object.
Remember: Never say "value types always live on the stack" — it's wrong for value type fields inside class objects.

2. "Any change within a function reflects back" — too broad

What went wrong: Said all mutations on reference types reflect back on the caller.
The correction: Mutations to the object's fields reflect back. Reassigning the reference variable itself (pointing it at a new object) does not affect the caller. Only ref makes reassignment affect the caller.

3. Boxing — "we lose the underlying data type"

What went wrong: Said boxing causes loss of data type.
The correction: The data is still there, you can unbox it back. The actual problem is heap allocation per box → GC pressure in tight loops.

4. Covariance — "List should be assignable to List"

What went wrong: Intuition said this should work.
Why it doesn't: List<T> allows writes. If allowed, you could write non-strings into a List<string> via the List<object> reference — type safety destroyed.
Remember: Covariance only works for read-only interfaces (IEnumerable<out T>). Mutable collections cannot be covariant.

5. Multicast with Func — "could pass multiple filters to Where"

What went wrong: Thought chaining delegates with += would combine filter conditions with AND.
The correction: Multicast with Func silently discards all return values except the last. The filters don't combine — only the last one applies. No error, just wrong results.
Remember: Multicast with Action — fine. Multicast with Func — practically broken, never do it. Combine conditions inside a single lambda.

6. -= with inline lambda — "it should remove the handler"

What went wrong: Assumed -= with an identical-looking lambda would remove the previously added one.
Why it doesn't: Each lambda is a new object. The comparison by reference fails. Nothing is removed.
Remember: Store the delegate in a variable if you ever need to remove it. Never use -= with inline lambdas.

7. Confusing domain events with C# event mechanics

What went wrong: Initially unclear on whether domain events use C# event keyword.
The correction: They don't. Domain events are plain classes. C# event mechanics are for UI and simple component notification. In DDD, dispatch goes through IEventDispatcher and DI — no +=, no EventHandler<T>, no delegate chains.


Key Values and Config to Remember

Item Value
LOH threshold 85,000 bytes
GC generations 0, 1, 2 + LOH
LOH collected with Gen2
MediatR command method _mediator.Send() — one handler, returns result
MediatR event method _mediator.Publish() — all handlers, no return
MediatR registration services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(...))
IEnumerable<out T> Covariant — read only, specific → general
Action<in T> Contravariant — write only, general → specific
Safe event invoke pattern MyEvent?.Invoke(this, args)
Finalizer suppression GC.SuppressFinalize(this) in Dispose()
Keep object alive for GC GC.KeepAlive(obj) at end of method

Unanswered Questions / Things to Investigate

  1. MediatR pipeline behaviours — MediatR supports pre/post processing via IPipelineBehavior<TRequest, TResponse>. Not covered. Worth looking at — logging, validation, and transaction wrapping all live here in a clean architecture setup.

  2. IEventDispatcher — save and dispatch in the same transaction — the current pattern saves first, then dispatches. What if the dispatch fails after save? The event is lost. The correct solution is the Outbox Pattern — persist events to a DB table in the same transaction as the save, then a background job dispatches them. Not covered. High-value for PEPPOL platform.

  3. MediatR vs raw IEventDispatcher — when would you not use MediatR and roll your own? Not resolved.

  4. ref vs out vs in parameters — covered ref, did not cover out (must be assigned before method returns) or in (read-only ref, no copy, no mutation). Worth covering.

  5. Thread safety of multicast delegate invocation — mentioned ?.Invoke is atomic but didn't go deep on what happens if a subscriber unsubscribes during invocation. Edge case but comes up in advanced interviews.


What's Next

  1. LINQ internals — deferred execution, IEnumerable vs IQueryable, expression trees, yield return. This is the next immediate item — delegates are the foundation for understanding LINQ.

  2. Records — immutability, value equality, with expressions, when to use over classes or structs.

  3. Pattern matchingswitch expressions, property patterns, positional patterns, when guards. Directly relevant to domain modelling.

  4. After C# fundamentals are complete — move to LLD Phase 1: SOLID principles one at a time with violation examples and fixes. Delegates and events covered in this session are direct prerequisites for Observer pattern in LLD Phase 2.

  5. Outbox Pattern — investigate for PEPPOL platform. The current save-then-dispatch approach has a gap. Worth implementing before the Azure deployment is considered production-grade.