Skip to content
QSWEQB

.NET and C# fundamentals

The answers a .NET interviewer works through before the hard questions start: the memory model, the type system, LINQ execution, async, ASP.NET Core wiring and the allocation-free types. Fifty-eight items, fourteen of them worked through with code, a table or a diagram.

58 questions

Go deeper on .NET & C#

The runtime and memory model

Where do value types and reference types actually live?

The honest answer is that the distinction is about copying semantics, not about storage, and the common "structs live on the stack" line is wrong often enough to be a trap. A value type is stored inline wherever it is declared: a local goes on the stack, a field of a class goes inside that object on the heap, an element of a Struct[] goes inline in the array on the heap. A reference type always has its object on the heap, with the reference itself stored inline wherever it was declared. What follows is the behaviour that matters: assigning a value type copies the bytes, so the two variables are independent, while assigning a reference copies only the pointer, so both names see the same mutations.

What does the generational garbage collector actually do?

It divides the heap into three generations on the assumption that most objects die young. New allocations go into generation 0, which is small and collected frequently and cheaply; survivors are promoted to generation 1, and survivors of that to generation 2, which is collected rarely because a full pass is expensive. The consequence for your code is that short-lived garbage is nearly free, so a per-request object graph costs very little, while an object that survives long enough to reach generation 2 is expensive to reclaim and expensive to keep. That is why the damaging allocation pattern is not "lots of objects" but "objects that live just long enough to be promoted" — a cache with a short-but-not-short-enough lifetime is worse than either extreme.

What is the large object heap, and why does it matter?

Allocations of roughly 85,000 bytes or more go on a separate large object heap, which is collected as part of a generation 2 collection and — historically — is not compacted, because moving multi-megabyte blocks is expensive. Two things follow. Any large allocation implicitly costs you a generation 2 collection eventually, so a request path that allocates a big array per call triggers the most expensive kind of collection at a rate proportional to traffic. And an uncompacted heap fragments, so the process can hold plenty of free memory and still fail to satisfy a large contiguous request. This is the concrete argument for ArrayPool<T> and for streaming rather than buffering: not that allocation is slow, but that this particular allocation is charged to the wrong budget.

What is boxing, and what does it cost?

Boxing is the runtime wrapping a value type in a heap-allocated object so it can be treated as a reference — which happens whenever a value is assigned to object, to an interface it implements, or to a non-generic API. Unboxing is the reverse, with a type check. The cost is an allocation plus a copy per box, and the reason it is dangerous is that it is invisible: no cast appears in the source, so a hot loop can allocate millions of objects with nothing in the code suggesting it. Generics exist largely to remove it — List<int> stores ints inline where the old ArrayList boxed every one — and the modern residue is mostly interpolated strings, params object[] logging calls, and comparing enums through non-generic interfaces.

Show me boxing in a loop, with the allocation count.

The code below contains no new and allocates ten million objects. It does contain two (int) casts, and they are worth looking at closely, because they are unboxing conversions rather than the thing doing the allocating — the boxing itself is written nowhere.

// 1. Assignment to `object` boxes. `object` is a reference type, so every int
//    has to be wrapped on the heap before it can be stored in that variable.
//    The visible (int) cast is the unboxing on the way back out; the boxing on
//    the way in has no syntax at all.
long Sum(int[] values)
{
    object total = 0;                      // one box
    for (int i = 0; i < values.Length; i++)
    {
        total = (int)total + values[i];     // unbox, add, box again: 10M boxes
    }
    return (int)total;
}

// 2. The logging call boxes, once per argument, on every iteration -
//    including when the log level is disabled and the string is never built.
for (int i = 0; i < 10_000_000; i++)
{
    logger.LogDebug("processed {Index} of {Total}", i, total);   // 2 boxes each
}

// 3. Enum comparison through the non-generic path.
if (status.Equals(OrderStatus.Placed)) { }   // boxes both sides
Method            Allocated
----------------  -----------
Sum, boxed        240,000,024 B   ~10M boxes at 24 bytes each
Sum, int total              0 B

Two hundred and forty megabytes of generation 0 garbage to add up an array. The collector handles it — that is what generation 0 is for — but the allocation itself is not free, the collections are not free, and the cache behaviour is ruinous because every logical integer is a pointer chase to a separate object.

The fixes are all the same shape: keep the value in a type the compiler knows statically. Use long total rather than object. Where you are comparing or sorting through an interface, put a generic constraint on it — where T : IComparable<T> — so the call resolves to the value type's own implementation without boxing, which is the same fix in a different costume. Check logger.IsEnabled before a hot debug call, or use a source-generated logging method, which avoids both the boxing and the format parse. And use == on enums rather than Equals.

The detail candidates miss is that boxing shows up in a memory profile and never in a CPU profile of your own methods — the time is spread across the collector, which is somebody else's stack frame.

Why does a .NET service get faster after a few minutes?

Because IL is compiled to machine code by the JIT on first call, and the tiered compilation system deliberately compiles quickly and badly first. Tier 0 produces unoptimised code fast, so start-up is quick; methods called often enough are recompiled at tier 1 with full optimisation, inlining and better register allocation. So the first few hundred calls to a path run code that is several times slower than what you shipped. The practical consequences are that a benchmark without a warm-up phase measures the wrong code entirely, that a latency graph should be expected to improve for the first minute after a deploy, and that ReadyToRun or Native AOT exist precisely to trade some peak throughput for predictable start-up.

Types and the type system

When is a struct the right choice over a class?

When the type is small, genuinely a value, immutable, and allocated in large numbers — a coordinate, a money amount, a timestamp, a key. The gain is that instances live inline rather than as separate heap objects, so an array of them is one contiguous block the processor can prefetch, and there is no allocation or collection cost per instance. The conventional size guidance is sixteen bytes or so, and it exists because every assignment, every argument pass and every return copies the whole thing, so a large struct trades one allocation for copying on every use. The disqualifiers are mutability, identity, inheritance, and being stored as an interface or in a non-generic collection, since each of those either breaks or boxes.

Show me struct copying semantics failing.

Every assignment and every argument pass copies the struct, so a mutation writes to a copy the caller never sees.

public struct Counter
{
    public int Value;
    public void Increment() => Value++;
}

var counters = new List<Counter> { new Counter() };

// 1. Compiles, runs, and silently does nothing. This is the worst case.
counters[0].Increment();          // the indexer returns a copy; Increment
                                  // mutates that temporary, which is then
                                  // discarded
Console.WriteLine(counters[0].Value);   // 0

// 1b. THIS is what the compiler catches, and only this shape.
// counters[0].Value = 5;
//   CS1612: "Cannot modify the return value of List<Counter>.this[int]
//   because it is not a variable"
// The error fires on assignment to a field or property of the returned
// value. A method call on it is not an assignment, so line 1 sails through.

// 2. The same thing written out longhand, which is at least visible.
var item = counters[0];
item.Increment();                 // increments the copy
Console.WriteLine(counters[0].Value);   // 0

// 3. foreach gives you a copy per iteration, so this is a no-op loop.
foreach (var c in counters) c.Increment();
Console.WriteLine(counters[0].Value);   // still 0

// 4. Arrays are the exception, and the inconsistency is the trap.
var array = new Counter[1];
array[0].Increment();             // works: array indexing gives a real location
Console.WriteLine(array[0].Value);      // 1

The first case is the one worth getting right, because the instinct is to assume the compiler will stop you and it will not. CS1612 is narrower than its reputation: it fires when you assign to a field or property of a value returned by a property, indexer or method, because the assignment would provably be lost. A method call on that same returned value is legal C#, so counters[0].Increment() compiles cleanly, runs, mutates a temporary that nobody holds a reference to, and prints zero. The safe-looking line is the dangerous one, and the line that draws an error is the one you were never going to ship.

That asymmetry is worth stating plainly in an interview, because it inverts the usual reassurance about value types. The compiler protects you from the obvious mistake and waves through the subtle one, so the protection cannot be relied on as a design control — which is the argument for readonly struct, where the mutating method cannot exist in the first place.

The array case is what makes this genuinely confusing rather than merely surprising. An array indexer is a direct reference to storage, so mutation works; a List<T> indexer is a property returning a copy, so it does not. The same code, differing only in the collection type, silently changes meaning.

The lesson is not "be careful with mutable structs" but "do not write mutable structs". Declare them readonly struct and the compiler rejects the mutating method outright, at the definition rather than at each misuse. Then every apparent modification returns a new value, and the copying semantics stop mattering because there is nothing to lose.

The related default trap: a struct cannot have a parameterless constructor that does anything meaningful, so default(Counter) and new Counter[100] produce zeroed instances without any of your invariants having run. A struct must be valid when every field is zero, which is a real constraint on the design.

What does `readonly struct` buy you?

It makes the compiler enforce immutability — every field must be readonly and no method may mutate state — and it removes a class of hidden copies. When a non-readonly struct is accessed through a readonly field or an in parameter, the compiler must defensively copy it before calling any method, because it cannot prove the method will not mutate the original. That means passing a large struct by in to avoid a copy can silently produce a copy per method call, which is exactly the opposite of the intent. Marking it readonly struct lets the compiler skip the defensive copy. So the annotation is both a correctness statement and a performance one, and it should be the default for any struct you write.

What are nullable reference types actually checking?

They are a compile-time flow analysis, not a runtime feature. With the feature enabled, string means "should not be null" and string? means "may be null", and the compiler tracks what it can prove along each path, warning where a possibly-null value is dereferenced or assigned to a non-nullable target. Two limits matter. Nothing is enforced at runtime, so a null arriving from a nullable-oblivious library, from reflection, from deserialisation or from default(T) passes straight through. And the annotations are erased to attributes, so they inform callers rather than constrain them. The value is real but it is the value of a good linter: it converts a class of production NullReferenceException into build warnings, provided you treat them as errors.

Show me nullable reference types catching a real null.

The bug is ordinary and the fix is a two-character change to a signature.

#nullable enable

public sealed class UserService
{
    // The signature is the claim: this may return nothing.
    public User? FindByEmail(string email) =>
        _users.SingleOrDefault(u => u.Email == email);

    public string DisplayName(string email)
    {
        var user = FindByEmail(email);
        return user.Name;
        //     ^^^^ CS8602: Dereference of a possibly null reference.
    }
}

Before the feature, that line shipped and threw NullReferenceException in production the first time somebody typed an unregistered address. The compiler now refuses it, because the declared return type says absence is possible and nothing on the path ruled it out.

The interesting part is what satisfies the analysis, because it is flow-based rather than syntactic:

// Any of these silence the warning, and they mean different things.
if (user is null) return "unknown";
return user.Name;                       // narrowed by the guard

return user?.Name ?? "unknown";         // handle it inline

return user!.Name;                      // assert: "I know better"

The ! operator is the one to be suspicious of in review. It suppresses the warning without changing anything at runtime, so it converts a build failure back into the production exception it was preventing — the only defensible uses are where an invariant genuinely exists and the compiler cannot see it, such as after a TryGetValue returning true.

Two gaps worth naming before an interviewer does. Deserialisation and ORMs set properties by reflection, so a non-nullable string Name { get; set; } on an entity is a promise the framework does not keep; the required modifier is the modern answer. And default(T) in a generic method produces null for a reference type regardless of annotation, which is why the standard library uses [MaybeNull] and friends on generic returns.

What is a record, and what equality does it give you?

A record is a class — or with record struct, a struct — for which the compiler generates value equality, a ToString, a deconstructor and a with expression from the positional parameters or declared properties. Value equality is the substantive part: two records are equal when their types match and every field is equal, rather than when they are the same instance, and GetHashCode is generated consistently with it. That makes records the right default for anything that is data — a DTO, a query result, a message, a dictionary key. It makes them the wrong choice for anything with identity or a lifecycle, most obviously an ORM entity, whose equality is defined by its primary key and whose fields change by design.

What is a `record struct`, and when would you use one?

It is a struct with the same generated members a record class gets, which is useful because hand-writing Equals and GetHashCode on a struct is both tedious and easy to get wrong — the compiler's default struct equality uses reflection in some cases and is markedly slower than a generated field-by-field comparison. So a record struct gives you a small immutable value with correct, fast equality and no heap allocation, which is exactly what a strongly-typed identifier or a coordinate wants. Declare it readonly record struct unless you have a reason not to: the plain form generates settable properties, which reintroduces mutable-struct semantics and the defensive copies that come with them.

What is a `ref struct`, and why is it so restricted?

A ref struct is a struct guaranteed to live on the stack, which is what allows it to hold a reference into arbitrary memory safely — Span<T> is the canonical example. The restrictions all follow from that guarantee: it cannot be boxed, cannot be a field of a class, cannot be captured by a lambda or a local function, cannot be used as a generic argument in most positions, and historically could not appear in an async method or an iterator, because both of those hoist locals onto the heap. Each restriction exists to prevent the stack reference outliving the frame it points into. The practical effect is that Span<T> is superb inside a synchronous method and unusable across an await, which is why Memory<T> exists as the heap-safe counterpart.

Why is `string` immutable, and when does that cost you?

Because immutability makes strings safe to share, and the runtime shares them aggressively: literals are interned so the same text is often the same instance, the hash code can be cached, and a string used as a dictionary key or in a security check cannot be altered afterwards by whoever else holds a reference. The cost is that every apparent modification allocates. Concatenating in a loop is quadratic — each + copies the whole accumulated string — so building a 10,000-line report with += allocates hundreds of megabytes to produce a few hundred kilobytes. StringBuilder amortises this by writing into a growable buffer and materialising once. For a handful of known pieces, interpolation or string.Concat is faster than a builder, because the total length can be computed up front and allocated once.

Collections and LINQ

What is deferred execution, and why is it the default?

Most LINQ operators return an object that describes the query rather than its result, and nothing runs until something enumerates it — a foreach, a ToList, a Count, an aggregation. This is what makes composition cheap: chaining Where then Select then Take builds one pipeline that streams each element through every stage, so a Take(10) over a million-element source touches ten elements rather than filtering the million and discarding most. It also means a query can be built in one place and executed somewhere else, with whatever the source contained at that moment. The costs are the two classic bugs: the query re-runs on every enumeration, and it captures variables by reference, so a loop variable read at execution time may not be the one you meant.

Show me a deferred query enumerated twice.

The query object is not the results. Enumerating it twice runs it twice, and against a database that means two round trips.

// Nothing has executed. This is a description of a query.
IQueryable<Order> placed = db.Orders.Where(o => o.Status == Status.Placed);

// First enumeration: SELECT ... issued here.
Console.WriteLine($"count: {placed.Count()}");

// Second enumeration: the same SELECT issued again, and the two results can
// legitimately disagree if another transaction committed in between.
foreach (var order in placed)
{
    Process(order);
}
-- What the SQL log shows for the block above.
SELECT COUNT(*) FROM Orders WHERE Status = 1;
SELECT Id, CustomerId, Status, Total FROM Orders WHERE Status = 1;

Two round trips where the author intended one, and — more seriously — no guarantee that the count matches the rows, because the two statements ran at different instants. Code that logs a count and then iterates is the everyday shape of this bug, and it scales with traffic rather than with data, so it survives every test.

The fix is to materialise once and reuse the result:

var orders = await db.Orders
    .Where(o => o.Status == Status.Placed)
    .ToListAsync();                       // one query, one point in time

Console.WriteLine($"count: {orders.Count}");   // Count on a List, not a query
foreach (var order in orders) Process(order);

Note that orders.Count is now a property read on a list rather than a Count() extension issuing SQL, which is the same distinction one level down.

The detail candidates leave out is that the reverse mistake is just as common: calling ToList() early, before the Where, pulls the whole table into memory and filters it there. The rule that resolves both is to compose the entire query while it is still an IQueryable, materialise exactly once at the point you need the data, and pass a List<T> or an array — never an unexecuted query — across a method boundary.

What is the difference between `IEnumerable` and `IQueryable`?

IEnumerable<T> composes delegates and runs them in your process, one element at a time. IQueryable<T> composes an expression tree, which a provider — EF Core, for instance — translates into another language, usually SQL, and executes remotely. The distinction decides where the work happens, and because both support the same operator names, switching between them is a one-word change with an enormous performance difference. Once a query is enumerated as an IEnumerable, everything after that point is in-memory, so a Where applied after a cast has already downloaded the rows it is filtering. Two further consequences: an IQueryable can only contain what the provider can translate, so calling your own method inside a predicate either throws or silently falls back to client evaluation depending on the version.

Show me where the query actually runs.

The two blocks below differ by one call and by four orders of magnitude in the work performed.

// A. Stays IQueryable. The whole thing is translated and runs in the database.
var top = await db.Orders
    .Where(o => o.Status == Status.Placed && o.Total > 100m)
    .OrderByDescending(o => o.Total)
    .Select(o => new OrderSummary(o.Id, o.Total))
    .Take(20)
    .ToListAsync();
SELECT TOP(20) o.Id, o.Total
FROM Orders AS o
WHERE o.Status = 1 AND o.Total > 100.0
ORDER BY o.Total DESC;
-- 20 rows crossing the network, index-assisted, no sort in your process.
// B. AsEnumerable moves the boundary. Everything after it runs in your process.
var top = db.Orders
    .AsEnumerable()                       // SELECT * FROM Orders - all of it
    .Where(o => o.Status == Status.Placed && o.Total > 100m)
    .OrderByDescending(o => o.Total)
    .Take(20)
    .ToList();
Rows fetched   Bytes over the wire   Sorted where
-------------  --------------------  -----------------------
A          20                 ~1 KB  database, using an index
B   4,200,000                ~900 MB  your process, full sort in memory

Nothing throws. The result is identical. The second version reads the entire table into memory, sorts four million objects to keep twenty, and works perfectly against the developer's seed data of thirty rows.

AsEnumerable is rarely written deliberately; the boundary usually moves by accident. A repository method declared to return IEnumerable<Order> erases the IQueryable, so every filter a caller adds is client-side. A foreach that starts before the filtering does the same. And calling a local method inside a predicate — Where(o => IsInteresting(o)) — cannot be translated, so EF Core either throws or, in older versions, silently evaluated it on the client, which was the more expensive outcome.

The habit that catches all of these is having SQL logging on in development and reading the statement count and shape for any new endpoint. The type of the variable tells you where the code will run, and it is worth being deliberate about it in every signature.

Which LINQ operators materialise, and which stay lazy?

The streaming operators — Where, Select, Take, Skip, SelectMany, Cast, Concat — yield elements as they go and hold nothing. The buffering ones must consume the entire source before producing their first element: OrderBy and ThenBy obviously, since sorting needs everything, plus GroupBy, Reverse, Join, Distinct and ToLookup. And the terminal operators execute immediately and return data rather than a query: ToList, ToArray, ToDictionary, Count, Sum, First, Any, Single. Knowing which is which matters for memory as much as for timing — an OrderBy in the middle of a pipeline over a large source materialises the whole thing regardless of a later Take, which is why pushing the ordering into the database matters.

What is the difference between `First`, `FirstOrDefault` and `Single`?

First returns the first match and throws if there are none. FirstOrDefault returns default(T) instead of throwing, which for a reference type means null and for a value type means zero — a distinction that has hidden plenty of bugs, because a FirstOrDefault over IQueryable<decimal> returning 0m looks like a legitimate value. Single asserts that exactly one match exists and throws if there are none or more than one, so it costs an extra row fetch to verify uniqueness. Choose by intent: Single when the predicate is a unique key and two matches would mean corrupt data, First when you have ordered the results and want the top one, and the OrDefault variants only where absence is a normal outcome you then handle.

Async and concurrency

What does `async`/`await` actually compile into?

A state machine. The compiler rewrites the method body into a struct implementing IAsyncStateMachine with a MoveNext method, hoisting every local that survives an await into a field, and assigning each await point a state number. Calling the method runs it synchronously up to the first await that is not already complete, at which point it registers a continuation and returns an incomplete Task to the caller — the thread is released, not blocked. When the awaited operation completes, MoveNext is invoked again, jumps to the saved state, and carries on. That is the whole mechanism, and it explains most of the surprising behaviour: await is not "run this in the background", it is "return here later".

Show me the async state machine.

The transformation is what makes the rest of async behaviour predictable rather than magical.

public async Task<int> GetTotalAsync(int id)
{
    var order = await _repo.FindAsync(id);   // await point 1
    var rate  = await _fx.RateAsync(order.Currency);   // await point 2
    return (int)(order.Total * rate);
}
flowchart TD
    A[Caller invokes GetTotalAsync] --> B[State machine created, state -1]
    B --> C[Runs synchronously to the first await]
    C --> D{Is the awaited task already complete}
    D -- Yes --> E[Continue inline on the same thread, no suspension]
    D -- No --> F[Return an incomplete Task to the caller immediately]
    F --> G[Continuation registered, state saved as 0]
    G --> H[Task completes, MoveNext resumes at state 0]
    H --> I[Result or exception published on the returned Task]

Four things fall out of this diagram, and each is a question an interviewer asks. The Yes branch is why an async method whose awaits all complete synchronously never yields at all — which is the entire justification for ValueTask, since the Task allocated on that path is pure waste.

The No branch is why an async method returns to its caller before finishing, and therefore why the caller must await it. Fire it and forget it and the continuation still runs, but nobody observes the result or the exception.

The saved-state hoisting is why a ref struct such as Span<T> cannot live across an await: the locals become fields on a heap object, and a stack reference cannot legally be stored there.

And the last node is why exceptions behave the way they do. A fault does not propagate up a stack — there is no stack any more — it is captured onto the returned Task and rethrown when that task is awaited. Never awaiting means never seeing it, which is exactly the async void bug.

What does `ConfigureAwait(false)` do?

By default, an await captures the current SynchronizationContext — or the current TaskScheduler — and resumes the continuation on it. ConfigureAwait(false) says you do not need that, so the continuation runs on whatever thread pool thread completed the operation. Historically this mattered enormously: classic ASP.NET and WinForms both had a context, so a library that captured it forced every continuation back through a single-threaded pump, costing throughput and enabling the deadlock where blocking the context thread prevented the continuation from ever running. ASP.NET Core has no SynchronizationContext, so the deadlock is gone and the throughput difference is small. The rule that survives is that library code should still use ConfigureAwait(false) on every await, because a library cannot know whether its caller has a context.

Show me blocking on `.Result` deadlocking and starving the pool.

Two distinct failures come from the same mistake, and which one you get depends on the framework.

// The classic deadlock, on any framework with a SynchronizationContext -
// classic ASP.NET, WPF, WinForms.
public ActionResult Index()
{
    var data = LoadAsync().Result;   // blocks the single context thread
    return View(data);
}

private async Task<string> LoadAsync()
{
    var response = await _http.GetStringAsync(url);
    // Wants to resume on the captured context. That thread is blocked
    // in .Result, waiting for this method. Neither ever proceeds.
    return response;
}

ASP.NET Core removed the context, so this no longer deadlocks — it does something subtler and harder to diagnose instead.

sequenceDiagram
    participant R as Incoming requests
    participant P as Thread pool
    participant I as Injection algorithm
    R->>P: 200 concurrent handlers each blocking on .Result
    P->>P: all available threads parked, none doing work
    I->>P: injects roughly one or two threads per second
    Note over P: throughput collapses to the injection rate
    R->>R: callers time out long before threads free up

The arithmetic is the point. With MinThreads at the processor count — say 8 — the first eight requests occupy every available thread and block. The pool's hill-climbing algorithm then adds threads at roughly one or two per second, so serving 200 concurrent requests takes upwards of a hundred seconds of thread injection. Meanwhile CPU sits near zero, no exception is thrown, and every dashboard shows a healthy process with rising latency.

The symptom pattern is worth memorising because it is diagnostic: latency climbing, throughput falling, CPU low, and ThreadPool.ThreadCount rising steadily. Low CPU with high latency almost always means threads are blocked rather than working.

The fix is to make the whole path async — await LoadAsync() — which requires changing every signature up to the entry point, and that is the real reason the bug exists. .Result, .Wait() and GetAwaiter().GetResult() are all the same mistake; the third is only preferable in the rare legitimate case because it does not wrap the exception in an AggregateException.

What is the difference between `Task` and `ValueTask`?

Task is a class, so every asynchronous method returning one allocates — even when the result was already available and no suspension occurred. ValueTask is a struct that either wraps a completed result directly, with no allocation, or wraps a Task when the operation genuinely went asynchronous. That makes it worthwhile precisely where the common path completes synchronously: a cache hit, a buffered stream read, a lookup that usually finds its value in memory. The cost is a stricter contract. A ValueTask may be awaited only once, may not be awaited concurrently, and its result may not be read before completion, because the underlying object can be recycled. Task has none of those restrictions, which is why it remains the default for public APIs.

Show me a `ValueTask` used correctly.

The case that justifies it is a method whose fast path returns without ever suspending.

public sealed class ProductCache
{
    private readonly Dictionary<int, Product> _hot = new();

    // Roughly 95% of calls hit the dictionary and never suspend. With Task,
    // every one of those allocates a Task object for nothing.
    public ValueTask<Product> GetAsync(int id)
    {
        if (_hot.TryGetValue(id, out var cached))
        {
            return new ValueTask<Product>(cached);   // zero allocation
        }
        return new ValueTask<Product>(LoadAndCacheAsync(id));   // wraps a Task
    }

    private async Task<Product> LoadAndCacheAsync(int id)
    {
        var product = await _db.Products.SingleAsync(p => p.Id == id);
        _hot[id] = product;
        return product;
    }
}
Scenario                         Allocated per call
-------------------------------  ------------------
Task<Product>, cache hit         72 B   the Task itself, pure waste
ValueTask<Product>, cache hit     0 B
ValueTask<Product>, cache miss   72 B   same as Task - no saving, no loss

The saving exists only on the synchronous path. If a method always awaits something real, ValueTask buys nothing and costs you the contract, so the guidance is to reach for it when you can point at a fast path and a call rate that makes the allocation matter.

Now the rule that gets asked about. Await it exactly once:

var task = cache.GetAsync(42);
var a = await task;
var b = await task;        // undefined behaviour: the instance may be recycled

// If you need it twice, or need to await it concurrently, convert first.
var real = cache.GetAsync(42).AsTask();
await Task.WhenAll(real, somethingElse);

Task is cached and safe to await repeatedly; ValueTask may be backed by a pooled IValueTaskSource that is reset and handed to the next caller as soon as your first await completes, so a second await reads someone else's result or throws. Task.WhenAll and Task.WhenAny both need a Task, so call AsTask for those. That asymmetry is the whole reason Task stays the default and ValueTask is an optimisation you apply deliberately after measuring.

Why is `async void` a bug?

Because there is nothing to await, so the caller cannot know when the method finished and cannot observe its outcome. An exception thrown inside it is not captured onto a task — there is no task — so it is raised on the SynchronizationContext or, in its absence, becomes an unhandled exception that crashes the process. Neither behaviour is what the author wanted: the try/catch around the call site does not fire, because the method returned at its first await, long before the failure occurred. It also cannot be tested, since a test has no way to wait for it. The single legitimate use is an event handler whose signature is fixed by a framework, and even there the whole body belongs in a try/catch.

Show me `async void` swallowing an exception.

The exception is raised, the catch does not fire, and the calling code continues as though everything worked.

public sealed class OrderProcessor
{
    // Looks like a normal method. Returns void, so the caller cannot await it.
    private async void PublishAsync(Order order)
    {
        await Task.Delay(50);
        throw new InvalidOperationException("broker unreachable");
    }

    public void Place(Order order)
    {
        try
        {
            PublishAsync(order);       // returns at the first await
            _repo.Save(order);
            Console.WriteLine("placed");
        }
        catch (Exception ex)
        {
            // Never reached. The throw happens 50ms later, on a different
            // stack, with this frame long gone.
            Console.WriteLine($"failed: {ex.Message}");
        }
    }
}
Output:
  placed
  <50ms later>
  Unhandled exception. System.InvalidOperationException: broker unreachable
  -> process terminates, or in ASP.NET Core the request has already returned 200

The order is saved and the caller is told it succeeded. That is worse than a crash, because the system is now inconsistent and the log entry — if the process survives to write one — is unattached to any request.

The fix is to return Task and await it, which propagates the failure into the caller's try/catch where it was expected:

private async Task PublishAsync(Order order) { /* same body */ }

public async Task PlaceAsync(Order order)
{
    await PublishAsync(order);         // exception surfaces here
    await _repo.SaveAsync(order);
}

Two related shapes deserve the same suspicion. An async lambda passed to a parameter typed Action is async void with no visible keyword — list.ForEach(async x => await F(x)) is the canonical example, and it returns immediately having started everything and awaited nothing. And a Task that is created and never awaited is the same failure in slower motion: the exception is captured onto a task nobody observes, so it disappears silently rather than crashing.

The detail candidates leave out is how to catch this in review. async void and unawaited tasks both produce compiler or analyser warnings — CS4014 and VSTHRD100 among them — and both are worth promoting to build errors, because they cannot be found by testing.

How should a `CancellationToken` travel through a call chain?

Down every level, as an explicit parameter, all the way to the outermost I/O call. That is the whole discipline, and it is unglamorous: a token that stops being passed at some intermediate method means the work below that point keeps running after the caller has given up, which is exactly the load you least want during an incident. ASP.NET Core hands you HttpContext.RequestAborted, which fires when the client disconnects, so accepting a token on a controller action is free. Two details are worth knowing: cancellation is cooperative, so nothing happens unless somebody checks the token or passes it to an API that does; and OperationCanceledException is a normal outcome rather than a failure, so it should not be logged as an error or counted in your error rate.

When do you use `lock` and when `Interlocked`?

lock gives mutual exclusion over a region, so use it when several operations must appear atomic together — reading two fields and deciding, or updating two collections consistently. Interlocked performs a single atomic operation on a single location using a hardware compare-and-swap: increment, exchange, compare-and-exchange. It is faster because there is no contention on a monitor and no possibility of deadlock, but atomicity covers exactly one variable, so as soon as a decision spans two values you are back to a lock. Two rules matter in practice: never lock on something publicly reachable such as this or a Type, because unrelated code can then lock the same object; and never await inside a lock, which the compiler forbids, because a monitor is owned by a thread and a continuation may resume on another.

When is `ConcurrentDictionary` the right answer?

When several threads read and write the same map and the operations you need are available atomically. Its real value over a Dictionary behind a lock is not the per-bucket locking but the compound operations: TryAdd, AddOrUpdate and GetOrAdd do check-then-act in one step, which is the race a hand-written version usually gets wrong. Two caveats to state before being asked. GetOrAdd does not guarantee the factory runs once — two threads can both invoke it and one result is discarded — so an expensive or side-effecting factory needs a Lazy<T> as the value instead. And Count and enumeration take snapshots across the whole structure, so calling them frequently defeats the point; enumeration also sees a moving target rather than a consistent view.

What is thread-pool starvation, and how do you recognise it?

It is the pool having no available threads because the existing ones are blocked rather than working, so queued work items wait for the injection algorithm to add threads at roughly one or two per second. The recognisable signature is high latency with low CPU: the process looks idle and responds to nothing, which distinguishes it from ordinary overload where CPU is saturated. The causes are all forms of blocking on a pooled thread — .Result, .Wait(), a synchronous lock held across I/O, a SemaphoreSlim.Wait rather than WaitAsync, or Parallel.For over blocking work. Raising MinThreads masks it and is a legitimate stopgap; the fix is to make the path asynchronous throughout, and the diagnostic tools are the ThreadPool counters plus a dump showing threads parked in ManualResetEventSlim.Wait.

Exceptions and disposal

What does `using` actually guarantee?

That Dispose is called when the scope exits, on every path — normal completion, early return, or an exception — because the compiler emits a try/finally around it. That is what makes it the correct way to release anything holding an unmanaged or scarce resource: a file handle, a socket, a database connection, a lock. The declaration form, using var conn = ..., disposes at the end of the enclosing block rather than a nested one, which is usually what you want and occasionally hides the lifetime. What using does not do is make disposal correct: if Dispose itself throws while an exception is already propagating, the original is replaced by the disposal failure, so the problem you needed to see is the one you lose.

Why do you rarely write a finaliser?

Because a finaliser is a safety net for unmanaged resources, and almost nothing you write owns one directly. The costs are real: an object with a finaliser is put on a queue at allocation, survives at least one extra collection, is promoted a generation, and is then processed by a single dedicated thread, so one slow finaliser delays every other. Worse, ordering is not guaranteed, so a finaliser cannot safely touch other managed objects — they may already have been finalised. If you genuinely wrap a handle, use SafeHandle, which the runtime finalises correctly for you, and implement IDisposable for the deterministic path. The full Dispose(bool disposing) pattern exists to serve both paths, and is unnecessary when there is no unmanaged resource.

What is `IAsyncDisposable` for?

For cleanup that itself performs I/O, which under IDisposable would have to block. Flushing a buffered stream to disk, sending a final frame on a socket, committing or rolling back a transaction — all are asynchronous operations, and doing them inside a synchronous Dispose blocks a pooled thread at exactly the moment a request is finishing. await using calls DisposeAsync in a finally, with the same guarantees. Two things to know: a type may implement both, in which case await using prefers the async path and a plain using silently takes the blocking one; and ASP.NET Core's DI container will call DisposeAsync on scoped services that implement it, but throws if a singleton implements only IAsyncDisposable and the container is disposed synchronously.

What is the difference between `throw` and `throw ex`?

throw rethrows the current exception preserving its original stack trace, so the frame where the failure actually happened is still in the trace. throw ex throws the same object as though it originated at that line, resetting the trace, so everything below your catch block is erased and the diagnosis starts from your own error-handling code. That single character is the difference between a stack trace naming the SQL constraint violation and one naming your service layer. When you genuinely want to add context, wrap rather than replace: throw new OrderException("could not place order", ex) keeps the original as InnerException. ExceptionDispatchInfo.Capture(ex).Throw() is the tool for rethrowing an exception captured elsewhere while preserving its trace.

Show me a disposal bug that `using` makes worse.

HttpClient implements IDisposable, so wrapping it in using looks like textbook correctness. It is the best-known resource bug in .NET.

// Wrong, and it looks right.
public async Task<string> GetAsync(string url)
{
    using var client = new HttpClient();
    return await client.GetStringAsync(url);
}
Symptom under load:
  SocketException: Only one usage of each socket address is normally permitted

Why:
  Dispose closes the connection, but the underlying socket enters TIME_WAIT
  for around 240 seconds on Windows before the OS releases the port.
  At 100 requests/second: 100 x 240 = 24,000 ports held simultaneously,
  against an ephemeral range of roughly 16,000. Exhausted in under 3 minutes.

So the disposal is what causes the failure. The class is designed to be long-lived and shared — it is thread-safe for concurrent requests, and its handler pools connections precisely so they are reused.

The opposite extreme has its own bug, which is why the answer is not simply "use a static instance": a single HttpClient held forever caches DNS resolution inside its handler, so it keeps calling an IP address that has been rotated away. IHttpClientFactory resolves both by pooling handlers and recycling them on a timer:

// Registration
builder.Services.AddHttpClient<PricingClient>(c => c.BaseAddress = new Uri(url));

// Injected, used per request. Disposing this instance is harmless -
// the pooled handler underneath outlives it.
public PricingClient(HttpClient client) => _client = client;

The general lesson is more useful than the specific fix: IDisposable tells you an object owns something scarce, not that it should be short-lived. The question to ask of any disposable is what it owns and what pools it — connections, handlers and DbContexts each answer differently, which is why DbContext should be scoped per request and HttpClient should not be created per call.

ASP.NET Core

What are the three DI lifetimes?

Singleton means one instance for the application's lifetime, created on first resolution and shared by every request and every thread — so it must be thread-safe and must hold no per-request state. Scoped means one instance per scope, which in a web application is one per HTTP request, making it the right lifetime for anything carrying request context, most obviously a DbContext. Transient means a new instance every time it is resolved, which is the safest default for cheap stateless services and is wasteful for anything expensive to construct. The choices interact, and that is where the bugs live: a service can only safely depend on something with an equal or longer lifetime, because otherwise it captures an instance that was supposed to have been discarded.

Show me the lifetimes as a table, and the captive dependency.

The rule is one line, and violating it produces a bug that survives every test because it needs a second request to appear.

lifetime    instances                 safe to depend on        typical use
----------  ------------------------  -----------------------  --------------------
Singleton   1 per application         Singleton only           caches, HttpClient
                                                               factories, options
Scoped      1 per HTTP request        Scoped, Singleton        DbContext, unit of
                                                               work, request user
Transient   1 per resolution          all three                stateless helpers,
                                                               cheap services

Rule: a service may depend only on lifetimes at least as long as its own.

The violation has a name — the captive dependency — and it is silent:

public sealed class ReportCache            // registered as a Singleton
{
    private readonly AppDbContext _db;     // registered as Scoped

    public ReportCache(AppDbContext db) => _db = db;   // captured forever
}
Request 1  container creates ReportCache, injects DbContext #1, scope ends,
           DbContext #1 is disposed
Request 2  ReportCache still holds DbContext #1
           -> ObjectDisposedException: Cannot access a disposed context

Or worse, if nothing disposed it: one DbContext shared by every request,
change-tracking every entity ever loaded, not thread-safe, growing forever.

Both outcomes are bad and the second is worse, because it does not throw. A DbContext accumulates tracked entities, so a captured one becomes a memory leak and a source of stale reads, and concurrent use produces corruption rather than an exception.

The fix is to inject a factory rather than the instance, so the scope is created where the work happens:

public ReportCache(IServiceScopeFactory scopes) => _scopes = scopes;

public async Task<Report> BuildAsync()
{
    using var scope = _scopes.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    return await BuildFrom(db);
}

The detail worth volunteering is that this class of bug is detectable at start-up: ValidateScopes, which is on by default in the Development environment, makes the container throw when a singleton resolves a scoped service. Running with it enabled in a test turns a production ObjectDisposedException into a failing build.

Why does middleware order matter?

Because the pipeline is a nested sequence of delegates, each calling the next and then running its own code on the way back out, so registration order determines both what runs before the endpoint and what wraps it. The consequences are concrete rather than stylistic: exception handling must be first or it cannot catch what comes after it; authentication must precede authorisation because the latter needs the identity the former established; routing must precede authorisation for endpoint-specific policies to be known; and static files placed early short-circuit before authentication runs, which is either a performance win or an access-control hole depending on what is in the folder. Nothing warns you about a wrong order — the application starts and behaves subtly incorrectly.

Show me the middleware pipeline.

Each component calls the next and then resumes, so the request goes down and the response comes back up through the same layers in reverse.

flowchart TD
    A[Request arrives] --> B[ExceptionHandler wraps everything below]
    B --> C[HSTS and HTTPS redirection]
    C --> D[Static files - may short-circuit and return]
    D --> E[Routing selects the endpoint]
    E --> F[Authentication establishes the identity]
    F --> G[Authorization checks the endpoint policy]
    G --> H[Endpoint runs, then the response unwinds upward]

Read the order as a set of dependencies rather than a convention. Exception handling is first because it can only catch failures from components it wrapped; put logging above it and you log requests, put it above logging and unhandled exceptions bypass your log. Routing sits above authorisation because [Authorize] is metadata on an endpoint, and the endpoint is not known until routing has run — this is why authorisation silently allowed everything in early versions when the two were transposed.

The short-circuit on static files is the security-relevant one:

// Wrong: files under wwwroot are served before anyone is authenticated.
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();

// Right for public assets, and if any asset needs protecting it does not
// belong in wwwroot at all - serve it from an authorised endpoint.
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

The bidirectional nature is the part people forget. Code before await next() runs on the way in; code after it runs on the way out, when the response headers may already have been sent — which is why attempting to add a header after the endpoint has written the body throws, and why response-modifying middleware needs OnStarting rather than a line after next.

How does model binding decide where a value comes from?

By convention, then by attribute. For a simple type — a string, a number, a date — the binder looks in route values, then the query string, then form fields, matching by parameter name. For a complex type in a controller with [ApiController], the default flips: it is assumed to come from the request body as JSON, because that is almost always the intent. You override either with an explicit source attribute: [FromRoute], [FromQuery], [FromBody], [FromForm], [FromHeader], [FromServices]. Only one parameter may be bound from the body, since the stream is read once. The failure worth recognising is a parameter that is silently null or 0 because the name did not match anything — binding does not fail, it simply produces the default.

Where does validation actually happen?

During model binding, before your action method runs. The binder populates the model, then runs the validation attributes on it and records every failure in ModelState. With [ApiController] on the class, a failed validation short-circuits automatically into a 400 with a ValidationProblemDetails body, so your action is never entered; without it, the action runs regardless and must check ModelState.IsValid itself — which is the source of the bug where validation attributes appear to do nothing. Attribute validation covers shape: required, length, range, pattern. It cannot express a rule needing another service or the database, such as "this email is not already registered", so those belong in the handler or in a validation library that supports injected dependencies.

What does `IOptions` give you over reading configuration directly?

A typed, validated object rather than string lookups scattered through the code, bound once from whatever providers the configuration system layered together. The three variants differ in when they read: IOptions<T> is a singleton bound once at start-up, IOptionsSnapshot<T> is scoped and re-reads per request so file changes are picked up, and IOptionsMonitor<T> is a singleton exposing a change callback, which is the only one usable inside another singleton. The part worth volunteering is validation. ValidateDataAnnotations plus ValidateOnStart makes a missing or malformed setting fail the process at start-up rather than throwing on the first request that touches the feature, which converts a 3 a.m. incident into a failed deployment.

What does EF Core change tracking do, and what does it cost?

It records a snapshot of every entity it materialises, and on SaveChanges compares current values against that snapshot to generate the necessary UPDATE statements. That is why mutating a loaded entity is persisted without you calling anything resembling Update, and why an incidental normalisation applied to a tracked object ends up in the database. It also acts as an identity map, so the same primary key returns the same instance within a context. The cost is a second copy of every entity plus a comparison pass over all of them at save time, which is why a read-only query over fifty thousand rows should call AsNoTracking — and why projecting to a DTO with Select is better still, since it fetches only the columns you use and tracks nothing.

Show me EF Core generating an N+1, and the `Include` fix.

One query for the parents, then one per parent for a navigation property. It scales with data rather than with code, so it passes every test against a small fixture.

var orders = await db.Orders
    .Where(o => o.Status == Status.Placed)
    .ToListAsync();                              // 1 query

foreach (var order in orders)
{
    // Lazy loading proxies enabled: one query per access.
    Console.WriteLine(order.Customer.Name);      // N queries
}
-- What the log shows for 20 orders. This is the only symptom.
SELECT Id, CustomerId, Total FROM Orders WHERE Status = 1;
SELECT Id, Name FROM Customers WHERE Id = 41;
SELECT Id, Name FROM Customers WHERE Id = 87;
SELECT Id, Name FROM Customers WHERE Id = 12;
-- ... seventeen more, one round trip each

Twenty-one round trips instead of one. At 3ms of network latency each, that is 63ms of pure waiting for a query the database could answer in 2ms.

// The fix: state the graph you need, and EF Core joins it in one statement.
var orders = await db.Orders
    .Where(o => o.Status == Status.Placed)
    .Include(o => o.Customer)
    .ToListAsync();                              // 1 query, joined

// Better still where you only need a few columns: project, and neither
// Include nor change tracking is required at all.
var summaries = await db.Orders
    .Where(o => o.Status == Status.Placed)
    .Select(o => new OrderSummary(o.Id, o.Total, o.Customer.Name))
    .ToListAsync();

Two caveats that separate a real answer from a memorised one. Include on a collection duplicates the parent columns across every child row, so including two collections produces a cartesian product — a hundred rows for ten lines and ten payments — which is slower than the N+1 it replaced; AsSplitQuery issues one statement per collection instead, at the cost of losing a single consistent snapshot. And Include combined with pagination on a collection cannot page in the database, so it fetches everything and pages in memory.

The habit that prevents all of it is logging SQL in development and reading the statement count for each endpoint. Disabling lazy loading entirely is the structural version of the same discipline: without proxies, a missing Include throws a null reference at development time rather than quietly issuing a thousand queries in production.

Performance

What is `Span<T>`, and what problem does it solve?

Span<T> is a ref struct representing a contiguous region of memory — managed array, stack buffer, or unmanaged block — as a length and a reference, with no copy. It solves the problem that the natural way to work with part of an array or string was to make a smaller one: Substring, Split, Skip().Take() and Array.Copy all allocate. A span slices in constant time with zero allocation, so a parser can walk a buffer producing no garbage at all. The constraints come from the stack guarantee: it cannot be a field of a class, cannot be captured in a lambda, and cannot cross an await or a yield. Where you need those, Memory<T> is the heap-safe equivalent, convertible to a span at the point of use.

Show me `Span<T>` removing an allocation.

Parsing a delimited line is the standard example because the allocating version is the one everybody writes first.

// Before: three allocations per line, plus the substrings inside them.
static (int id, decimal amount) ParseSlow(string line)
{
    var parts = line.Split(',');           // string[] + one string per field
    return (int.Parse(parts[0]), decimal.Parse(parts[1]));
}

// After: no allocation at all. Slicing a span is a length and an offset.
static (int id, decimal amount) ParseFast(ReadOnlySpan<char> line)
{
    int comma = line.IndexOf(',');
    var id     = int.Parse(line[..comma]);
    var amount = decimal.Parse(line[(comma + 1)..]);
    return (id, amount);
}
Method       Mean       Allocated    Gen0 collections per 1M calls
-----------  ---------  -----------  -----------------------------
ParseSlow    118 ns     144 B                                  ~34
ParseFast     41 ns       0 B                                    0

Three times faster, and the reason is mostly not the parsing — it is that nothing is allocated, so nothing has to be collected. At a million lines the allocating version generates 144 MB of generation 0 garbage to produce two numbers per line.

The overload resolution detail is what makes this practical: int.Parse and friends all accept ReadOnlySpan<char>, and a string converts implicitly, so adopting spans usually means changing a signature rather than rewriting logic. string.AsSpan() gets you in explicitly.

Where it stops working is the async boundary:

// Does not compile: a ref struct cannot be hoisted into the state machine.
// async Task ProcessAsync(ReadOnlySpan<char> line) { await ...; }

// Use Memory<T> across the await, and take a span inside the synchronous part.
async Task ProcessAsync(ReadOnlyMemory<char> line)
{
    await _sink.WriteAsync(line);
    var parsed = ParseFast(line.Span);      // span taken locally
}

The judgement to show is restraint. Spans belong in hot paths where you have measured an allocation problem — parsers, serialisers, buffer handling. Applying them to code that runs once per request trades readability for nothing, and the compiler restrictions will fight you the whole way.

What is `stackalloc`, and when is it safe?

It allocates an array-shaped buffer in the current stack frame, so it costs nothing to allocate and nothing to free — the frame's teardown reclaims it. In modern C# it is assigned to a Span<T>, which makes it safe to use because the span cannot escape the frame. The rule is that the size must be small and bounded: a stack is typically one megabyte, and stackalloc with a caller-controlled length is a stack overflow waiting to happen, which is a process-killing failure with no catch. The idiomatic pattern is a threshold — stack-allocate below 256 or 512 elements, rent from ArrayPool<T> above it — and never to stackalloc inside a loop, since the allocations accumulate for the whole method rather than per iteration.

What does `ArrayPool<T>` give you?

Reuse of large arrays, so a hot path that needs a 64 KB buffer per operation does not allocate one each time and push it onto the large object heap. You rent an array of at least the requested length, use it, and return it. Three details are the actual answer. The array you get back may be larger than you asked for, so you must track the length you are using rather than relying on Length. It is not cleared unless you ask, so it contains a previous caller's data — a correctness and potentially a disclosure problem. And a rented array that is never returned is simply a slower allocation, while one returned twice corrupts the pool, so the return belongs in a finally.

What is the difference between server and workstation GC?

Workstation GC uses one collection thread and is tuned for responsiveness on a machine shared with a user interface. Server GC allocates a separate heap and collection thread per core, so collections run in parallel and allocation contention between threads drops sharply, at the cost of using more memory and more CPU during a collection. It is the default for ASP.NET Core and is almost always right for a server, but not unconditionally: in a container limited to one or two cores, server GC's per-core heaps and its larger budgets can consume noticeably more memory than the limit allows, and switching to workstation GC is a legitimate fix. Concurrent — background — collection is a separate switch, letting generation 2 collection overlap with execution.

How do you actually measure .NET performance?

With BenchmarkDotNet for micro-benchmarks, because it handles the things a stopwatch loop gets wrong: warming up so you measure tier-1 code rather than the JIT's first attempt, running enough iterations to get a statistically meaningful distribution, isolating each benchmark in its own process, and reporting allocations alongside time via a memory diagnoser. For a running service the tools are different — dotnet-counters for live metrics including allocation rate and thread-pool queue depth, dotnet-trace for a sampled CPU profile, and dotnet-gcdump for what is retained. The discipline underneath both is that a number without a baseline means nothing, and that allocation is often the more actionable metric because it appears in your code while the resulting collection time does not.

Interview traps

Why did adding `ToList()` fix the exception and slow everything down?

Because ToList executes the query and copies every row into memory, which resolves whatever the deferred version was tripping over — a disposed DbContext, a second operation on the same connection, an untranslatable expression — while also fetching everything the database was about to filter. The exception disappears and the endpoint now reads a table it used to page. That is the diagnostic value of the symptom: ToList in the middle of a pipeline usually marks the place where somebody hit a translation or lifetime problem and made it go away. The correct fix is almost always further up — keep the composition inside the IQueryable, keep the context alive for the whole query, and materialise once at the end.

Why does returning `IEnumerable<T>` from a repository cause trouble?

Because the caller receives something that has not run yet, and the caller has no way to know that. Two failures follow. The query executes after the repository's DbContext has been disposed — a scope ended, a using closed — producing an ObjectDisposedException from code that looks entirely innocent. And any filtering the caller adds is applied in memory, because the static type has erased the IQueryable, so a Where that used to be a SQL predicate is now a full table read. Pick a side deliberately: return IReadOnlyList<T> when the repository owns the query, or return IQueryable<T> and accept that composition is the caller's job and the context must outlive them.

Why did my `async` method not run anything concurrently?

Because await means "wait here", and awaiting inside a loop serialises everything. Ten calls each taking 100ms take a second, not 100ms, and the code looks concurrent because it is full of async keywords. Concurrency comes from starting the operations before awaiting any of them — collecting the tasks, then await Task.WhenAll(tasks) — which is a deliberate act rather than a property of the syntax. Two cautions come with it: unbounded fan-out will happily open two thousand connections against a database sized for two hundred, so bound it with a SemaphoreSlim or Parallel.ForEachAsync with MaxDegreeOfParallelism. And a DbContext does not support concurrent operations, so parallel queries need a context each.

Why is a nullable value type not the same as a nullable reference type?

int? is Nullable<int>, a real struct with a value and a HasValue flag, so the nullability exists at runtime and is enforced: reading .Value when empty throws InvalidOperationException, not NullReferenceException. string? is the same string with a compile-time annotation and nothing at runtime at all. That asymmetry produces several traps. Boxing an empty int? produces a genuine null reference rather than a boxed Nullable<int>. Comparisons involving an empty nullable follow three-valued logic, so x > 5 is false when x is null and so is x <= 5. And in SQL translation, EF Core must generate IS NULL rather than = NULL, which is why an equality filter on a nullable column behaves differently from the in-memory version.

What single follow-up separates candidates most reliably?

"Where does this allocate, and where does it block?" It cannot be prepared generically, because the answer depends on the code in front of you, and it requires having profiled something rather than read about it. A strong answer walks the path naming the allocations — the boxed argument, the LINQ closure, the Task on a synchronous fast path, the substring per line — and then names every point where a thread waits, distinguishing a real await from a .Result, and says what the thread is doing during each. A weak answer talks about the garbage collector being fast and async being non-blocking, both of which are true and neither of which is an answer, because the question is about this code rather than about the runtime's reputation.