C# Interview Questions and Answers

Last updated:

Check out 60 of the most common C# interview questions, then take an AI-powered practice interview

.NETASP.NETUnityAzureXamarin
60+
Questions
24
Basic
24
Intermediate
12
Advanced
Q1

Where do value types and reference types actually live in memory in C#?

BasicMemory Model

Answer

The honest answer, and the one that separates candidates who memorised 'stack vs heap' from those who understand the CLR, is: it depends on where the variable is declared, not on the type alone. A struct declared as a local variable typically lives on the stack (or in a register after JIT optimisation). But a struct that is a field of a class lives on the heap inside that object.

A struct captured by a lambda closure gets hoisted into a compiler-generated class and moves to the heap. A struct in a List<T> lives in the list's underlying heap array. Reference type variables hold a reference; the object they point to is always heap-allocated.

The practical consequences: assigning a struct copies the whole value (so mutating a copy does nothing to the original, the classic mutable-struct bug), assigning a class copies only the reference (so two variables see the same mutation), and boxing occurs when a value type is converted to object or an interface type, allocating a heap copy. Interviewers follow up with: what happens when you store an int in an ArrayList versus a List<int> (boxing per element versus none), and why large structs passed by value hurt performance (each call copies every byte, which is why the 'in' modifier and readonly struct exist). If you can explain that 'value types go on the stack' is a simplification the interviewer is often fishing for, you have already passed this question.

Key Points

  • Storage depends on the variable's home: locals may be stack, fields follow their containing object
  • Struct assignment copies the value; class assignment copies the reference
  • Boxing allocates a heap copy when a value type becomes object or an interface
  • Closures hoist captured locals (including structs) to the heap
  • Large structs passed by value copy every byte per call
Q2

Why are C# strings immutable, and when should you use StringBuilder or string.Create instead of concatenation?

BasicStrings

Answer

System.String is immutable: every 'modification' (Replace, ToUpper, concatenation with +) allocates a brand-new string and copies the characters. Immutability enables safe sharing across threads without locks, string interning (compile-time literals are deduplicated in the intern pool), and reliable use as dictionary keys, since a key that could mutate would corrupt the hash bucket it lives in. The cost shows up in loops: building a string with += in a loop over n items is O(n²) in copied characters and produces n intermediate garbage strings that pressure Gen 0 collections.

StringBuilder maintains an internal growable char buffer, so appends are amortised O(1); call ToString() once at the end. For hot paths where you know the final length, string.Create(length, state, action) lets you write characters directly into the final string's buffer via a Span<char>, one allocation total, no intermediate copies. Interpolated strings ($"...") are fine for single expressions; since C# 10 the compiler lowers them to an interpolated string handler that avoids intermediate allocations in many cases, and in logging APIs a custom handler can skip the formatting work entirely when the log level is disabled. Interviewers often probe interning: string a = "abc" and string b = "abc" are reference-equal because both literals resolve to the same interned instance, but new string(...) or runtime-built strings are not interned unless you call string.Intern explicitly, which is why you compare strings with == (value comparison for string) or string.Equals with an explicit StringComparison, never ReferenceEquals.

using System.Text;

// Bad: O(n^2) copying, n intermediate strings
string csvBad = "";
foreach (var row in rows) csvBad += row + ",";

// Good: amortised O(1) appends
var sb = new StringBuilder(capacity: rows.Count * 16);
foreach (var row in rows) sb.Append(row).Append(',');
string csv = sb.ToString();

// Best for known length: write straight into the final buffer
string id = string.Create(8, 42, (span, seed) =>
{
    "ABCDEFGH".AsSpan().CopyTo(span);
});

// Comparison: always be explicit about culture
bool same = string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
💡 Pro Tip: If an interviewer asks why string is a reference type that behaves like a value type in comparisons, the answer is operator overloading: string overloads == to compare contents, not references.
Q3

What is the difference between ==, Equals(), and ReferenceEquals() in C#?

BasicFundamentals

Answer

ReferenceEquals(a, b) always answers one question: do these two references point at the same object? It cannot be overridden and always returns false for two distinct boxed value types, even with identical contents. Equals() is a virtual method on System.Object; by default it does reference comparison for classes and field-by-field comparison for structs (via reflection unless the struct overrides it, which is why you should override Equals and GetHashCode on any struct used as a dictionary key: the reflective default is slow).

The == operator is resolved at compile time based on the static types of the operands: for classes that do not overload it, == compiles to reference comparison; string and record types overload it to compare values. The classic trap: object x = "hello"; object y = new string("hello".ToCharArray()); here x == y is false because with object-typed operands the compiler picks reference ==, while x.Equals(y) is true because Equals dispatches virtually to string's value comparison. Records changed the everyday experience: a record class generates value-based Equals, GetHashCode, and == out of the box, comparing all positional and declared properties.

The contract interviewers expect you to state: if two objects are Equal they must return the same GetHashCode, otherwise Dictionary and HashSet silently misbehave (an object stored under one hash can never be found again if its hash-relevant fields mutate). That last point, mutating a key after inserting it, is a real production bug worth mentioning unprompted.

Key Points

  • == binds at compile time on static types; Equals dispatches virtually at runtime
  • ReferenceEquals is identity only and cannot be overridden
  • object-typed operands make == a reference comparison even for strings
  • Equal objects must produce equal hash codes or hashed collections break
  • Records auto-generate value equality including ==
Q4

class vs struct vs record vs record struct: how do you choose in modern C#?

BasicType Design

Answer

Four axes decide it: allocation behavior, copy semantics, equality semantics, and mutability intent. A class is heap-allocated with reference semantics and reference equality by default: the right choice for entities with identity (a User with an Id), services, and anything large or long-lived. A struct is a value type copied on assignment: right for small (guideline: under roughly 16-24 bytes), immutable, logically-single-value data like a coordinate, a money amount, or a range; declare it readonly struct to prevent defensive copies and accidental mutation.

A record class (C# 9) is a reference type with compiler-generated value equality, a with expression for non-destructive mutation, deconstruction, and a readable ToString: the default for DTOs, API contracts, configuration snapshots, and domain values that are compared by content. A record struct (C# 10) combines value-type storage with record conveniences; readonly record struct is the sweet spot for small immutable values because it also generates fast, reflection-free equality (a plain struct's default Equals can fall back to reflection). Gotchas interviewers fish for: records give you value equality but a record containing a List<T> compares the list by reference, so nested collections silently break equality; with expressions do a shallow copy; and mutable structs are almost always a bug because methods and foreach operate on copies. A strong closing answer: 'entities are classes, values are readonly record structs when small and record classes when large, and I never write a mutable struct.'

// Entity: identity matters, reference semantics
public class Order
{
    public Guid Id { get; init; }
    public List<OrderLine> Lines { get; } = [];
}

// Value object: small, immutable, compared by content
public readonly record struct Money(decimal Amount, string Currency)
{
    public Money Add(Money other) =>
        other.Currency == Currency
            ? this with { Amount = Amount + other.Amount }
            : throw new InvalidOperationException("currency mismatch");
}

// DTO: reference type, value equality, non-destructive updates
public record OrderSummary(Guid Id, Money Total, int LineCount);

var a = new Money(100m, "INR");
var b = a with { Amount = 150m };   // copy with one change
Console.WriteLine(a == new Money(100m, "INR")); // True: value equality
💡 Pro Tip: If asked when a struct hurts performance, mention defensive copies: calling a method on a non-readonly struct stored in a readonly field forces the compiler to copy the entire struct first.
Q5

How do nullable reference types work, and what do the ?, !, and #nullable annotations actually do at runtime?

BasicNull Safety

Answer

Nullable reference types (NRT), on by default in every project template since .NET 6 via <Nullable>enable</Nullable> in the .csproj, are a compile-time flow analysis, not a runtime guarantee. With NRT enabled, string means never-null and string? means may-be-null; the compiler tracks null states through your code and raises warnings like CS8602 (possible dereference of null) and CS8618 (non-nullable property uninitialised). Crucially, nothing changes in the IL for reference types: a caller compiled without NRT, reflection, deserializers, or EF Core materialisation can still hand you null in a string-typed slot, so public API boundaries still need real checks.

The tools: the null-forgiving operator ! (the 'dammit operator') suppresses the warning and does nothing at runtime, so every ! is a promise you might be lying about; ArgumentNullException.ThrowIfNull(arg) is the modern one-line guard; the required modifier (C# 11) forces callers to set a property in the object initializer, fixing the CS8618 warning on DTOs properly instead of with = null!; and attributes like [NotNullWhen(true)] and [MemberNotNull] teach the flow analysis about your helper methods, which is how string.IsNullOrEmpty tells the compiler the value is non-null in the false branch. Do not confuse string? with int?: Nullable<int> is a real struct wrapping a value and a HasValue flag, whereas string? is purely annotation. Interviewers like asking why a freshly enabled NRT codebase full of ! is worse than no NRT at all: the annotations become documentation that lies.

#nullable enable
public class UserService
{
    // C# 11 'required': caller must set it, no CS8618, no 'null!' hack
    public required string BaseUrl { get; init; }

    public string DisplayName(User? user)
    {
        ArgumentNullException.ThrowIfNull(user); // runtime guard at the boundary
        // flow analysis: user is non-null from here on
        return user.Name ?? "(anonymous)";
    }

    public bool TryGet(string key, [NotNullWhen(true)] out string? value)
    {
        value = _cache.TryGetValue(key, out var v) ? v : null;
        return value is not null;
    }
}

Key Points

  • NRT is compiler flow analysis; the runtime never enforces it
  • ! suppresses the warning only, it inserts no check
  • required (C# 11) is the correct fix for CS8618 on DTOs
  • Deserialization, reflection, and non-NRT callers can still pass null
  • string? is annotation; int? is a real Nullable<T> struct
Q6

Explain init-only setters, required members, and primary constructors as three ways to initialise objects.

BasicType Design

Answer

These three features, from C# 9, 11, and 12 respectively, cover different initialisation contracts. An init accessor (public string Name { get; init; }) allows assignment only during object construction: in the constructor, in an object initializer, or in a with expression. After that the property is immutable, which gives you readonly semantics with object-initializer ergonomics.

The gap: init alone does not force anyone to set the property, so a non-nullable init property still triggers CS8618. The required modifier (C# 11) closes that gap: a required property must be set in the object initializer or constructor, enforced at compile time with error CS9035 if omitted; a constructor that sets all required members can declare [SetsRequiredMembers] to relieve callers. Primary constructors (C# 12) put constructor parameters directly in the type header: public class OrderService(IOrderRepo repo, ILogger<OrderService> log).

The parameters are in scope throughout the class body and the compiler generates a capture field only if a parameter is used outside initialisers. Two behavioural differences from records interviewers check: on a non-record class, primary constructor parameters do NOT become public properties (in a record they do), and they are mutable within the class, which surprises people expecting readonly injected dependencies. The practical 2026 pattern in ASP.NET Core codebases: primary constructors for DI-heavy services to kill 10 lines of boilerplate per class, required init properties for request/response DTOs, and records when you also want value equality.

// C# 12 primary constructor: DI without boilerplate
public class InvoiceService(IInvoiceRepo repo, ILogger<InvoiceService> log)
{
    public async Task<Invoice> GetAsync(Guid id)
    {
        log.LogInformation("Fetching invoice {Id}", id);
        return await repo.FindAsync(id)
            ?? throw new KeyNotFoundException($"invoice {id}");
    }
}

// C# 11 required + C# 9 init: compiler-enforced immutable DTO
public class CreateInvoiceRequest
{
    public required Guid CustomerId { get; init; }
    public required decimal Amount { get; init; }
    public string? Notes { get; init; }
}

var req = new CreateInvoiceRequest
{
    CustomerId = Guid.NewGuid(),
    Amount = 4999m, // omitting CustomerId or Amount = compile error CS9035
};
Q7

What is the difference between var, dynamic, and object, and where does each resolve its members?

BasicType System

Answer

var is purely compile-time type inference: the compiler substitutes the exact static type of the initializer, so var list = new List<int>() is identical IL to the explicit declaration, with full IntelliSense and compile-time member checking. It changes nothing at runtime and cannot be used without an initializer. object is the root reference type: you can store anything in it, but you can only call the members of System.Object until you cast back, and casting a value type in and out causes boxing and unboxing. dynamic defers all member resolution to runtime via the DLR (Dynamic Language Runtime): d.AnyMethod() compiles even if no such method exists, and you get a RuntimeBinderException at the call site if it does not. Each dynamic call site is backed by a cached binder (call-site caching), so repeated calls with the same runtime types are faster than raw reflection, but still far slower than static dispatch, and you lose IntelliSense, refactoring safety, and compile-time errors entirely.

Legitimate dynamic uses are narrow: COM interop (Office automation), interop with dynamic JSON structures where you deliberately trade safety for brevity, and double-dispatch tricks. The interview follow-ups: why can var not be a field type (inference is scoped to method bodies for compile-speed and readability reasons), what does dynamic compile to (object plus call-site infrastructure in IL), and does assigning an int to dynamic box it (yes, same as object). A good closing line: var is a keystroke saver, object is a type-system root, dynamic is an escape hatch you should be able to justify in code review.

Key Points

  • var: compile-time inference, zero runtime difference
  • object: universal container, boxing for value types, members checked at compile time
  • dynamic: runtime binding via the DLR, RuntimeBinderException on bad calls
  • dynamic call sites are cached but still much slower than static calls
  • Valid dynamic uses: COM interop, deliberately dynamic payloads
Q8

What is boxing and unboxing, and how do you find and eliminate it in a hot path?

BasicPerformance

Answer

Boxing wraps a value type in a freshly heap-allocated object so it can be treated as object or an interface; unboxing casts it back, with an InvalidCastException if the exact type does not match (you cannot unbox a boxed int as long). Each box is a small allocation that pressures Gen 0 GC, and in tight loops this shows up directly in latency percentiles. Classic sources: pre-generic collections (ArrayList, Hashtable), string.Format and interpolation with value-type arguments hitting the object[] overload, calling non-generic interface methods on structs, using an enum as a Dictionary key without a comparer (the default EqualityComparer historically boxed enums; modern runtimes optimise this, but a custom IEqualityComparer<TEnum> was the traditional fix), the yield of casting a struct to IComparable, and older APIs like Console.WriteLine(object).

Elimination strategies: use generics so the type flows through without erasure (List<int> stores ints inline in the array, no boxes), constrain generic parameters (where T : IComparable<T> calls the interface method via constrained call without boxing), prefer interpolated string handlers and ToString() over object-typed format APIs, and implement IEquatable<T> on structs so Equals(T) is called instead of Equals(object). Detection: allocation profilers (dotnet-trace with the gc-verbose profile, Visual Studio's allocation tool, JetBrains dotMemory) show System.Int32 or your struct type appearing as heap objects; BenchmarkDotNet's [MemoryDiagnoser] prints allocated bytes per operation, and a nonzero number for pure-computation code is usually boxing. Interviewers often close with: why does calling GetType() on a struct always box? Because GetType is a non-virtual object method needing an object receiver, whereas typeof(T) or pattern matching avoids it.

using System.Collections;

int n = 42;
object boxed = n;          // boxing: heap allocation
int back = (int)boxed;      // unboxing: exact-type cast
// long bad = (long)boxed;  // InvalidCastException at runtime

// Hidden boxing #1: non-generic collections
var oldList = new ArrayList();
oldList.Add(1); // boxes every int

// Hidden boxing #2: struct through non-generic interface
IComparable c = 5; // boxes

// Fix: generics with constraints, no box
static T Max<T>(T a, T b) where T : IComparable<T>
    => a.CompareTo(b) >= 0 ? a : b; // constrained call, no boxing

// Fix: IEquatable<T> on structs used in collections
public readonly struct Sku(int id) : IEquatable<Sku>
{
    public int Id { get; } = id;
    public bool Equals(Sku other) => Id == other.Id;
    public override int GetHashCode() => Id;
}
Q9

IEnumerable<T> vs IQueryable<T>: what actually happens differently when you call Where() on each?

BasicLINQ

Answer

The signatures look like twins but the mechanics are opposites. IEnumerable<T>.Where takes a Func<T, bool>: compiled delegate, executed in your process memory, object by object, via LINQ-to-Objects iterators. IQueryable<T>.Where takes an Expression<Func<T, bool>>: the compiler builds an expression tree (a data structure describing the lambda) instead of compiling it, and the query provider (EF Core, for example) translates that tree into SQL when the query finally executes.

The consequence that decides real code reviews: dbContext.Orders.Where(o => o.Total > 1000) sends WHERE Total > 1000 to the database and returns only matching rows, but dbContext.Orders.AsEnumerable().Where(...) (or accidentally typing the variable as IEnumerable early) pulls the entire Orders table into memory and filters in C#. On a table with ten million rows that is the difference between 5 ms and an outage. Second consequence: an expression tree can only contain what the provider can translate; call a private helper method inside an IQueryable Where and EF Core throws an InvalidOperationException saying the expression could not be translated (older versions silently client-evaluated, which EF Core 3+ removed precisely because it hid table scans).

Third: composition. You can keep chaining Where/OrderBy/Select on an IQueryable and it all folds into one SQL statement at enumeration time; the boundary where SQL generation stops is exactly where you drop to IEnumerable. Practical rule to state in interviews: keep queries as IQueryable until the last possible moment, materialise explicitly with ToListAsync(), and treat AsEnumerable() as a deliberate, commented decision to switch to in-memory processing.

Key Points

  • IEnumerable filters in memory with compiled delegates
  • IQueryable builds expression trees the provider translates to SQL
  • Dropping to IEnumerable early pulls whole tables across the wire
  • Untranslatable expressions throw in EF Core 3+ rather than silently client-evaluating
  • Materialise explicitly with ToList/ToListAsync at the boundary
Q10

What is deferred execution in LINQ, and what bug does calling ToList() at the wrong time cause or fix?

BasicLINQ

Answer

Most LINQ operators (Where, Select, OrderBy, Take) are lazy: they build an iterator chain and execute nothing until something enumerates it, meaning foreach, ToList, ToArray, Count, First, Sum, and friends. Three real bugs flow from this. First, multiple enumeration: if you store var expensive = items.Where(Slow) and then check expensive.Any() before foreach-ing it, the Slow predicate runs twice end to end; against an IQueryable that is two database round trips.

Tools like ReSharper flag 'possible multiple enumeration' for exactly this; the fix is materialising once with ToList. Second, captured-variable surprises: a query built inside a loop that captures the loop variable executes later with the variable's final value (less common since foreach scoping was fixed in C# 5, but still real with for loops and with variables mutated after query construction, since the query sees the value at enumeration time, not definition time). Third, the disposed-context trap in ASP.NET Core: return a raw IEnumerable from a method whose DbContext is scoped to a using block, and enumeration happens after disposal, throwing ObjectDisposedException: 'Cannot access a disposed context instance.'

The countervailing bug: calling ToList too early on an IQueryable materialises the whole table before later Where clauses run, moving filtering into memory. So the rule has two halves: keep the query lazy while you are still composing it against the provider, then materialise exactly once at the boundary where the data leaves the data-access layer, and never let a lazy sequence escape the lifetime of the resources it depends on.

var numbers = new List<int> { 1, 2, 3 };
var query = numbers.Where(n =>
{
    Console.WriteLine($"evaluating {n}");
    return n > 1;
});

numbers.Add(4);

// Nothing has printed yet: the Where has not run.
var result = query.ToList(); // evaluates NOW, sees 4 items, prints 4 lines
Console.WriteLine(string.Join(",", result)); // 2,3,4

// Multiple-enumeration bug: predicate runs twice
if (query.Any())           // full/partial pass #1
    foreach (var n in query) { } // pass #2

// Fix: materialise once
var snapshot = query.ToList();
if (snapshot.Count > 0)
    foreach (var n in snapshot) { }
💡 Pro Tip: In an interview, connect this to EF Core: returning IQueryable from a repository leaks query composition and context lifetime concerns to callers; return materialised lists or well-defined projections instead.
Q11

How do exception filters (catch ... when) differ from catching and rethrowing, and what does throw; vs throw ex; do to the stack trace?

BasicExceptions

Answer

An exception filter, catch (SqlException ex) when (ex.Number == 1205), evaluates the condition before the catch block is entered and before the stack unwinds. If the filter returns false, the runtime keeps searching handlers as if this catch did not exist, and crucially the original stack is still intact at the throw point, so a crash dump taken on an unhandled exception shows the real failure site. Catch-inspect-rethrow, by contrast, has already unwound the stack by the time your if runs.

The second half of this question kills a lot of candidates: inside a catch block, a bare throw; rethrows preserving the original stack trace, while throw ex; resets the stack trace to the current line, destroying the information you need at 3 AM. Static analysers flag this as CA2200. When you need to rethrow on a different thread or enrich context, ExceptionDispatchInfo.Capture(ex).Throw() preserves the original trace, and wrapping with throw new PaymentException("charge failed", ex) keeps the original as InnerException.

Related runtime facts worth volunteering: finally blocks run whether or not an exception occurred (but not on Environment.FailFast or a corrupted-state crash), exceptions are for exceptional flow because throwing is orders of magnitude more expensive than a return code (the runtime walks handler tables and captures traces), and the TryParse pattern exists precisely so parsing bad input, an expected event, never throws. In ASP.NET Core, unhandled exceptions surface through UseExceptionHandler or IExceptionHandler (.NET 8+), which is where you map domain exceptions to ProblemDetails responses in one place.

try
{
    await ChargeAsync(order);
}
// Filter: runs BEFORE unwinding; non-matching deadlock errors keep original stack
catch (SqlException ex) when (ex.Number == 1205) // SQL Server deadlock victim
{
    await RetryAsync(order);
}
catch (SqlException ex)
{
    _log.LogError(ex, "Charge failed for {OrderId}", order.Id);
    throw;              // GOOD: original stack trace preserved
    // throw ex;        // BAD (CA2200): stack trace reset to this line
}

// Rethrowing later without losing the trace:
ExceptionDispatchInfo.Capture(stored).Throw();

// Wrapping with context:
throw new PaymentFailedException($"order {order.Id}", inner: sqlEx);

Key Points

  • when filters run before stack unwind and keep dumps pointing at the real failure
  • throw; preserves the stack, throw ex; resets it (CA2200)
  • ExceptionDispatchInfo rethrows a stored exception with its original trace
  • finally always runs on normal unwind; FailFast bypasses it
  • Prefer TryParse-style APIs for expected failures; throwing is expensive
Q12

Explain IDisposable, using declarations, and when you also need a finalizer or SafeHandle.

BasicResource Management

Answer

The GC reclaims memory, but it knows nothing about file handles, sockets, database connections, or unmanaged buffers, and it runs at unpredictable times. IDisposable is the contract for deterministic cleanup: Dispose() releases the resource now. The using statement (and the terser C# 8 using declaration, which disposes at the end of the enclosing scope) compiles to try/finally, so cleanup happens even when an exception is thrown.

Forgetting it is a top production incident source: undisposed SqlConnections exhaust the connection pool and requests start throwing InvalidOperationException about pool timeouts; undisposed HttpResponseMessages pin sockets. The full Dispose pattern with a protected virtual void Dispose(bool disposing) and a finalizer exists only for classes that directly own unmanaged resources, which in 2026 you should almost never write: wrap the raw handle in a SafeHandle subclass instead, which gives you a critical finalizer, handle-recycling attack protection, and lets your own class implement plain IDisposable with no finalizer at all. Finalizers are actively costly: finalizable objects survive at least one extra GC generation and run on a single finalizer thread, so an accidental finalizer on a hot type inflates Gen 1 and 2.

Modern additions worth naming: IAsyncDisposable with await using for resources whose cleanup does I/O (DbContext, streams over networks), and the analyzer rule CA2000 that flags locals not disposed on all paths. In DI containers, note that the container disposes IDisposable services it created when their scope ends, which is why you never manually dispose an injected DbContext.

// C# 8 using declaration: disposed at end of scope, compiles to try/finally
public async Task<byte[]> ReadReportAsync(string path)
{
    await using var stream = File.OpenRead(path);   // IAsyncDisposable
    using var ms = new MemoryStream();
    await stream.CopyToAsync(ms);
    return ms.ToArray();
}

// Owning an unmanaged handle the modern way: SafeHandle, no finalizer needed here
public sealed class NativeBuffer : IDisposable
{
    private readonly SafeHandle _handle;   // e.g. a SafeFileHandle subclass
    private bool _disposed;

    public void Dispose()
    {
        if (_disposed) return;
        _handle.Dispose();   // SafeHandle guarantees release even on crash paths
        _disposed = true;
    }
}
💡 Pro Tip: If asked 'does Dispose get called by the GC', the answer is no: the GC calls finalizers, never Dispose. Nothing calls Dispose except your code, using blocks, or a DI container.
Q13

Interfaces vs abstract classes in C#: what changed with default interface methods and static abstract members?

BasicType Design

Answer

The classical distinctions still apply: a class can implement many interfaces but inherit one abstract class; abstract classes can hold state (fields), constructors, and non-public members, while interfaces define a contract. But two modern features moved the boundary. Default interface methods (C# 8) let an interface provide a body: existing implementers compile unchanged when you add a method with a default body, solving the interface-versioning problem that previously forced IFoo2 interfaces.

The catch interviewers probe: the default implementation is only reachable through the interface type, not the class (a class implementing ILogger with a default LogWarning cannot call this.LogWarning unless it declares it), and diamond scenarios require the most-specific-override rule. Static abstract interface members (C# 11) enable generic math: INumber<T> declares static abstract T operator +(T, T), so you can write T Sum<T>(IEnumerable<T> xs) where T : INumber<T> and have it work for int, double, and decimal with no boxing and no runtime dispatch tricks, since the JIT specialises per value type. When to still choose an abstract class: you need shared mutable state, a constructor that enforces invariants, or you are modelling an is-a hierarchy where a template method calls protected hooks.

When to choose interfaces: any seam you plan to mock in tests, any cross-cutting capability (IComparable, IDisposable), and all DI-registered service contracts, since Moq and NSubstitute proxy interfaces trivially. A modern .NET codebase leans hard toward small interfaces plus composition; deep abstract-class hierarchies are a code smell interviewers at product companies actively ask you to critique.

// C# 8: default interface method, existing implementers unaffected
public interface INotifier
{
    Task SendAsync(string to, string message);
    Task SendBulkAsync(IEnumerable<string> tos, string message)
    {
        // default body; only callable through the interface type
        return Task.WhenAll(tos.Select(t => SendAsync(t, message)));
    }
}

// C# 11: static abstract members = generic math without boxing
public static T Sum<T>(ReadOnlySpan<T> values) where T : System.Numerics.INumber<T>
{
    var total = T.Zero;
    foreach (var v in values) total += v; // static abstract operator+
    return total;
}

double d = Sum<double>([1.5, 2.5]);
decimal m = Sum<decimal>([100m, 250m]);
Q14

Walk through C# access modifiers, including private protected, protected internal, and the C# 11 file modifier.

BasicFundamentals

Answer

public is visible everywhere. internal is visible within the same assembly, and is the correct default for implementation types in a library; InternalsVisibleTo("MyLib.Tests") in the .csproj (or AssemblyInfo) exposes internals to a test project, which is how you test internal classes without making them public. private restricts to the containing type, and protected to derived types. The two combined forms are where candidates stumble: protected internal means protected OR internal (derived types anywhere, plus anything in the same assembly), while private protected (C# 7.2) means protected AND internal (derived types, but only within the same assembly), which is the right choice for extension points you do not want external assemblies subclassing into. The file modifier (C# 11) scopes a type to its source file; it exists mainly for source generators to emit helper types without name collisions, but is also handy for file-local test doubles.

Related rules worth stating: top-level types can only be public or internal (default internal); interface members are public by default; a derived class cannot widen access of an overridden member; and struct members cannot be protected because structs are sealed. In code reviews the practical guidance is: start everything internal or private, promote to public deliberately, because every public member is an API contract you must version. Interviewers sometimes tie this to assembly design: internal plus InternalsVisibleTo is how large .NET codebases (including the runtime itself) keep surface area small while remaining testable.

Key Points

  • protected internal = protected OR internal; private protected = protected AND internal
  • internal + InternalsVisibleTo is the standard testability pattern
  • file (C# 11) scopes a type to one source file, built for source generators
  • Top-level types default to internal; interface members default to public
  • Overrides cannot change the accessibility of the base member
Q15

readonly vs const vs static readonly: which one bakes values into the caller's assembly and why does that matter?

BasicFundamentals

Answer

const is a compile-time constant limited to primitives, enums, and strings. The compiler inlines the value into every call site: if LibraryA defines public const int MaxRetries = 3 and LibraryB references it, the literal 3 is burned into LibraryB's IL. Ship a new LibraryA with MaxRetries = 5 without recompiling LibraryB, and LibraryB still uses 3.

This cross-assembly versioning trap is the single most important thing to say: public constants are a binary compatibility contract, so reserve const for values that are permanent by definition (days in a week, mathematical constants) and use static readonly for anything that could ever change. static readonly is initialised at runtime (field initializer or static constructor) and read via a field load, so all consumers see the current value; it also supports any type, including arrays, objects, and values computed at startup. Instance readonly fields can only be assigned in the declaration or constructors, giving per-object immutability; note that readonly on a mutable reference type freezes the reference, not the object (a readonly List<int> can still be Add-ed to, which is why exposing IReadOnlyList<T> matters). Two subtleties for senior loops: readonly struct fields cause defensive copies when you call methods on a non-readonly struct through a readonly field, and static readonly fields of primitive types can be treated as JIT-time constants in tiered compilation, so the performance argument for const is mostly gone. Also mention const interpolation: since C# 10, const string Greeting = $"Hello {Name}" works when Name is itself a const string.

public class RetryPolicy
{
    // Baked into CALLER assemblies at compile time. Changing it
    // requires recompiling every consumer. Reserve for true constants.
    public const int DaysInWeek = 7;

    // Read at runtime: safe to change across versions, any type allowed.
    public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);

    // Per-instance immutability: settable only here or in a constructor.
    private readonly HttpClient _client;

    public RetryPolicy(HttpClient client) => _client = client;
}

// Gotcha: readonly freezes the reference, not the object
public static readonly List<string> Hosts = new() { "a" };
// Hosts.Add("b") still compiles and runs. Expose IReadOnlyList<string> instead.
Q16

Show how switch expressions, property patterns, and list patterns replace if-else chains in modern C#.

BasicPattern Matching

Answer

Pattern matching grew from a type test (is) into a small language. Switch expressions (C# 8) are expressions, not statements: they return a value, require exhaustiveness (the compiler warns CS8509 if patterns miss cases, and throws SwitchExpressionException at runtime for an unmatched value), and use => arms with a _ discard as the catch-all. Type patterns combine test and cast: case Circle c => uses c directly.

Property patterns match into object shape: order is { Status: OrderStatus.Paid, Total: > 5000 } reads like a spec and handles null automatically because a null never matches an object pattern (this is why x is { } is an idiomatic null check, and x is not null is clearer still). Relational patterns (> >= < <=) and combinators (and, or, not) compose: temp is > 20 and <= 30. List patterns (C# 11) match arrays and lists positionally: [var first, .., var last] binds ends, [] matches empty, and the slice pattern .. can capture the middle.

Var patterns bind for reuse in when guards. What interviewers evaluate: do you know matching is order-dependent top to bottom, that the compiler flags unreachable arms (CS8510), and that property patterns call the actual property getters, so side-effecting getters make patterns unpredictable. A practical production example: mapping domain events or HTTP status handling with a single switch expression instead of nested ifs, which reviewers can verify for exhaustiveness at a glance. Recursive patterns nest arbitrarily deep, but style guides usually cap nesting at two levels for readability.

public static decimal ShippingFee(Order order) => order switch
{
    { Status: OrderStatus.Cancelled } => 0m,
    { Total: >= 1000m, Destination.Country: "IN" } => 0m, // free domestic above 1000
    { Weight: <= 0.5, Express: false } => 49m,
    { Express: true } => 199m,
    _ => 99m,
};

public static string Describe(int[] readings) => readings switch
{
    [] => "no data",
    [var only] => $"single reading {only}",
    [var first, .., var last] when last > first => "trending up",
    [var first, .., var last] => $"range {first}..{last}",
};

// Null-safe by construction: null matches no object pattern
string Label(object? o) => o switch
{
    null => "nothing",
    int and > 0 => "positive int",
    string { Length: > 0 } s => $"text: {s}",
    _ => "other",
};
Q17

How do generic constraints work, and why does where T : struct or new() exist at all?

BasicGenerics

Answer

Constraints tell the compiler what a type parameter can do, unlocking member access inside the generic body and controlling what callers may supply. The catalogue: where T : class (reference type, enables null assignment and reference comparison), where T : struct (non-nullable value type, which is also the gate for T? meaning Nullable<T>), where T : new() (public parameterless constructor, so you can write new T(); it must come last in the list), where T : SomeBase, where T : ISomeInterface, where T : unmanaged (no reference fields at any depth, enabling pointers and stackalloc over T), where T : notnull, and where T : default / where T : Enum and Delegate constraints that older C# lacked. The reason constraints matter beyond compilation: unlike Java's erasure, .NET generics are reified.

The JIT compiles one shared method body for all reference types but a specialised body per value type, so List<int> stores unboxed ints and where T : IComparable<T> emits a constrained call that invokes the struct's method without boxing. This is why generic collections beat the pre-.NET-2.0 object-based ones and why generic math (C# 11's INumber<T>) can match hand-written performance. Real-world constraint patterns to cite: repositories (where TEntity : class, IEntity, new()), the CRTP-like self-constraint where T : IComparable<T>, and factory methods using new(). Gotchas: new() cannot pass constructor arguments (use a Func<T> factory parameter instead), constraints are not inherited by overriding methods (they are re-stated implicitly), and you cannot constrain by sealed types or specific structs because nothing could ever satisfy or extend them meaningfully.

public interface IEntity { Guid Id { get; } }

public class Repository<T> where T : class, IEntity, new()
{
    private readonly Dictionary<Guid, T> _store = new();

    public T Create(Action<T> configure)
    {
        var entity = new T();          // legal only because of new()
        configure(entity);
        _store[entity.Id] = entity;    // Id accessible because of IEntity
        return entity;
    }
}

// Value-type specialisation: no boxing on the CompareTo call
public static T Clamp<T>(T value, T min, T max) where T : struct, IComparable<T>
    => value.CompareTo(min) < 0 ? min
     : value.CompareTo(max) > 0 ? max
     : value;

// unmanaged: allows stackalloc of T
public static unsafe int ByteSize<T>() where T : unmanaged => sizeof(T);
Q18

Delegates, Action/Func, and events: what does the compiler generate, and why can only the declaring class raise an event?

BasicDelegates & Events

Answer

A delegate is a type-safe reference to one or more methods: delegate void PriceChanged(decimal p) compiles to a full class deriving from MulticastDelegate with Invoke, BeginInvoke, and an invocation list. In modern code you rarely declare delegate types by hand: Action<T...> (returns void), Func<T..., TResult> (returns a value), and Predicate<T> cover nearly everything, and lambdas or method groups populate them. Delegates are multicast: += appends to the invocation list, -= removes; invoking runs handlers in order, and if one throws, the rest do not run, a real production consideration for notification fan-out (wrap each call in try/catch if isolation matters).

Return values of multicast delegates: only the last handler's return survives. An event is an access-restriction wrapper over a delegate field: the event keyword generates add/remove accessors so external code can only subscribe and unsubscribe; assigning (=, which would wipe other subscribers) and invoking are restricted to the declaring class. This encapsulation is the whole point: without it, any consumer could fire your OrderShipped event or erase everyone else's handlers.

Standard shape: EventHandler<TEventArgs>, raised via a null-conditional PriceChanged?.Invoke(this, args), which handles the no-subscribers case atomically against a race with unsubscription. The classic interview follow-ups: events can cause memory leaks because the publisher's delegate holds strong references to subscriber objects (a long-lived static service holding an event keeps every subscribed page or ViewModel alive; the fix is deterministic unsubscription or weak event patterns), and lambda subscriptions cannot be unsubscribed unless you store the delegate instance, because a new lambda is a new object and -= will not match.

public class PriceFeed
{
    public event EventHandler<decimal>? PriceChanged;

    public void Publish(decimal price)
    {
        // null-conditional invoke: race-safe when the last handler unsubscribes
        PriceChanged?.Invoke(this, price);
    }
}

public class AlertService : IDisposable
{
    private readonly PriceFeed _feed;
    private readonly EventHandler<decimal> _handler; // stored so -= can match

    public AlertService(PriceFeed feed)
    {
        _feed = feed;
        _handler = (_, price) => { if (price > 1000m) Notify(price); };
        _feed.PriceChanged += _handler;
    }

    // Without this, the feed keeps AlertService alive forever: event leak
    public void Dispose() => _feed.PriceChanged -= _handler;
}
Q19

How do extension methods resolve, and how do C# 14 extension members change the picture?

BasicLanguage Features

Answer

An extension method is a static method in a static class whose first parameter carries the this modifier; the compiler rewrites value.Method(args) into StaticClass.Method(value, args) when no instance method matches. Resolution rules matter in interviews: instance methods always win over extensions; among extensions, the compiler searches enclosing namespaces and usings, and ambiguity between two applicable extensions in scope is a compile error you resolve by calling one statically. Because dispatch is static, extensions on an interface do not behave polymorphically, and an extension can be called on a null receiver without a NullReferenceException at the call site (the throw happens inside if you dereference), which enables guard-style helpers like source.IsNullOrEmpty() but confuses readers if overused.

LINQ is the flagship example: Where and Select are extensions on IEnumerable<T> in the System.Linq namespace, which is why forgetting the using makes them vanish. C# 14 (shipped with .NET 10) generalises the feature into extension members: an extension block inside a static class can declare not just methods but properties and static members for a receiver type, so you can add a .IsWeekend property to DateOnly rather than a IsWeekend() method call. The receiver is declared once for the block (extension(DateOnly date) { ... }), and existing this-parameter extension methods remain fully supported and binary compatible. What to say about design: extensions shine for fluent APIs over types you do not own (string, IEnumerable, HttpContext) and for keeping interfaces minimal (default behavior as extensions, like Microsoft.Extensions.DependencyInjection's AddSingleton overloads over IServiceCollection), but business logic on your own domain types belongs on the types themselves where it can be overridden and tested polymorphically.

// Classic extension method (works in all modern versions)
public static class EnumerableExtensions
{
    public static bool IsNullOrEmpty<T>(this IEnumerable<T>? source)
        => source is null || !source.Any();
}

// C# 14 extension members: properties, not just methods
public static class DateOnlyExtensions
{
    extension(DateOnly date)
    {
        public bool IsWeekend
            => date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;

        public DateOnly NextWorkingDay
        {
            get
            {
                var d = date.AddDays(1);
                while (d.IsWeekend) d = d.AddDays(1);
                return d;
            }
        }
    }
}

// usage: reads like a first-class member
var shipDate = DateOnly.FromDateTime(DateTime.UtcNow).NextWorkingDay;
Q20

What are raw string literals and UTF-8 string literals in C# 11, and where do they beat verbatim strings?

BasicStrings

Answer

Raw string literals open and close with at least three double quotes (""" ... """) and change two painful things: nothing inside is escaped (backslashes, quotes, and braces are literal), and closing-delimiter indentation defines how much leading whitespace is stripped from every line, so multi-line content can be indented naturally with the surrounding code. Need literal double quotes inside? Use more quotes in the delimiter than any run inside the content (four quotes to contain three).

They compose with interpolation: prefix with $ as usual, and if your content itself contains braces (JSON templates!), use $$""" ... """ so interpolation requires double braces {{expr}}, leaving single braces literal. This makes embedded JSON, XML, regex patterns, and SQL dramatically cleaner than verbatim strings (@"..."), where every internal quote doubles and indentation leaks into the string. Compare writing a regex like \d{3}-\d{4}: in a regular string every backslash doubles; in a raw string it is written exactly as the regex engine sees it.

UTF-8 string literals, "GET"u8, produce a ReadOnlySpan<byte> containing UTF-8 bytes at compile time with no runtime encoding cost and no allocation; they exist because network protocols and JSON processing operate on UTF-8 bytes, and previously you either paid Encoding.UTF8.GetBytes at runtime or hand-maintained byte arrays. System.Text.Json's Utf8JsonReader-based code and HTTP header comparisons are the natural home. Two details for completeness: u8 literals end up in the assembly's data section, and they are spans, so you .ToArray() when an actual byte[] is required. In interviews, raw strings most often come up via test code: embedding expected JSON payloads in unit tests without escape noise.

// Raw string: no escaping, indentation stripped to closing delimiter
string expectedJson = """
    {
      "orderId": "A-1001",
      "path": "C:\\exports\\daily",
      "status": "PAID"
    }
    """;

// Interpolated raw string with JSON braces: $$ makes {{ }} the interpolation
string body = $$"""
    { "total": {{order.Total}}, "currency": "INR" }
    """;

// Regex exactly as the engine reads it
var phone = new System.Text.RegularExpressions.Regex("""^\+91-\d{10}$""");

// UTF-8 literal: compile-time bytes, zero allocation
ReadOnlySpan<byte> methodGet = "GET"u8;
bool isGet = requestBytes.StartsWith(methodGet);
Q21

How do Dictionary<TKey,TValue> and List<T> behave internally, and what performance rules follow?

BasicCollections

Answer

List<T> wraps a T[] plus a Count. Add is amortised O(1): when the array fills, the list allocates a new array of double the capacity and copies, so growing a list to a million items performs about 20 reallocations and copies roughly two million element slots along the way. The rule that follows: pass a capacity to the constructor whenever you know the size (new List<T>(rows.Count)), and note that each doubling briefly holds both arrays live, and arrays over 85,000 bytes land on the Large Object Heap.

Insert and RemoveAt at the front are O(n) because everything shifts; if you need cheap removal from both ends, that is a Queue<T> or a Deque-style structure. Dictionary<TKey,TValue> is a hash table using buckets and entries arrays: GetHashCode picks a bucket, equality resolves collisions by chaining through entry indexes. Lookup, add, and remove are O(1) expected, degrading toward O(n) if hash codes collide badly, and a resize rehashes every entry into a larger prime-sized table.

Practical consequences interviewers expect: keys must be immutable in their hash-relevant fields (mutate a key after insertion and it becomes unreachable), custom key types should implement IEquatable<T> and a good GetHashCode (HashCode.Combine), string-keyed dictionaries accept a StringComparer.OrdinalIgnoreCase at construction rather than normalising keys manually, TryGetValue beats ContainsKey-then-indexer (one hash computation instead of two), and CollectionsMarshal.GetValueRefOrAddDefault gives a ref for count-style updates with a single lookup. Also name the modern option: FrozenDictionary (System.Collections.Frozen, .NET 8) trades slow construction for the fastest possible reads, ideal for lookup tables built once at startup.

// Capacity avoids ~20 grow-and-copy cycles for large fills
var items = new List<OrderLine>(capacity: 10_000);

// One hash lookup, not two
if (prices.TryGetValue(sku, out var price))
    total += price;

// Case-insensitive keys done right: comparer, not key normalisation
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

// Single-lookup upsert for counters (.NET 6+)
ref int count = ref System.Runtime.InteropServices.CollectionsMarshal
    .GetValueRefOrAddDefault(wordCounts, word, out _);
count++;

// Built once, read millions of times (.NET 8+)
using System.Collections.Frozen;
FrozenDictionary<string, int> statusCodes =
    rawCodes.ToFrozenDictionary(StringComparer.Ordinal);
Q22

What is the difference between is, as, and a direct cast, and what do checked and unchecked control?

BasicType System

Answer

A direct cast ((Circle)shape) asserts the conversion and throws InvalidCastException when wrong: use it when a failure is a bug you want loud. The as operator returns null instead of throwing, works only for reference and nullable types, and its historical pairing with a null check has been superseded by pattern matching: if (shape is Circle c) tests and binds in one step with no double cast and no separate null check, and is not, is null, and property patterns extend the same syntax. A subtle correctness point: as and is respect runtime type identity (including variance for interfaces and arrays) but never invoke user-defined conversion operators, while a direct cast will; so if a type defines an explicit operator, only the cast syntax runs it.

Numeric conversions are a separate world: implicit widening (int to long) versus explicit narrowing (long to int) that silently truncates by default. That is where checked and unchecked come in: arithmetic overflow on int and friends wraps silently in unchecked context (the default for runtime code), while a checked block or the /p:CheckForOverflowUnderflow=true (CheckForOverflowUnderflow in the csproj) makes it throw OverflowException. Financial code is the canonical checked use case, and the canonical interview trap: int.MaxValue + 1 in a const expression fails at compile time, but the same computed at runtime wraps to int.MinValue unless checked.

Also worth naming: unchecked((int)0xFFFFFFFF) for intentional bit-pattern conversions in hashing code, decimal never wraps (it always throws on overflow), and floating point never throws (it produces Infinity or NaN). Candidates who mention that GetHashCode implementations conventionally use unchecked arithmetic to allow benign wraparound show they have read real code.

// Modern: test + bind in one pattern, no InvalidCastException risk
if (payment is UpiPayment { Vpa: { Length: > 0 } vpa })
    Route(vpa);

// as: null on mismatch, reference/nullable types only
var circle = shape as Circle;    // null if not a Circle

// Direct cast: throws when wrong, and (unlike is/as) runs
// user-defined explicit conversion operators
var inr = (Money)rupeeString;

// Overflow control
int big = int.MaxValue;
int wrapped = unchecked(big + 1);      // int.MinValue, silent
try
{
    int boom = checked(big + 1);       // throws OverflowException
}
catch (OverflowException) { /* ledger-safe path */ }

// Intentional bit reinterpretation, idiomatic in hash code math
int mask = unchecked((int)0xDEADBEEF);
Q23

What do top-level statements, global usings, and file-scoped namespaces change about program structure since .NET 6?

BasicModern C#

Answer

Three template-level changes define what a 'normal' C# file looks like now, and interviewers use them to check whether your experience is current or frozen at .NET Framework 4.x. Top-level statements let Program.cs contain statements directly, no explicit class Program or static void Main: the compiler synthesises the entry point, args is available implicitly, an await at top level makes the entry point async Task, and a return with an int sets the exit code. Only one file per project may have top-level statements (error CS8802 otherwise), and ASP.NET Core minimal hosting is built on it: var builder = WebApplication.CreateBuilder(args) straight at the top.

One testing consequence to know: the generated Program class is internal, so integration tests using WebApplicationFactory<Program> need either public partial class Program { } declared at the bottom of Program.cs or InternalsVisibleTo. Global usings (global using System.Text.Json;) apply a using to the entire project, and the SDK injects a set of ImplicitUsings (enabled via <ImplicitUsings>enable</ImplicitUsings>) that varies by SDK: web projects get System.Net.Http.Json and friends automatically, which is why modern files have almost no using block. Teams centralise extras in a GlobalUsings.cs.

File-scoped namespaces (namespace MyApp.Orders;) remove one brace and one indentation level for the whole file, now the default style enforced by dotnet format and .editorconfig (csharp_style_namespace_declarations = file_scoped). None of these change runtime behavior; they change what reviewers expect a 2026 codebase to look like, and writing a new file with block-scoped namespaces and a ceremonial Main in an interview signals dated habits.

// Program.cs: the whole file. Compiler generates Main.
using Microsoft.AspNetCore.Builder;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();

var app = builder.Build();
app.MapGet("/ping", () => Results.Ok(new { pong = true }));
await app.RunAsync();

// Needed so WebApplicationFactory<Program> can see the entry point
public partial class Program { }

// ---- GlobalUsings.cs (applies project-wide) ----
global using System.Text.Json;
global using FluentAssertions;

// ---- Orders/OrderService.cs: file-scoped namespace ----
namespace MyApp.Orders;

public class OrderService { /* one less indent level everywhere */ }
Q24

How does yield return actually work, and what are the sharp edges of iterator methods?

BasicIterators

Answer

A method containing yield return is rewritten by the compiler into a hidden state machine class implementing IEnumerable<T> and IEnumerator<T>. Calling the method executes none of your code; it just constructs the state machine. Each MoveNext() resumes execution from the last yield, runs until the next yield return (setting Current) or yield break or the method end, and saves locals as fields so they survive between calls.

This gives you lazy, streaming sequences with O(1) memory: reading a 10 GB log file line by line through an iterator never holds more than one line. The sharp edges are what interviews probe. First, deferred exceptions: argument validation inside an iterator does not run until the first MoveNext, so callers get the ArgumentNullException far from the call site; the fix is a two-method pattern, a public method that validates eagerly and returns a private iterator.

Second, multiple enumeration re-runs the entire body from scratch, including any I/O. Third, iterators capture this and parameters, so an iterator over a DbContext or open stream must be fully enumerated before the resource is disposed, the same ObjectDisposedException family of bugs as deferred LINQ. Fourth, try/finally works (finally code runs when the enumerator is disposed, which foreach guarantees), but yield return cannot appear inside a catch, and cannot appear in lambdas.

Fifth, ref/out parameters and unsafe blocks are disallowed in iterators. Mention the async sibling: IAsyncEnumerable<T> with await foreach (C# 8) applies the same state-machine idea to asynchronous streams, letting you yield inside an async method, which is the modern way to stream rows from EF Core (AsAsyncEnumerable) or messages from a queue without buffering.

// Two-method pattern: eager validation + lazy iteration
public static IEnumerable<string> ReadNonEmptyLines(string path)
{
    ArgumentException.ThrowIfNullOrEmpty(path);   // throws at CALL time
    return Iterate(path);

    static IEnumerable<string> Iterate(string p)
    {
        using var reader = new StreamReader(p);   // finally runs on Dispose
        while (reader.ReadLine() is { } line)     // property pattern null check
        {
            if (line.Length > 0)
                yield return line;                // suspends here
        }
    }
}

// Async stream: same idea, awaits allowed (C# 8+)
public static async IAsyncEnumerable<Order> PendingOrdersAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var order in _db.Orders.AsAsyncEnumerable().WithCancellation(ct))
        if (order.Status == OrderStatus.Pending)
            yield return order;
}
Q25

What does the compiler generate for an async method, and what role does SynchronizationContext play?

IntermediateAsync/Await

Answer

Marking a method async makes the compiler rewrite it into a struct state machine implementing IAsyncStateMachine. Code runs synchronously until the first await whose awaitable is not already complete; at that point the state machine records its position, boxes itself to the heap if needed, and registers a continuation with the awaiter via AwaitUnsafeOnCompleted. The method returns to its caller immediately with a Task that will complete later.

When the awaited operation finishes, the continuation calls MoveNext(), which jumps back to the recorded state and resumes. Two performance details worth naming: if the awaitable is already complete, await takes a fast path with no scheduling at all (which is why hot cache-hit paths use ValueTask), and since .NET Core the runtime pools and optimises these boxes aggressively. Where the continuation runs is decided by SynchronizationContext.Current captured at the await: classic ASP.NET and UI frameworks (WinForms, WPF, MAUI) install a context that marshals continuations back to the request or UI thread, which is what makes 'update a label after await' work, and also what makes .Result deadlock: the blocked UI thread is the very thread the continuation needs.

ASP.NET Core deliberately runs without a SynchronizationContext, so continuations resume on any ThreadPool thread, single-thread deadlocks largely disappear, and ConfigureAwait(false) becomes a library-hygiene concern rather than an application one. Interviewers often finish with: what is the difference between the Task returned and the work? An async method does not create a thread; it composes continuations over I/O completion (ultimately IOCP on Windows, epoll on Linux), and CPU-bound work still needs Task.Run to move off the caller's thread.

// What you write:
public async Task<decimal> GetBalanceAsync(Guid id)
{
    var user = await _repo.FindAsync(id);   // suspension point
    return user.Balance;
}

// Conceptually what ships (heavily simplified):
// struct StateMachine : IAsyncStateMachine {
//   int _state; TaskAwaiter<User> _awaiter; AsyncTaskMethodBuilder<decimal> _b;
//   void MoveNext() {
//     if (_state == -1) {
//       _awaiter = _repo.FindAsync(id).GetAwaiter();
//       if (!_awaiter.IsCompleted) { _state = 0;
//         _b.AwaitUnsafeOnCompleted(ref _awaiter, ref this); return; }
//     }
//     var user = _awaiter.GetResult();
//     _b.SetResult(user.Balance);
//   }
// }

// The classic deadlock (UI / classic ASP.NET, NOT ASP.NET Core):
// var balance = GetBalanceAsync(id).Result;  // blocks the thread the
// continuation must resume on. Fix: async all the way up.
Q26

Task vs ValueTask: when does ValueTask actually help, and what are its usage rules?

IntermediateAsync/Await

Answer

Task<T> is a heap-allocated class: every async method that suspends allocates one (completed-Task caching covers common cases like Task.CompletedTask and small integers, but not your data). For APIs called millions of times where the result is usually available synchronously, a cache in front of a database, a buffered stream read, those allocations are pure overhead. ValueTask<T> is a struct that either wraps the immediate result (no allocation on the synchronous path) or wraps an underlying Task/IValueTaskSource when the operation actually suspends.

That is the entire value proposition: cheap synchronous completion on hot paths, which is why Stream.ReadAsync, ChannelReader.ReadAsync, and most new BCL I/O APIs return ValueTask. The usage rules are strict and interviewers test them because violations fail intermittently: a ValueTask may be awaited only once, must not be awaited concurrently, and you must not call .Result or .GetAwaiter().GetResult() before confirming IsCompleted, because when it is backed by a pooled IValueTaskSource, consuming it twice reads recycled state and can observe a different operation's result. If you need to await multiple times, store it, or pass it around, convert with .AsTask() once and use the Task.

Guidance to state: return Task from ordinary application code and public APIs by default (composability with Task.WhenAll, safe multiple awaits, simpler semantics); reach for ValueTask on measured hot paths, typically library or infrastructure code, where profiling shows Task allocations matter; and never make an API return ValueTask just because it sounds faster. Also mention non-generic ValueTask for async methods that usually complete synchronously and return nothing, and that IAsyncDisposable.DisposeAsync returns ValueTask for exactly these reasons.

public sealed class QuoteCache(IQuoteApi api)
{
    private readonly ConcurrentDictionary<string, decimal> _cache = new();

    // Hot path: usually completes synchronously, zero allocation on hits
    public ValueTask<decimal> GetQuoteAsync(string symbol)
    {
        if (_cache.TryGetValue(symbol, out var cached))
            return new ValueTask<decimal>(cached);      // no Task allocated

        return new ValueTask<decimal>(FetchAndCacheAsync(symbol));
    }

    private async Task<decimal> FetchAndCacheAsync(string symbol)
    {
        var quote = await api.FetchAsync(symbol);
        _cache[symbol] = quote;
        return quote;
    }
}

// RULES:
var vt = cache.GetQuoteAsync("TITAGARH");
var price = await vt;          // fine: awaited exactly once
// await vt;                   // WRONG: second await of the same ValueTask
// var t = vt.AsTask();        // correct way if you need Task semantics
Q27

Why is async void dangerous, and what is the one legitimate place to use it?

IntermediateAsync/Await

Answer

An async void method has no Task for the caller to observe, which breaks async in three ways. First, exceptions: a fault inside an async Task method is stored in the returned Task and surfaces when awaited; a fault inside async void has nowhere to go, so the runtime posts it to the SynchronizationContext captured at the start, and with no context (ASP.NET Core, console apps) it is rethrown on a ThreadPool thread, crashing the process, the same effect as an unhandled exception on a raw thread. You cannot catch it at the call site; try { CallAsyncVoid(); } catch {} catches nothing because the method already returned.

Second, composition: callers cannot await completion, so 'fire and forget' work races shutdown, tests pass before the code under test finishes (a notorious flaky-test source), and ASP.NET request bodies can be disposed mid-operation. Third, tooling: analysers, WhenAll fan-out, and cancellation plumbing all assume Task-shaped methods. The one legitimate use: event handlers, because event delegate signatures are void-returning by contract, private async void OnClick(object sender, EventArgs e) in WinForms/WPF/MAUI is the sanctioned pattern, and even there the body should be a try/catch around an awaited call into an async Task method.

For genuine fire-and-forget in services, the honest alternatives are: a background queue (Channel<T> drained by a BackgroundService/IHostedService), Task.Run with an explicit continuation that logs faults, or at minimum a SafeFireAndForget-style extension that observes exceptions. Two related traps to volunteer: async lambdas passed to Action-typed parameters silently become async void (List<T>.ForEach(async x => ...) is a bug factory), and unobserved faulted Tasks no longer crash the process since .NET 4.5 but vanish silently unless you hook TaskScheduler.UnobservedTaskException.

// The trap: this LOOKS handled, but the catch never fires
try { DoWorkAsync(); } catch (Exception) { /* unreachable for async void */ }

async void DoWorkAsync()          // exception escapes to the context/threadpool
{
    await Task.Delay(10);
    throw new InvalidOperationException("process may die");
}

// Legitimate: UI event handler, body delegates and guards
private async void SaveButton_Click(object sender, EventArgs e)
{
    try { await SaveAsync(); }
    catch (Exception ex) { ShowError(ex); }
}

// Hidden async void: Action parameter swallows the async lambda
items.ForEach(async i => await PostAsync(i));   // BUG: unobserved, racy

// Service-grade fire-and-forget: queue + BackgroundService instead
await _channel.Writer.WriteAsync(new SendEmailJob(user.Email));

Key Points

  • async void exceptions bypass the caller and can kill the process
  • Completion is unobservable: races with shutdown and flaky tests
  • Only event handlers justify async void, with internal try/catch
  • async lambdas become async void when assigned to Action
  • Real fire-and-forget belongs in a Channel + BackgroundService
Q28

How should CancellationToken flow through a .NET service, and what does proper cancellation look like at each layer?

IntermediateAsync/Await

Answer

Cancellation in .NET is cooperative: a CancellationTokenSource owns the trigger, its Token flows through call chains, and each layer either passes it along, polls it, or registers callbacks. Nothing is aborted forcibly; code that ignores the token simply keeps running, which is why 'accept a CancellationToken as the last parameter and forward it to every awaited call' is stated as a hard API design rule (analyzer CA2016 flags forgotten forwarding). In ASP.NET Core the framework hands you HttpContext.RequestAborted, which fires when the client disconnects; binding a CancellationToken parameter in a controller action or minimal API handler wires it automatically, so an abandoned search request stops its SQL query (EF Core forwards tokens to the database driver) instead of burning a connection for a result nobody will read.

Layers and their idioms: I/O calls take the token directly (ReadAsync(buffer, ct)); CPU loops poll with ct.ThrowIfCancellationRequested() every iteration or chunk; long waits use Task.Delay(interval, ct); linked sources (CancellationTokenSource.CreateLinkedTokenSource(requestCt, timeoutCts.Token)) combine a caller's token with a local timeout, and .NET 8 made CancelAsync available plus timeout-specific niceties like CancellationTokenSource.TryReset for pooling. On the throwing side: cancel by throwing OperationCanceledException via ThrowIfCancellationRequested, and catch it at the top as a normal outcome (log at information level, return 499-style status), never as an error that pages on-call. Tests and consumers distinguish cancellation from failure because Task.IsCanceled differs from IsFaulted, and TaskCanceledException derives from OperationCanceledException. Two production gotchas: registrations (ct.Register) must be disposed or they accumulate on long-lived tokens, and passing default(CancellationToken) deep in a chain silently severs cancellation for everything below it.

// Minimal API: framework binds RequestAborted automatically
app.MapGet("/search", async (string q, AppDb db, CancellationToken ct) =>
{
    // EF Core forwards ct to the DB driver: client gone => query cancelled
    var hits = await db.Jobs
        .Where(j => j.Title.Contains(q))
        .Take(50)
        .ToListAsync(ct);
    return Results.Ok(hits);
});

// Combining caller token with a local timeout
public async Task<Report> BuildReportAsync(CancellationToken callerCt)
{
    using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
    using var linked = CancellationTokenSource
        .CreateLinkedTokenSource(callerCt, timeout.Token);

    foreach (var chunk in _chunks)
    {
        linked.Token.ThrowIfCancellationRequested();   // CPU-loop polling
        await ProcessAsync(chunk, linked.Token);        // forward everywhere
    }
    return Assemble();
}

// Top-level: cancellation is an outcome, not an error
catch (OperationCanceledException) { _log.LogInformation("client abandoned request"); }
Q29

Does ConfigureAwait(false) still matter in 2026, and where exactly should you use it?

IntermediateAsync/Await

Answer

ConfigureAwait(false) tells the awaiter not to capture SynchronizationContext.Current (or the current TaskScheduler) for the continuation, letting it resume on whatever ThreadPool thread completed the operation. Whether it matters depends entirely on the host. ASP.NET Core has no SynchronizationContext, so in a typical web service ConfigureAwait(false) changes effectively nothing, and most application teams have dropped it from app code for readability.

It still matters in three places. Library code: a NuGet package has no idea whether it will run under WPF, MAUI, classic ASP.NET, or a console app, so libraries use ConfigureAwait(false) on every await to avoid deadlocking a consumer who sync-blocks on their UI thread and to skip needless context marshalling; the .NET runtime repositories enforce this with analyzer CA2007. UI applications: code after an await that touches UI controls must NOT use it (you need the capture to get back to the UI thread), while purely computational library-ish stretches inside the same app can.

Anywhere sync-over-async still exists: if some legacy caller does .Result on your method, ConfigureAwait(false) inside is the mitigation that prevents the single-thread deadlock, though the real fix is removing the block. Newer API surface worth naming: ConfigureAwait(ConfigureAwaitOptions.ForceYielding | ...) overloads (.NET 8) can force asynchronous continuation or suppress exception context, and await foreach takes ConfigureAwait through ConfiguredCancelableAsyncEnumerable. The interview trap: ConfigureAwait(false) on the first await does not protect later awaits; every await in the method needs it, because each one captures independently. And it affects only the continuation of that awaited task, not code inside the called method.

Key Points

  • No-op in ASP.NET Core apps: there is no SynchronizationContext to skip
  • Mandatory hygiene in libraries (CA2007) to avoid deadlocking unknown hosts
  • UI code after await must keep the capture to touch controls
  • Must appear on every await; each awaits captures independently
  • .NET 8 added ConfigureAwaitOptions like ForceYielding
Q30

Compare lock, Monitor, SemaphoreSlim, and the System.Threading.Lock type for mutual exclusion, including async code.

IntermediateConcurrency

Answer

The lock statement is compiler sugar over Monitor.Enter/Exit in a try/finally, giving reentrant mutual exclusion on a reference-type gate object. Rules interviewers check: lock on a private readonly object dedicated to the purpose, never on this (external code could lock the same instance), never on typeof(T) or string literals (interned strings are process-wide shared), and never a value type (it would box to a different object each time, locking nothing). C# 13 with .NET 9 introduced System.Threading.Lock: when the lock statement's operand is a Lock, the compiler emits calls to its EnterScope pattern instead of Monitor, which measures faster and gives a dedicated type that cannot be confused with an arbitrary object; new code targeting .NET 9+ should declare private readonly Lock _gate = new();.

Monitor directly adds capabilities lock hides: TryEnter with a timeout (deadlock-resistant acquisition) and Wait/Pulse for condition signalling. The hard rule that changes designs: you cannot await inside a lock block (compiler error CS1996), because the continuation could resume on a different thread than the one that entered the Monitor. For mutual exclusion around asynchronous work, the standard tool is SemaphoreSlim(1, 1) with await _sem.WaitAsync(ct) and a try/finally Release(); it is not reentrant, so a method that awaits itself while holding it deadlocks, a real bug class in cache-refresh code. Alternatives to mention for credit: ReaderWriterLockSlim for read-heavy protection of rarely-written state (with the caveat that it is easy to misuse and often loses to a plain lock in benchmarks), Interlocked for single-variable atomic updates without any lock, and redesigning with immutable snapshots or Channels so most contention disappears entirely.

public sealed class RateCache
{
    private readonly System.Threading.Lock _gate = new();   // .NET 9+ / C# 13
    private Dictionary<string, decimal> _rates = new();

    public decimal? Get(string ccy)
    {
        lock (_gate)                      // compiles to Lock.EnterScope
            return _rates.TryGetValue(ccy, out var r) ? r : null;
    }

    // Async mutual exclusion: SemaphoreSlim, NOT lock (CS1996: cannot await in lock)
    private readonly SemaphoreSlim _refreshGate = new(1, 1);

    public async Task RefreshAsync(IRateApi api, CancellationToken ct)
    {
        await _refreshGate.WaitAsync(ct);
        try
        {
            var fresh = await api.FetchAllAsync(ct);        // await while 'held'
            lock (_gate) _rates = fresh;                     // swap under lock
        }
        finally { _refreshGate.Release(); }                  // ALWAYS release
    }
}
💡 Pro Tip: If asked why locking on a string is dangerous, the answer is interning: two unrelated pieces of code locking "cache" share one lock object process-wide and can deadlock each other.
Q31

What do Interlocked and volatile actually guarantee, and when is each insufficient?

IntermediateConcurrency

Answer

These are the low-level tools below locks, and the interview goal is usually to check you know their limits. Interlocked provides atomic read-modify-write operations with full memory barriers: Increment, Decrement, Add, Exchange, and CompareExchange (the CAS primitive on which lock-free algorithms are built). Interlocked.Increment(ref _count) is the correct way to bump a counter shared across threads; _count++ is a load, add, store triple that loses updates under contention.

CompareExchange(ref location, newValue, expectedValue) updates only if the current value equals expected and returns the old value, enabling optimistic loops: read, compute, attempt swap, retry on conflict. That pattern implements lock-free stacks, lazy singleton publication, and 'add to immutable snapshot' updates. volatile is much weaker and widely misunderstood: it guarantees visibility (reads see the latest published value rather than a register-cached one) and ordering constraints (acquire semantics on read, release on write, so the compiler/JIT/CPU cannot reorder across them in the forbidden directions), but it provides no atomicity for compound operations: volatile int does not make ++ safe, and volatile cannot even be applied to long or double on 32-bit platforms' atomicity terms (use Interlocked.Read or Volatile.Read for 64-bit values on 32-bit processes). The modern API preference is Volatile.Read/Volatile.Write over the field modifier because the semantics sit visibly at the call site.

When both are insufficient: any invariant spanning multiple variables (transfer between two account balances), or check-then-act sequences (if not contains, then add), needs a lock or a redesign; sprinkling Interlocked on each variable individually leaves the combined invariant unprotected. A strong close: 'my default is a lock for correctness, Interlocked for single-word counters and CAS publication, and volatile almost never, because if I am reasoning about memory ordering by hand I want it explicit via Volatile.Read.'

public sealed class Metrics
{
    private long _requests;

    public void Hit() => Interlocked.Increment(ref _requests);   // atomic, barriered
    public long Snapshot() => Interlocked.Read(ref _requests);   // atomic 64-bit read
}

// CAS publication: initialise once, first writer wins, no lock
private static ExpensiveClient? _client;
public static ExpensiveClient Client
{
    get
    {
        var existing = Volatile.Read(ref _client);
        if (existing is not null) return existing;
        var created = new ExpensiveClient();
        // returns the ORIGINAL value: if someone beat us, use theirs
        return Interlocked.CompareExchange(ref _client, created, null) ?? created;
    }
}

// INSUFFICIENT: two Interlocked calls do not make ONE atomic transfer
// Interlocked.Add(ref a, -100); Interlocked.Add(ref b, +100); // torn invariant
// A lock (or a ledger/message design) is required for multi-variable invariants.
Q32

How do ConcurrentDictionary and System.Threading.Channels differ as tools for producer-consumer work?

IntermediateConcurrency

Answer

They solve different problems and pairing them wrongly is a design smell. ConcurrentDictionary<TKey,TValue> is shared mutable state done safely: striped locking internally, lock-free reads, and atomic compound APIs, GetOrAdd, AddOrUpdate, TryRemove, TryUpdate. The gotchas carry real weight: GetOrAdd's valueFactory runs outside the lock, so under a race two threads can both execute the factory and one result is discarded, unacceptable if the factory opens connections or is expensive; the standard fix is GetOrAdd with Lazy<T> values so only the Lazy is duplicated and its Value executes once.

Count, Keys, Values, and enumeration take snapshots or lock all stripes and are disproportionately expensive; enumeration is lock-free but sees a moving picture, not a point-in-time snapshot. AddOrUpdate's delegates can also run multiple times under contention, so they must be pure. Channel<T> (System.Threading.Channels) is a different animal: an async-first queue connecting producers to consumers.

Channel.CreateBounded<T>(new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.Wait }) gives you backpressure: when consumers fall behind, WriteAsync suspends producers instead of letting the queue grow until OutOfMemoryException, which is precisely the failure mode of the naive ConcurrentQueue-plus-polling design it replaces. Consumers do await foreach (var item in reader.ReadAllAsync(ct)), completion propagates via writer.Complete(), and options cover single-reader/single-writer fast paths and DropOldest/DropWrite modes for lossy telemetry. The decision rule to state: keyed lookup shared across components is ConcurrentDictionary; a flow of work items from producers to consumers is a Channel; and a BackgroundService draining a bounded channel is the modern in-process job queue in ASP.NET Core, the same shape used behind IHostedService email senders and audit-log writers at most .NET shops.

// Cache with single-execution factory: Lazy defeats the duplicate-factory race
private readonly ConcurrentDictionary<string, Lazy<Task<Config>>> _configs = new();

public Task<Config> GetConfigAsync(string tenant) =>
    _configs.GetOrAdd(tenant,
        t => new Lazy<Task<Config>>(() => LoadFromDbAsync(t))).Value;

// In-process job queue with backpressure
var channel = Channel.CreateBounded<EmailJob>(new BoundedChannelOptions(500)
{
    FullMode = BoundedChannelFullMode.Wait,   // producers await when full
    SingleReader = true,
});

// Producer (e.g., inside a request handler)
await channel.Writer.WriteAsync(new EmailJob(user.Email, body), ct);

// Consumer: a BackgroundService
public class EmailWorker(ChannelReader<EmailJob> reader, IEmailSender sender)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await foreach (var job in reader.ReadAllAsync(ct))
            await sender.SendAsync(job, ct);
    }
}
Q33

Explain GC generations, the Large Object Heap, and the difference between Workstation and Server GC in .NET.

IntermediateGarbage Collection

Answer

The .NET GC is generational, tracing, and compacting. New objects are allocated by a pointer bump in Gen 0; when Gen 0 fills, a collection traces reachable objects from roots (stacks, statics, GC handles), promotes survivors to Gen 1, and reclaims the rest, typically in well under a millisecond because most objects die young (the generational hypothesis). Gen 1 buffers the survivors; long-lived objects reach Gen 2, collected far less often and far more expensively, since a full Gen 2 pass traces the whole heap.

Objects of 85,000 bytes or more go straight to the Large Object Heap, which is collected only with Gen 2 and historically never compacted (compaction copies are too expensive), so LOH churn, repeatedly allocating big byte arrays for uploads or serialisation buffers, fragments memory and inflates the process working set even when 'live' bytes are modest; the fixes are ArrayPool<byte>.Shared for buffers, RecyclableMemoryStream for streams, and, when needed, one-off compaction via GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce before an induced collection. The Pinned Object Heap (.NET 5+) segregates pinned buffers so they stop fragmenting the ordinary heap. Workstation GC optimises for latency on client machines; Server GC (<ServerGarbageCollection>true</ServerGarbageCollection>, the default for ASP.NET Core) gives each logical CPU its own heap and collection thread for throughput, at the price of a bigger memory footprint, which stings in small containers.

That sting is why .NET 8 introduced and .NET 9 enabled by default DATAS (Dynamic Adaptation To Application Sizes), which starts Server GC with fewer heaps and grows them with load, cutting idle memory dramatically in Kubernetes-sized pods. Also name concurrent/background collection (Gen 2 runs mostly concurrently with your code) and that most collections pause all threads briefly at safe points, which is what you see as p99 latency spikes when Gen 2 or LOH pressure is high.

Key Points

  • Gen 0/1/2 with promotion; most objects should die in Gen 0
  • LOH threshold is 85,000 bytes; collected with Gen 2, not compacted by default
  • ArrayPool / RecyclableMemoryStream are the LOH-churn fixes
  • Server GC: per-core heaps, throughput-first, container-hungry
  • DATAS (default in .NET 9+) right-sizes Server GC heaps dynamically
💡 Pro Tip: For container questions, mention that the GC reads cgroup memory limits and that DOTNET_GCHeapHardLimitPercent tunes the ceiling; a pod OOMKilled despite 'low' managed heap is usually LOH fragmentation or native memory, which dotnet-gcdump vs dotnet-counters can tell apart.
Q34

What are Span<T>, Memory<T>, and stackalloc, and why can Span<T> not live on the heap?

IntermediatePerformance

Answer

Span<T> is a ref struct providing a type-safe view over any contiguous memory: an array segment, a stackalloc buffer, a string's characters (ReadOnlySpan<char>), or native memory. Its power is uniform, allocation-free slicing: span.Slice(2, 5) and value.AsSpan(1..^1) create views without copying, so parsing code that once allocated Substring garbage per token now allocates nothing. The BCL leans into this everywhere: int.Parse(ReadOnlySpan<char>), Encoding.UTF8.GetBytes(span, span), MemoryExtensions.SequenceEqual, and string.Create for building the final string in place.

The 'why can it not live on the heap' part is the differentiator question: Span<T> is a ref struct because it may wrap a stack address (stackalloc) or an interior pointer into an object; if a span could be stored in a heap object, the GC would need to track interior pointers on the heap and a span could outlive its stack frame, a use-after-free. So the compiler forbids ref structs as class fields, in boxed positions, as generic arguments (relaxed by allows ref struct anti-constraint in C# 13 for APIs designed for it), and, until recent versions, in async/iterator methods (C# 13 allows locals not spanning awaits). Memory<T> is the heap-safe sibling: an ordinary struct referencing array-backed or MemoryManager-backed memory that CAN be a field or cross an await; you call .Span on it at the synchronous moment of use. The standard pattern: store/pass Memory<T> through async pipelines, drop to Span<T> in the tight loop. stackalloc int[64] allocates on the stack, freed on return, ideal for small scratch buffers; guard size (typically under 1 KB) to avoid stack overflow, with the hybrid idiom: stackalloc when small, ArrayPool rental otherwise.

// Zero-allocation parsing: no Substring, no split arrays
public static (int major, int minor) ParseVersion(ReadOnlySpan<char> input)
{
    int dot = input.IndexOf('.');
    return (int.Parse(input[..dot]), int.Parse(input[(dot + 1)..]));
}

// Hybrid buffer idiom: stack for small, pool for large
public static string ToHex(ReadOnlySpan<byte> data)
{
    char[]? rented = null;
    Span<char> buffer = data.Length <= 64
        ? stackalloc char[128]
        : (rented = System.Buffers.ArrayPool<char>.Shared.Rent(data.Length * 2));
    try
    {
        for (int i = 0; i < data.Length; i++)
            data[i].TryFormat(buffer.Slice(i * 2, 2), out _, "x2");
        return new string(buffer[..(data.Length * 2)]);
    }
    finally
    {
        if (rented is not null) System.Buffers.ArrayPool<char>.Shared.Return(rented);
    }
}

// Async boundary: Memory<T> crosses awaits, Span<T> cannot
public async Task PumpAsync(Stream src, Memory<byte> buffer, CancellationToken ct)
{
    int read = await src.ReadAsync(buffer, ct);   // Memory-based overload
    Process(buffer.Span[..read]);                  // drop to Span synchronously
}
Q35

Explain the three DI lifetimes in Microsoft.Extensions.DependencyInjection and the captive dependency problem.

IntermediateDependency Injection

Answer

The built-in container has exactly three lifetimes. AddSingleton: one instance for the application's lifetime, created on first resolution (or provided as an instance), shared by all threads, so it must be thread-safe and must not hold per-user state. AddScoped: one instance per scope, and in ASP.NET Core the framework creates a scope per HTTP request, which is why DbContext is registered scoped: one unit of work per request, disposed at request end.

AddTransient: a new instance per resolution, for cheap stateless helpers; note the container disposes transient IDisposables it created, holding them until the owning scope ends, so a transient disposable resolved from the root provider effectively leaks until shutdown. The captive dependency problem is the classic failure: inject a scoped service into a singleton, and the singleton 'captures' the first scope's instance forever, in practice a DbContext used concurrently by every request, producing InvalidOperationException: 'A second operation was started on this context instance before a previous operation completed', intermittent data corruption, and connections that never return. The default environment catches many cases: scope validation (ValidateScopes, on in Development) throws 'Cannot consume scoped service from singleton' at startup, and ValidateOnBuild surfaces missing registrations early; enable both in production builds too, the startup cost is trivial.

When a singleton genuinely needs scoped work, inject IServiceScopeFactory and create a scope per operation, the standard pattern inside BackgroundService, which is itself resolved from the root and therefore cannot take scoped constructor dependencies. Also worth naming: keyed services (.NET 8) via AddKeyedSingleton and [FromKeyedServices("name")] for multiple implementations of one interface, TryAdd variants for library-friendly registration, and that constructor injection with the fewest dependencies wins design points; a class taking eight services is a refactoring prompt, not a DI achievement.

builder.Services.AddSingleton<IClock, SystemClock>();          // stateless, thread-safe
builder.Services.AddScoped<AppDbContext>();                     // per request
builder.Services.AddTransient<IEmailComposer, EmailComposer>(); // cheap, stateless
builder.Services.AddKeyedSingleton<IStore, S3Store>("cold");    // .NET 8 keyed

// Fail fast on captive dependencies and missing registrations:
builder.Host.UseDefaultServiceProvider(o =>
{
    o.ValidateScopes = true;
    o.ValidateOnBuild = true;
});

// Singleton needing scoped work: scope per operation, never a captured DbContext
public class NightlyCleanup(IServiceScopeFactory scopes) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (await _timer.WaitForNextTickAsync(ct))
        {
            await using var scope = scopes.CreateAsyncScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            await db.Sessions.Where(s => s.Expired).ExecuteDeleteAsync(ct);
        }
    }
}
Q36

How does the ASP.NET Core middleware pipeline work, and why does registration order change behavior?

IntermediateASP.NET Core

Answer

The pipeline is a chain of RequestDelegate wrappers: each middleware receives HttpContext and a next delegate, does work before calling await next(context), and does more work after next returns, giving every component a wrap-around view of the request. app.Use registers such a component, app.Run registers a terminal one that never calls next, and app.Map/MapWhen branch the pipeline by path or predicate. Order is behavior, because a middleware only affects what happens downstream of it. The canonical ordering questions: UseExceptionHandler (or the .NET 8+ IExceptionHandler infrastructure) goes first so it can catch everything below; UseHttpsRedirection and UseHsts early; UseStaticFiles before routing so static hits skip the MVC machinery; UseRouting before UseAuthentication before UseAuthorization, authentication populates HttpContext.User, authorization reads it, and both need routing's endpoint selection to know which policy applies; UseCors must sit between routing and the endpoints or preflight requests fail mysteriously in production while working in Postman (which does not send preflights); rate limiting (UseRateLimiter, .NET 7+) and output caching similarly slot after routing.

Endpoint execution (MapControllers, MapGet) is the terminal stage. Two mechanics interviewers probe: short-circuiting, an auth middleware returning 401 without calling next is how requests get rejected cheaply, and the response-started trap: once the response body has begun streaming, you cannot change status code or headers, so exception-handling middleware that tries to rewrite a half-written 200 into a 500 throws InvalidOperationException ('StatusCode cannot be set because the response has already started'), which is why handlers check context.Response.HasStarted. Also distinguish middleware from filters: middleware sees every request including static files and unmatched routes, while MVC filters run only inside the controller invocation with model-binding context available.

var app = builder.Build();

app.UseExceptionHandler();          // outermost: catches everything below
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();               // short-circuits static hits cheaply
app.UseRouting();
app.UseCors("frontend");           // AFTER routing, BEFORE endpoints
app.UseAuthentication();            // fills HttpContext.User
app.UseAuthorization();             // consumes it
app.UseRateLimiter();

// Inline middleware: wrap-around timing, order-sensitive
app.Use(async (ctx, next) =>
{
    var sw = System.Diagnostics.Stopwatch.StartNew();
    await next(ctx);                                  // everything downstream
    ctx.RequestServices.GetRequiredService<ILogger<Program>>()
        .LogInformation("{Path} took {Ms}ms -> {Status}",
            ctx.Request.Path, sw.ElapsedMilliseconds, ctx.Response.StatusCode);
});

app.MapControllers();               // terminal endpoint stage
app.Run();
Q37

Minimal APIs vs MVC controllers in ASP.NET Core: what are the real trade-offs in 2026?

IntermediateASP.NET Core

Answer

Minimal APIs (app.MapGet("/orders/{id}", handler)) started as a scripting-friendly veneer and matured into a first-class model: route groups (MapGroup) share prefixes, filters, and auth policies; endpoint filters (AddEndpointFilter) give per-endpoint cross-cutting behavior analogous to action filters; TypedResults (Results<Ok<Order>, NotFound>) make return types statically checkable and self-documenting in OpenAPI (which .NET 9 generates natively via Microsoft.AspNetCore.OpenApi, no Swashbuckle dependency); [AsParameters] binds grouped parameters; and validation of data annotations on parameters arrived with .NET 10. Two arguments have hardened in their favour: performance, less pipeline machinery than MVC's controller activation, filters, and model-binding stack, and crucially compatibility with Native AOT, where the Request Delegate Generator source-generates the binding code that MVC would do via reflection: MVC controllers remain unsupported for AOT, so trimmed self-contained deployments effectively mandate minimal APIs. Controllers still earn their keep where their machinery is the point: large API surfaces organised into classes with shared base behavior, heavy use of the filter pipeline, complex model binding of deep form posts, content negotiation beyond JSON, and views (Razor/MVC proper).

Testing is a wash: both integration-test cleanly through WebApplicationFactory, and minimal handlers taking dependencies as parameters unit-test as plain methods. The pragmatic team guidance to give: new JSON-over-HTTP services default to minimal APIs with route groups per feature (which pairs naturally with vertical-slice architecture and MediatR-style handlers); existing controller codebases do not migrate for fashion, the models interoperate in one app, so you add new endpoint groups as minimal APIs beside legacy controllers. Interviewers also like hearing the anti-pattern: a 500-line Program.cs of inline lambdas is not 'minimal', extract handlers to static methods or classes and keep Program.cs as a route table.

// Route group: shared prefix, auth, and filter; handlers stay testable statics
var orders = app.MapGroup("/api/orders")
    .RequireAuthorization("OrdersRead")
    .AddEndpointFilter(async (ctx, next) =>
    {
        // endpoint filter: runs per call, can short-circuit
        return await next(ctx);
    });

orders.MapGet("/{id:guid}", GetOrder);
orders.MapPost("/", CreateOrder);

// TypedResults: return type documents the contract, OpenAPI picks it up
public static async Task<Results<Ok<OrderDto>, NotFound>> GetOrder(
    Guid id, IOrderService svc, CancellationToken ct)
{
    var order = await svc.FindAsync(id, ct);
    return order is null
        ? TypedResults.NotFound()
        : TypedResults.Ok(OrderDto.From(order));
}

public static async Task<Created<OrderDto>> CreateOrder(
    CreateOrderRequest req, IOrderService svc, CancellationToken ct)
{
    var created = await svc.CreateAsync(req, ct);
    return TypedResults.Created($"/api/orders/{created.Id}", OrderDto.From(created));
}
Q38

How does EF Core change tracking work, and when do you use AsNoTracking, AsSplitQuery, and ExecuteUpdate?

IntermediateEF Core

Answer

Every entity materialised by a tracked query gets an entry in the DbContext's change tracker with a snapshot of original values. SaveChanges diffs current values against snapshots, computes INSERT/UPDATE/DELETE statements, wraps them in a transaction, and fixes up navigation properties and generated keys. This is what makes the load-modify-save workflow feel effortless, and also why a long-lived context bloats: tracking is per-context state, and a context reused across thousands of entities slows every query (identity-map lookups) and every SaveChanges (bigger diffs), one more reason contexts are scoped per request or per unit of work and are not thread-safe (the 'second operation started on this context' exception is almost always concurrent use of one context).

AsNoTracking skips snapshots and identity resolution for read-only queries, a meaningful CPU and allocation win on hot read endpoints; AsNoTrackingWithIdentityResolution keeps deduplication of repeated entities without full tracking. The N+1 problem appears when you iterate parents and touch a lazy or separately-loaded collection per iteration, one query becomes hundreds; fixes are Include/ThenInclude for eager loading or, better, Select projections that fetch exactly the columns the endpoint returns (projections are never tracked and dodge over-fetching). Cartesian explosion is Include's own failure mode: two collection Includes multiply rows (parent x children1 x children2); AsSplitQuery issues one query per collection instead, trading round trips for row count, with the caveat that split queries lose single-query consistency without a transaction.

For set-based writes, ExecuteUpdate and ExecuteDelete (EF Core 7+) push UPDATE ... SET and DELETE straight to the database without loading entities, bypassing the change tracker entirely, the right tool for 'expire all sessions older than 30 days', with the flip side that interceptors, concurrency tokens, and tracked-entity state do not participate, so mixed usage needs care.

// Hot read endpoint: no tracking + projection = no snapshots, minimal columns
var page = await db.Jobs
    .AsNoTracking()
    .Where(j => j.City == "Bengaluru" && j.IsActive)
    .OrderByDescending(j => j.PostedAt)
    .Select(j => new JobCardDto(j.Id, j.Title, j.Company.Name, j.SalaryBand))
    .Take(20)
    .ToListAsync(ct);

// Two collection Includes => cartesian explosion; split into 3 queries instead
var profile = await db.Candidates
    .Include(c => c.Skills)
    .Include(c => c.WorkHistory)
    .AsSplitQuery()
    .FirstOrDefaultAsync(c => c.Id == id, ct);

// Set-based write: no entities loaded, no change tracker involved (EF Core 7+)
int expired = await db.Sessions
    .Where(s => s.LastSeen < DateTime.UtcNow.AddDays(-30))
    .ExecuteDeleteAsync(ct);

await db.Orders
    .Where(o => o.Status == OrderStatus.PaymentPending && o.CreatedAt < cutoff)
    .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, OrderStatus.Cancelled), ct);
Q39

Describe a production-safe EF Core migrations workflow, from dotnet ef migrations add to deployment.

IntermediateEF Core

Answer

Locally: dotnet ef migrations add AddJobExpiryColumn scaffolds a migration class with Up and Down methods plus an updated model snapshot; dotnet ef database update applies it. The parts that matter in production reviews: always read the generated migration, EF's diff is mechanical and will happily generate a column drop from a rename (a rename should be hand-edited to migrationBuilder.RenameColumn or you lose data); the model snapshot file must be committed and is a merge-conflict hotspot when two branches add migrations, resolved by rebasing and regenerating the later migration. For deployment, calling context.Database.Migrate() at app startup is fine for a single-instance dev environment and a menace beyond it: multiple replicas racing to migrate can deadlock or double-apply, startup couples to schema changes, and rollback becomes a redeploy.

The production-grade options: generate SQL scripts with dotnet ef migrations script --idempotent (safe to run repeatedly because it checks the __EFMigrationsHistory table) and apply them through your release pipeline with DBA review; or use dotnet ef migrations bundle to produce a self-contained executable that a deploy job runs before rolling instances; either way, the app itself never mutates schema. Zero-downtime discipline is the senior differentiator: schema changes must be backward compatible with the previous app version because old and new run simultaneously during a rolling deploy, so the pattern is expand-and-contract, add the nullable column now, backfill, ship code that writes both, then remove the old column in a later release; never add a NOT NULL column without a default to a hot table in one step, and remember that on very large MySQL/Postgres tables even 'add column' semantics deserve checking. Also mention Down() honestly: many teams treat down-migrations as untested fiction and roll forward instead.

# Development loop
dotnet ef migrations add AddJobExpiryColumn --project src/Infra --startup-project src/Api
dotnet ef database update

# Inspect what will really run against production
dotnet ef migrations script --idempotent -o artifacts/migrate.sql

# Or a self-contained migration executable for the deploy pipeline
dotnet ef migrations bundle --self-contained -r linux-x64 -o artifacts/efbundle
./efbundle --connection "$PROD_CONNECTION_STRING"

// Hand-edit renames: the scaffolder sees drop+add, which destroys data
protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.RenameColumn(
        name: "ExpiryDate", table: "Jobs", newName: "ExpiresAtUtc");

    // Expand-and-contract step 1: nullable first, backfill separately
    migrationBuilder.AddColumn<DateTime>(
        name: "ArchivedAtUtc", table: "Jobs", nullable: true);
}
Q40

IOptions<T> vs IOptionsSnapshot<T> vs IOptionsMonitor<T>: how does typed configuration actually reload?

IntermediateConfiguration

Answer

The options pattern binds configuration sections to POCOs: builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp")) registers the binding, and consumers inject one of three interfaces whose differences are lifetime and reload behavior. IOptions<T> is a singleton computed once on first access and never updated: cheapest, injectable anywhere, correct for settings that cannot meaningfully change mid-process. IOptionsSnapshot<T> is scoped: recomputed once per DI scope (per HTTP request), so after appsettings.json changes (the JSON provider watches the file with reloadOnChange: true by default for appsettings), the next request sees new values; it cannot be injected into singletons (captive dependency, and the container's scope validation will say so).

IOptionsMonitor<T> is a singleton that exposes CurrentValue plus OnChange callbacks, the right choice inside singletons and background services that must react to changes without a request cycle; note OnChange can fire twice for one file save (file-watcher quirk) so handlers should be idempotent or debounced. Reload only works for providers that support it: JSON files yes; environment variables no (read once at startup); Azure App Configuration and similar cloud providers push changes with their own refresh mechanics. Validation is the production half of this answer: AddOptions<SmtpOptions>().BindConfiguration("Smtp").ValidateDataAnnotations().Validate(o => o.Port > 0, "port must be positive").ValidateOnStart() turns a missing or malformed section into a startup crash (OptionsValidationException) instead of a NullReferenceException at 2 AM when the first email sends; without ValidateOnStart, validation runs lazily on first resolution, which just delays the explosion. Also name named options (Configure<T>("tenantA", ...) resolved via IOptionsMonitor<T>.Get("tenantA")) for multi-tenant or multi-endpoint bindings, and the guidance that domain code should depend on the bound POCO or a small interface, not on IConfiguration, string-key lookups scattered through business logic are the anti-pattern the options pattern exists to kill.

public sealed class SmtpOptions
{
    [Required] public string Host { get; init; } = "";
    [Range(1, 65535)] public int Port { get; init; } = 587;
    public bool UseTls { get; init; } = true;
}

// Fail at startup, not on first email
builder.Services.AddOptions<SmtpOptions>()
    .BindConfiguration("Smtp")
    .ValidateDataAnnotations()
    .Validate(o => !o.UseTls || o.Port != 25, "TLS on port 25 is misconfigured")
    .ValidateOnStart();

// Singleton that reacts to config changes: monitor, not snapshot
public sealed class MailerHealth(IOptionsMonitor<SmtpOptions> smtp) : IDisposable
{
    private readonly IDisposable? _sub = smtp.OnChange(o =>
        Console.WriteLine($"SMTP now {o.Host}:{o.Port}"));

    public SmtpOptions Current => smtp.CurrentValue;
    public void Dispose() => _sub?.Dispose();
}
Q41

What makes ILogger logging 'structured', and what do LoggerMessage source generators improve?

IntermediateObservability

Answer

Structured logging means the message template and its named placeholders travel as data, not as a flattened string: _log.LogInformation("Order {OrderId} charged {Amount}", id, amt) stores the template plus an OrderId and Amount property, so sinks like Seq, Elastic, or an OTLP backend can query where OrderId = 'X-42' across millions of events; string interpolation ($"Order {id}") destroys that structure and defeats template-based deduplication, which is why analyzer CA2254 flags non-constant templates. The ILogger<T> abstraction in Microsoft.Extensions.Logging fans out to providers (console, OpenTelemetry, Serilog as a provider), carries EventIds, and supports scopes (BeginScope) that stamp ambient properties like RequestId on everything inside, ASP.NET Core adds request scopes automatically, and correlation across services rides on Activity/trace-id integration, which OpenTelemetry exports. Level checks matter for cost: LogDebug with an interpolated string pays for formatting even when Debug is off; template-based calls defer formatting but still box value-type arguments into the object[] params array on every call.

That is the gap the LoggerMessage source generator closes: declare a partial method with [LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} charged {Amount}")] and the generator emits a cached delegate that checks IsEnabled first, avoids the params array and boxing entirely, and enforces template/parameter agreement at compile time. On hot paths (per-request logging in a high-QPS service) this is a measurable allocation win and the pattern the runtime itself uses. Production practices worth volunteering: log levels are configured per category via the Logging section ("Microsoft.AspNetCore": "Warning" silences framework noise), high-cardinality values belong in properties rather than in the template text, exceptions go in the dedicated exception parameter (LogError(ex, template, args)) so sinks capture stack traces properly, and never log secrets, log redaction middleware and the Microsoft.Extensions.Compliance.Redaction package exist for exactly that.

public sealed partial class PaymentService(ILogger<PaymentService> log)
{
    // Source-generated: zero-alloc when disabled, compile-checked template
    [LoggerMessage(Level = LogLevel.Information,
        Message = "Order {OrderId} charged {Amount} via {Gateway}")]
    partial void LogCharged(string orderId, decimal amount, string gateway);

    [LoggerMessage(Level = LogLevel.Error,
        Message = "Charge failed for {OrderId} after {Attempts} attempts")]
    partial void LogChargeFailed(Exception ex, string orderId, int attempts);

    public async Task ChargeAsync(Order order)
    {
        using var _ = log.BeginScope(new Dictionary<string, object>
        {
            ["MerchantId"] = order.MerchantId,   // stamped on every log inside
        });
        try
        {
            await _gateway.ChargeAsync(order);
            LogCharged(order.Id, order.Total, "razorpay");
        }
        catch (GatewayException ex)
        {
            LogChargeFailed(ex, order.Id, ex.Attempts);   // exception as data
            throw;
        }
    }
}
Q42

Why does new HttpClient() per request exhaust sockets, and how do IHttpClientFactory and SocketsHttpHandler fix it?

IntermediateNetworking

Answer

HttpClient is IDisposable, so developers new it up in a using per call, and that is precisely wrong: disposing the client closes its handler's pooled connections, and each closed TCP connection sits in TIME_WAIT for the OS-configured duration. Under load, a service doing this bleeds thousands of TIME_WAIT sockets until outbound calls fail with SocketException (address exhaustion), a classic incident. The opposite naive fix, one static HttpClient forever, hits the second trap: connections live indefinitely, so DNS changes (a failover flipping a CNAME, blue-green swaps) are never observed and the client keeps talking to a dead or wrong IP.

IHttpClientFactory (services.AddHttpClient) resolves both by pooling and recycling the underlying handlers: HttpClient instances handed out are cheap wrappers, the shared HttpMessageHandler pool keeps connections alive across requests, and handlers rotate by default every two minutes so DNS is re-resolved. Typed clients (AddHttpClient<GitHubClient>(c => c.BaseAddress = ...)) give you injectable, configured clients; named clients cover multi-endpoint cases; and the DelegatingHandler chain is where cross-cutting concerns live (auth token attachment, logging, and resilience: the Microsoft.Extensions.Http.Resilience package's AddStandardResilienceHandler wires Polly-based retry, circuit breaker, and timeout in one line, the current recommended pattern over hand-rolled Polly policies). If you are not using the factory, the direct alternative is a long-lived HttpClient over a SocketsHttpHandler with PooledConnectionLifetime = TimeSpan.FromMinutes(2), which achieves the same DNS-respecting recycling; this is the documented approach for singletons and console tools. Details that earn senior credit: HttpClient.Timeout covers the entire request including retries inside handlers, per-attempt timeouts belong in the resilience pipeline; HttpCompletionOption.ResponseHeadersRead streams large bodies instead of buffering; and response messages should still be disposed to return connections promptly.

// Typed client + standard resilience (retry, circuit breaker, timeouts)
builder.Services.AddHttpClient<PayoutClient>(c =>
    {
        c.BaseAddress = new Uri("https://api.partner-bank.example");
        c.Timeout = TimeSpan.FromSeconds(30);       // overall budget
    })
    .AddStandardResilienceHandler();                 // Microsoft.Extensions.Http.Resilience

public sealed class PayoutClient(HttpClient http)
{
    public async Task<PayoutStatus> GetStatusAsync(string id, CancellationToken ct)
    {
        using var resp = await http.GetAsync($"/payouts/{id}",
            HttpCompletionOption.ResponseHeadersRead, ct);
        resp.EnsureSuccessStatusCode();
        return (await resp.Content.ReadFromJsonAsync<PayoutStatus>(ct))!;
    }
}

// Without the factory (console tools, singletons): recycle connections yourself
private static readonly HttpClient Shared = new(new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(2),   // re-resolve DNS
});
Q43

How do you structure unit and integration tests for an ASP.NET Core service with xUnit, NSubstitute, and WebApplicationFactory?

IntermediateTesting

Answer

The 2026 default stack is xUnit (facts and theories), NSubstitute or Moq for test doubles, FluentAssertions or the built-in Assert for assertions, and WebApplicationFactory<Program> plus Testcontainers for integration tests. Unit tests target domain and service classes through their constructor-injected interfaces: [Fact] for single cases, [Theory] with [InlineData]/[MemberData] for tables; xUnit creates a fresh test-class instance per test (constructor is your setup, IDisposable your teardown), shares expensive state via IClassFixture<T>, and runs test classes in parallel by default, which flushes out hidden static state, a feature, not a nuisance. With NSubstitute, Substitute.For<IPaymentGateway>() plus gateway.ChargeAsync(...).Returns(...) arranges behavior and await gateway.Received(1).ChargeAsync(...) verifies interaction; the discipline interviewers look for is mocking only boundaries you own (repositories, gateways, clocks) and asserting outcomes over interactions wherever possible, tests that merely mirror the implementation's call sequence break on every refactor while catching nothing.

Time is a boundary too: inject TimeProvider (.NET 8's abstraction) and use Microsoft.Extensions.TimeProvider.Testing's FakeTimeProvider to make token-expiry and scheduling logic deterministic. Integration tests boot the real pipeline in memory: WebApplicationFactory<Program> spins the app (needing the public partial class Program { } marker with top-level statements), WithWebHostBuilder + ConfigureTestServices swaps real dependencies for fakes or points EF Core at a Testcontainers-launched PostgreSQL/SQL Server, real SQL, real migrations, so provider-specific behavior (translations, constraints) is actually exercised, where the InMemory provider famously lies (no relational semantics, no transactions). CreateClient() returns an HttpClient wired to the in-memory server, and tests assert status codes, response contracts, and database effects end to end. Round out with: one assertion concept per test, test names stating scenario and expectation, and coverage measured but not worshipped, an untested error path matters more than a 90% line-coverage badge.

public class PaymentServiceTests
{
    private readonly IPaymentGateway _gateway = Substitute.For<IPaymentGateway>();
    private readonly FakeTimeProvider _clock = new(DateTimeOffset.Parse("2026-01-01T00:00Z"));

    [Theory]
    [InlineData(3, true)]   // third retry succeeds within budget
    [InlineData(5, false)]  // exceeds retry budget
    public async Task Charge_respects_retry_budget(int failures, bool expectSuccess)
    {
        _gateway.ChargeAsync(Arg.Any<Order>())
            .Returns(_ => failures-- > 1
                ? Task.FromException<Receipt>(new GatewayTimeoutException())
                : Task.FromResult(new Receipt("ok")));

        var sut = new PaymentService(_gateway, _clock, maxRetries: 3);
        var result = await sut.ChargeAsync(TestOrders.Valid());

        result.Succeeded.Should().Be(expectSuccess);
    }
}

public class OrdersApiTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Post_without_auth_returns_401()
    {
        var client = factory.CreateClient();
        var resp = await client.PostAsJsonAsync("/api/orders", new { total = 100 });
        resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
    }
}
Q44

System.Text.Json vs Newtonsoft.Json: behavior differences, JsonSerializerOptions pitfalls, and when source generation is required.

IntermediateSerialization

Answer

System.Text.Json (STJ) is the default in ASP.NET Core and the performance winner: UTF-8-native (no string round trip), Span-based, low-allocation, with async streaming APIs (DeserializeAsyncEnumerable). But it is deliberately stricter than Newtonsoft, and migrations trip over the differences: STJ is case-sensitive by default outside ASP.NET Core's defaults (web defaults set PropertyNameCaseInsensitive and camelCase naming), does not serialise fields unless IncludeFields is set, historically threw on reference cycles unless ReferenceHandler.Preserve or IgnoreCycles is configured (Newtonsoft could silently handle more), has no direct equivalent of Newtonsoft's contract resolver flexibility (its counterpart is TypeInfoResolver modifiers), and treats numbers-in-quotes as errors unless NumberHandling.AllowReadingFromString is on, a classic breakage when a JS frontend sends "42". Polymorphism went from Newtonsoft's risky TypeNameHandling (a deserialization RCE foot-gun) to STJ's explicit, safe [JsonPolymorphic]/[JsonDerivedType] discriminators.

The top production pitfall is options caching: JsonSerializerOptions builds and caches type metadata, so constructing a new options instance per call throws that cache away and tanks throughput; create options once (static readonly, or JsonSerializerOptions.Web in .NET 9+ for the standard web defaults) and reuse. Custom converters (JsonConverter<T>) cover the genuinely custom cases, registered on options or via attribute. Source generation is the second half: annotating a partial JsonSerializerContext with [JsonSerializable(typeof(OrderDto))] makes the generator emit serialisation code at compile time, removing startup reflection cost and, decisively, making serialisation work under Native AOT and aggressive trimming, where reflection-based STJ fails with 'metadata for type was not provided'; AOT'd minimal APIs require wiring TypeInfoResolver to your context. Newtonsoft remains the pragmatic choice when you depend on its ecosystem (JObject-heavy code, JSON Schema, OData quirks) via AddNewtonsoftJson, but new services default to STJ, and interviewers expect you to know why and where it bites.

// Cache options ONCE: per-call construction destroys metadata caching
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
    NumberHandling = JsonNumberHandling.AllowReadingFromString,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};

// Safe polymorphism: explicit discriminators, no TypeNameHandling RCE risk
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(UpiPayment), "upi")]
[JsonDerivedType(typeof(CardPayment), "card")]
public abstract record Payment(decimal Amount);
public record UpiPayment(decimal Amount, string Vpa) : Payment(Amount);
public record CardPayment(decimal Amount, string Last4) : Payment(Amount);

// Source generation: compile-time metadata, required for Native AOT
[JsonSerializable(typeof(Payment))]
[JsonSerializable(typeof(List<OrderDto>))]
public partial class AppJsonContext : JsonSerializerContext { }

builder.Services.ConfigureHttpJsonOptions(o =>
    o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
Q45

Reflection vs source generators in modern .NET: why is the platform moving compile-time, and how does a source generator run?

IntermediateMetaprogramming

Answer

Reflection reads and invokes type metadata at runtime: typeof(T).GetProperties(), Activator.CreateInstance, MethodInfo.Invoke, plus Reflection.Emit for generating IL on the fly. It powers classic serializers, DI containers, ORMs, and mocking frameworks, and it has three growing costs: per-call overhead (member lookups and Invoke are orders slower than direct calls, mitigated by caching MemberInfo and building compiled delegates via expression trees or CreateDelegate), startup cost (scanning assemblies), and, decisively in 2026, incompatibility with trimming and Native AOT, the trimmer cannot see what reflection will touch, so members get removed and reflection-heavy code fails at runtime with MissingMetadataException-style errors unless annotated with [DynamicallyAccessedMembers] or preserved via descriptors. Source generators are the platform's answer: a generator is an analyzer-like component (IIncrementalGenerator) that runs inside the compiler, inspects the syntax trees and semantic model of your code, and emits additional C# source compiled into the same assembly.

Everything happens at build time, so the output is plain, fast, trimmable, AOT-safe code, and errors surface as build diagnostics rather than runtime surprises. You have already used them even if you never wrote one: System.Text.Json's JsonSerializerContext, the [LoggerMessage] logging generator, Regex via [GeneratedRegex] (compiles the pattern to code, no runtime regex compilation), P/Invoke's [LibraryImport] replacing runtime marshalling stubs, ASP.NET Core's Request Delegate Generator for AOT minimal APIs, and mapper generators like Mapperly and Riok replacing runtime AutoMapper reflection. Writing one means implementing IIncrementalGenerator with a pipeline: a predicate/transform over syntax nodes (ForAttributeWithMetadataName is the fast path), then RegisterSourceOutput emitting code with context.AddSource; incremental caching keeps IDE typing responsive. The honest trade-offs to state: generators cannot modify existing code (only add), debugging them is clunky, and they run on every keystroke in the IDE so a slow generator degrades the whole team's editor, but for the serialise/log/map/marshal class of problems, compile-time generation is now the default answer and 'reflection, cached aggressively, behind an interface' is the fallback.

// Consuming generators you get for free:

// 1. Compile-time regex: no Regex constructor cost, AOT-safe
public static partial class Validators
{
    [GeneratedRegex(@"^[6-9]\d{9}$")]                 // Indian mobile format
    public static partial Regex IndianMobile();
}
bool ok = Validators.IndianMobile().IsMatch("9876543210");

// 2. Reflection done tolerably when you must: cache + compiled delegate
private static readonly ConcurrentDictionary<Type, Func<object>> Factories = new();

public static object Create(Type t) =>
    Factories.GetOrAdd(t, type =>
        System.Linq.Expressions.Expression
            .Lambda<Func<object>>(
                System.Linq.Expressions.Expression.Convert(
                    System.Linq.Expressions.Expression.New(type), typeof(object)))
            .Compile())();   // ~direct-call speed after first hit

// 3. Trimming-safety annotation when reflection is unavoidable
public static string Dump(
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
    Type type) => string.Join(", ", type.GetProperties().Select(p => p.Name));
Q46

Task.WhenAll vs Parallel.ForEachAsync vs PLINQ: which tool for which kind of parallel work?

IntermediateConcurrency

Answer

The split is I/O-bound versus CPU-bound, and bounded versus unbounded concurrency. Task.WhenAll composes already-asynchronous work: kick off N I/O operations and await them together; no threads are occupied while I/O is in flight. Its sharp edges: starting one task per item over a large collection is an unbounded fan-out that can hammer a downstream API or exhaust connections (self-inflicted DDoS); on failure WhenAll awaits all tasks and then throws only the first exception, the rest hide in the returned task's Exception.InnerExceptions (or use the .NET 9+ Task.WhenEach to process completions as they finish); and tasks are hot on creation, so Select(x => DoAsync(x)).ToList() has already started everything.

For bounded async concurrency, Parallel.ForEachAsync (.NET 6+) is purpose-built: it partitions the source, runs the async body with MaxDegreeOfParallelism you set explicitly, flows a CancellationToken to each iteration, and completes when all are done, the right shape for 'call this API for 10,000 rows, 8 at a time'; the older DIY equivalent is SemaphoreSlim as a throttle around WhenAll. PLINQ (AsParallel) is for CPU-bound, in-memory computation: it splits a query across cores with WithDegreeOfParallelism, unordered by default (AsOrdered restores order at a cost), and must never wrap async calls or I/O, blocking ThreadPool threads on I/O inside PLINQ is a starvation pattern. Plain Parallel.For/ForEach similarly targets synchronous CPU work. Decision rules to state crisply: pure fan-out of a handful of independent async calls, WhenAll; large async workloads needing a concurrency cap, Parallel.ForEachAsync; CPU-heavy transforms of in-memory data, PLINQ or Parallel.For; and never Task.Run per item for I/O (threads add nothing to I/O) nor parallelism inside an already-loaded web server without measuring, because request-level concurrency is usually the parallelism you have.

// Small fan-out of independent I/O: WhenAll
var (profile, orders, credits) = await TaskTupleAwait(
    _users.GetAsync(id, ct), _orders.ListAsync(id, ct), _wallet.BalanceAsync(id, ct));

// Large async workload with an explicit cap: Parallel.ForEachAsync
await Parallel.ForEachAsync(candidateIds,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
    async (candidateId, token) =>
    {
        var score = await _scoringApi.ScoreAsync(candidateId, token);
        await _db.SaveScoreAsync(candidateId, score, token);
    });

// CPU-bound transform: PLINQ, no I/O inside
var histogram = resumeTexts
    .AsParallel()
    .WithDegreeOfParallelism(Environment.ProcessorCount)
    .SelectMany(Tokenize)
    .GroupBy(t => t)
    .ToDictionary(g => g.Key, g => g.Count());

// WhenAll failure detail: only the FIRST exception is thrown;
// inspect the task for the rest
var all = Task.WhenAll(tasks);
try { await all; }
catch { foreach (var ex in all.Exception!.InnerExceptions) _log.LogError(ex, "item failed"); }
Q47

DateTime vs DateTimeOffset vs DateOnly, and how does TimeProvider make time-dependent code testable?

IntermediateFundamentals

Answer

DateTime is a tick count plus a three-state Kind (Utc, Local, Unspecified), and Kind is where bugs breed: two structurally identical DateTimes with different Kinds compare equal on ticks but mean different instants; DateTime.Parse gives Unspecified unless the string carries an offset; and serialisation round trips through JSON or a database routinely lose Kind, after which ToUniversalTime() silently applies the server's timezone to a value that was already UTC, the classic 'timestamps off by 5:30' bug on India-hosted systems. DateTimeOffset removes the ambiguity by storing the UTC offset with the value: it always identifies an exact instant, compares correctly across offsets, and is the right type for event timestamps, audit logs, and APIs; the offset it carries is not a timezone (IST's +05:30 does not say 'India', and offsets with DST change over the year), so when you need timezone semantics you pair a UTC instant with a TimeZoneInfo (FindSystemTimeZoneById("India Standard Time" on Windows, "Asia/Kolkata" on Linux; .NET 6+ can translate between the two conventions) and convert at display time via TimeZoneInfo.ConvertTime. The storage discipline to state: persist UTC (or DateTimeOffset), convert at the edge; schedule future local-time events (a 9 AM reminder) by storing local time plus IANA zone id, because the UTC equivalent of a future local time can change when governments change the rules.

DateOnly and TimeOnly (.NET 6+) end the hack of smuggling dates in midnight DateTimes: a birthdate or a shift start has no instant semantics, and EF Core maps them to SQL date/time types cleanly. Finally, testability: static DateTime.UtcNow makes expiry logic untestable, and .NET 8's TimeProvider abstraction fixes it, inject TimeProvider.System in production, FakeTimeProvider in tests, and time-travel with fake.Advance(TimeSpan.FromMinutes(30)); it also powers testable timers (CreateTimer) and Task.Delay overloads, so token-expiry, cache-TTL, and scheduler code become deterministic under xUnit rather than sleep-flaky.

// Store the instant unambiguously; convert at the display edge
DateTimeOffset paidAt = DateTimeOffset.UtcNow;
var ist = TimeZoneInfo.FindSystemTimeZoneById("Asia/Kolkata"); // Linux id; .NET maps on Windows
DateTimeOffset shownToUser = TimeZoneInfo.ConvertTime(paidAt, ist);

// Date without an instant: DateOnly, not midnight DateTime
DateOnly dob = new(1998, 11, 23);
int age = DateOnly.FromDateTime(DateTime.UtcNow).Year - dob.Year;

// Testable time (.NET 8): inject TimeProvider, never DateTime.UtcNow directly
public sealed class OtpService(TimeProvider clock)
{
    public Otp Issue() => new(Code: Random.Shared.Next(100000, 999999).ToString(),
                              ExpiresAt: clock.GetUtcNow().AddMinutes(5));
    public bool IsValid(Otp otp) => clock.GetUtcNow() <= otp.ExpiresAt;
}

// In tests: var clock = new FakeTimeProvider();
// clock.Advance(TimeSpan.FromMinutes(6));  -> IsValid becomes false, deterministically
Q48

How do BackgroundService and IHostedService work, and what are the rules for long-running work in ASP.NET Core?

IntermediateASP.NET Core

Answer

IHostedService is the host lifecycle contract: StartAsync runs during application startup, StopAsync during graceful shutdown. BackgroundService is the standard base class layering a long-running ExecuteAsync(CancellationToken stoppingToken) on top; the token fires when shutdown begins, and the host waits a bounded shutdown timeout (configurable via HostOptions.ShutdownTimeout, default 30 seconds; container orchestrators send SIGTERM then SIGKILL on their own schedule) for your loop to drain. The operational rules that separate working services from incident reports: first, ExecuteAsync must yield quickly, code before the first await runs inline during startup and can block the host from starting (await Task.Yield() or structure the loop so awaits come early); second, an unhandled exception in ExecuteAsync stops that service silently in older runtimes, while .NET 6+ defaults BackgroundServiceExceptionBehavior to StopHost, meaning one crashing worker takes the process down, which is usually what you want in Kubernetes (restart and alert) but must be a conscious choice; wrap the loop body in try/catch for per-iteration resilience and let truly fatal states escape.

Third, DI lifetimes: hosted services are singletons resolved from the root, so scoped dependencies (DbContext) must come from an IServiceScopeFactory scope per iteration, never the constructor. Fourth, scheduling: PeriodicTimer with await timer.WaitForNextTickAsync(stoppingToken) is the modern loop primitive, no drift-prone Task.Delay arithmetic, no overlapping timer callbacks like System.Timers.Timer; for cron semantics teams use Quartz.NET or hosted Hangfire. Fifth, work distribution: an in-process worker competes with request handling for the same ThreadPool and dies with the pod, so heavy or must-not-lose jobs belong in a separate Worker Service deployment (dotnet new worker) consuming a durable queue (SQS, RabbitMQ, Kafka), with the in-process Channel<T>-drained worker reserved for best-effort tasks like cache warming and email fan-out where losing in-flight items on deploy is acceptable.

public sealed class InvoiceReminderWorker(
    IServiceScopeFactory scopes,
    ILogger<InvoiceReminderWorker> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // yield before any heavy sync work: do not stall host startup
        await Task.Yield();

        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(15));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                // scoped deps per iteration: singletons cannot hold DbContext
                await using var scope = scopes.CreateAsyncScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

                var due = await db.Invoices
                    .Where(i => !i.Paid && i.DueDate <= DateTime.UtcNow)
                    .Take(100)
                    .ToListAsync(stoppingToken);

                foreach (var invoice in due)
                    await SendReminderAsync(invoice, stoppingToken);
            }
            catch (OperationCanceledException) { throw; }   // shutdown: let it end
            catch (Exception ex)
            {
                log.LogError(ex, "Reminder sweep failed; continuing next tick");
            }
        }
    }
}
Q49

Which C# 12, 13, and 14 features actually change how production code is written, and what should you know about each?

AdvancedModern C#

Answer

Interviewers use this to separate candidates who read release notes from those who ship on current tooling. C# 12 (.NET 8): collection expressions unify construction, int[] a = [1, 2, 3], List<int> l = [..a, 4] with the spread operator, working across arrays, spans, and any type with a collection builder; primary constructors on non-record classes (covered earlier) reshape DI-heavy services; alias any type (using Point = (int X, int Y);) makes tuple aliases usable; and [InlineArray] enables fixed-size struct buffers the runtime and serializers exploit. C# 13 (.NET 9): params collections extend params beyond arrays, params ReadOnlySpan<T> means variadic calls can be allocation-free, a real API-design change since callers no longer pay an array per call; the System.Threading.Lock type with its dedicated lock codegen; partial properties (generators can now implement property declarations, which regex and mapping generators use); and overload resolution priority for library authors steering callers between span and array overloads.

C# 14 (.NET 10): the field keyword ends the boilerplate backing-field dance, public int Age { get => field; set => field = value >= 0 ? value : throw new ArgumentOutOfRangeException(); } keeps auto-property brevity with validation; extension members generalise extension methods to properties and static members; null-conditional assignment (customer?.Address = value) assigns only when the receiver is non-null; nameof works with unbound generics (nameof(List<>)); and first-class span conversions make more code silently pick span overloads, an occasional source of subtle overload-resolution changes when recompiling old code. The honest framing to give: none of these are syntax candy alone, collection expressions plus span params plus field-backed properties measurably cut allocations and boilerplate, and knowing which C# version pairs with which .NET release (12/8, 13/9, 14/10, with LangVersion overridable but runtime-feature-gated) signals you manage real upgrade trains.

// C# 12: collection expressions + spread
int[] base3 = [1, 2, 3];
List<int> extended = [..base3, 4, 5];
ReadOnlySpan<char> vowels = ['a', 'e', 'i', 'o', 'u'];

// C# 13: params ReadOnlySpan<T>: variadic without the array allocation
public static int SumAll(params ReadOnlySpan<int> values)
{
    int total = 0;
    foreach (var v in values) total += v;
    return total;
}
var s = SumAll(1, 2, 3, 4);   // no int[] allocated

// C# 14: field keyword: validation without a named backing field
public class Listing
{
    public decimal Price
    {
        get => field;
        set => field = value >= 0
            ? value
            : throw new ArgumentOutOfRangeException(nameof(value));
    }
}

// C# 14: null-conditional assignment
profile?.LastSeenUtc = DateTime.UtcNow;   // no-op when profile is null
Q50

What does Native AOT actually do to a .NET application, and what breaks when you enable it?

AdvancedDeployment & AOT

Answer

PublishAot=true compiles your app ahead of time to a single native executable: no JIT, no IL, the runtime (GC, type system essentials) linked in, and aggressive whole-program trimming that removes everything not provably reachable. The wins are concrete: startup in milliseconds rather than JIT-warmup seconds (decisive for serverless cold starts and CLI tools), a fraction of the memory footprint (no JIT data structures, smaller code), no .NET runtime dependency on the box, and a smaller attack surface. What breaks is everything that assumes runtime code generation or unbounded reflection: Reflection.Emit and dynamic method generation are impossible (no JIT), so classic AutoMapper, Castle DynamicProxy-based mocking and interception, and expression-tree Compile() fall back to interpretation or fail; reflection over members the trimmer removed throws at runtime, which is why AOT-compatible libraries carry [DynamicallyAccessedMembers] annotations and why serialisation must go through source generators (System.Text.Json's JsonSerializerContext); assembly loading at runtime (plugins via Assembly.LoadFrom) is out; and generic virtual methods plus unconstrained generic recursion can hit compile-time specialisation limits.

The ecosystem status you should know: console apps, Worker Services, and gRPC/minimal API services are the supported sweet spot (ASP.NET Core minimal APIs work through the Request Delegate Generator; MVC controllers, Razor Pages, and SignalR remain unsupported for AOT), EF Core is not AOT-ready (Dapper's AOT-friendly source-generated mode exists), and the diagnostics story trades dotnet-monitor richness for native profilers plus DOTNET_gcServer-style env-var knobs that still work. Process guidance interviewers want: turn on the analyzers first (IsAotCompatible / EnableTrimAnalyzer, warnings IL2026/IL2075/IL3050 family), fix or annotate every warning, prefer source-generated serialisation, logging, and regex, and validate with real integration tests on the published binary, because trimming failures are runtime failures by nature. If the app leans on reflection-heavy frameworks, the honest architecture answer is ReadyToRun (AOT-precompiled but JIT-backed) or plain JIT with tiered compilation, not forcing AOT.

<!-- .csproj: opt in and surface AOT/trim warnings during development -->
<PropertyGroup>
  <PublishAot>true</PublishAot>
  <IsAotCompatible>true</IsAotCompatible>
  <InvariantGlobalization>true</InvariantGlobalization>
  <StripSymbols>true</StripSymbols>
</PropertyGroup>

# Publish a native single binary for containerised Linux
dotnet publish -c Release -r linux-x64

// AOT-safe minimal API: source-generated JSON wired explicitly
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.ConfigureHttpJsonOptions(o =>
    o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));

var app = builder.Build();
app.MapGet("/health", () => Results.Ok(new HealthDto("ok")));
app.Run();

[JsonSerializable(typeof(HealthDto))]
public partial class AppJsonContext : JsonSerializerContext { }
public record HealthDto(string Status);
Q51

A .NET API's p99 latency spikes to seconds under load while CPU stays low. Walk through diagnosing ThreadPool starvation.

AdvancedProduction Diagnostics

Answer

Low CPU with terrible latency is the signature of ThreadPool starvation: worker threads are blocked (not busy), queued work piles up, and the pool injects new threads only at roughly one per second beyond its ready count, so a burst of blocking requests stalls everything, including timer callbacks and continuations that would have completed the blocked work. Confirm before fixing: dotnet-counters monitor -p <pid> System.Runtime and watch threadpool-queue-length (sustained growth is the tell), threadpool-thread-count climbing steadily (injection lag), and cpu-usage staying modest. Capture proof of where threads sit: dotnet-stack report or dotnet-dump collect then clrstacks in dotnet-dump analyze; starvation dumps show dozens of threads parked in Monitor.Wait or Task.Wait inside HttpClient/DbCommand paths.

The usual causes, in observed frequency order: sync-over-async (.Result, .Wait(), .GetAwaiter().GetResult() on hot request paths, sometimes buried in a library or a constructor that cannot be async), blocking I/O labelled as fast (synchronous File.ReadAllText of a 'small' config per request, a synchronous logging sink flushing to disk), lock convoys around a hot cache entry, and unbounded parallel fan-out that saturates connection pools so everything waits. Fixes in the same order: make the path async end to end (the only real cure; ConfigureAwait(false) in libraries reduces deadlock coupling but does not unblock threads), move truly synchronous CPU work to bounded queues, replace lazy caches with async-aware ones (Lazy<Task<T>> or a per-key SemaphoreSlim, avoiding the GetOrAdd duplicate-factory stampede), and cap fan-out with Parallel.ForEachAsync. Mitigations to name honestly as mitigations: ThreadPool.SetMinThreads raises the injection floor and buys time at the cost of memory and context switching; it is a tourniquet, not a fix. Post-incident, prevent regression: ban the blocking calls with analyzers (the Microsoft.VisualStudio.Threading analyzers' VSTHRD002 family or banned-API lists), add a threadpool-queue-length alert, and load-test the specific endpoint mix that caused the spiral, because starvation is a threshold effect invisible at half the traffic.

# 1. Confirm: queue growing, threads climbing ~1/sec, CPU low
dotnet-counters monitor -p 1234 System.Runtime
#   threadpool-queue-length      1450   <- sustained growth = starvation
#   threadpool-thread-count      187
#   cpu-usage                    11

# 2. Where are threads stuck?
dotnet-dump collect -p 1234
dotnet-dump analyze core_20260811 -c clrstacks
#   ...dozens of frames in Task.Wait / Monitor.ReliableEnter under
#   LegacyPdfRenderer.Render -> HttpClient.Send -> .Result

// 3. The culprit pattern:
public Report Generate(Guid id)
{
    // blocks a pool thread; its continuation needs... a pool thread
    var data = _api.FetchAsync(id).Result;      // BUG
    return Render(data);
}

// 4. The cure: async end to end, no thread parked during I/O
public async Task<Report> GenerateAsync(Guid id, CancellationToken ct)
{
    var data = await _api.FetchAsync(id, ct);
    return Render(data);
}
Q52

How do you use BenchmarkDotNet correctly, and what are the standard techniques for driving allocations out of a hot path?

AdvancedPerformance

Answer

BenchmarkDotNet is the community-standard harness because hand-rolled Stopwatch loops measure the wrong things: they time JIT compilation and tiering, ignore warmup, let dead-code elimination delete the work, and report means without distribution. BenchmarkDotNet runs each [Benchmark] in a separate process, performs pilot and warmup phases so tiered compilation and dynamic PGO settle, prevents dead-code elimination by consuming return values, and reports mean, error, standard deviation, and, with [MemoryDiagnoser], allocated bytes per operation and Gen 0/1/2 collection rates, which for server workloads is often the number that matters, since allocation rate drives GC pauses that surface as p99 latency. Practice essentials: always benchmark Release builds (it refuses Debug), use [Params] to sweep input sizes, [Benchmark(Baseline = true)] for ratio columns, GlobalSetup for one-time state, and compare runtimes with [SimpleJob(RuntimeMoniker...)] when validating a .NET upgrade.

Then the optimisation toolbox, in the order actually applied: eliminate hidden allocations (LINQ chains in hot loops become foreach; closures capturing locals become static lambdas with state parameters, enforced by the static lambda modifier; params arrays become params ReadOnlySpan<T>; string concatenation becomes string.Create or cached formats; interface-typed enumeration of List<T> boxes the struct enumerator, so type variables concretely); reuse buffers via ArrayPool<T>.Shared with try/finally Return (and clearArray: true for sensitive data), RecyclableMemoryStream for stream churn, and ObjectPool<T> (Microsoft.Extensions.ObjectPool) for expensive reference types like StringBuilder; move parsing and formatting to Span-based APIs (Utf8Parser, ISpanFormattable, TryFormat) so no intermediate strings exist; consider struct-based enumerables and readonly structs for tiny hot values; and only after measurement, reach for stackalloc, [SkipLocalsInit], and vectorisation via Vector<T> or the TensorPrimitives/SIMD intrinsics. Close with process discipline: optimise what a profile (dotnet-trace, PerfView, or the BenchmarkDotNet EventPipeProfiler diagnoser) says is hot, keep a benchmark project in the repo so regressions are diffable in PRs, and treat a 'faster' change that increases allocated bytes per op with suspicion, it often trades throughput for tail latency.

[MemoryDiagnoser]
public class SlugBenchmarks
{
    [Params(10, 1000)]
    public int Length;

    private string _title = "";

    [GlobalSetup]
    public void Setup() => _title = string.Join(' ', Enumerable.Repeat("Senior C# Engineer Pune", Length / 4));

    [Benchmark(Baseline = true)]
    public string Naive() =>
        string.Join("-", _title.ToLower().Split(' ', StringSplitOptions.RemoveEmptyEntries));

    [Benchmark]
    public string Pooled()
    {
        var buffer = System.Buffers.ArrayPool<char>.Shared.Rent(_title.Length);
        try
        {
            int written = 0;
            bool pendingDash = false;
            foreach (var ch in _title.AsSpan())
            {
                if (char.IsWhiteSpace(ch)) { pendingDash = written > 0; continue; }
                if (pendingDash) { buffer[written++] = '-'; pendingDash = false; }
                buffer[written++] = char.ToLowerInvariant(ch);
            }
            return new string(buffer, 0, written);   // single allocation: the result
        }
        finally { System.Buffers.ArrayPool<char>.Shared.Return(buffer); }
    }
}
// dotnet run -c Release --project benchmarks
// Report: Mean / Ratio / Gen0 / Allocated per op: the Allocated column
// dropping from KBs to bytes is the tail-latency story.
Q53

readonly struct, ref struct, and the in modifier: how do defensive copies happen and how do you prevent them?

AdvancedPerformance

Answer

The compiler must guarantee that a readonly view of a struct is never mutated, and since any method or property getter on a struct might mutate this, calling one through a readonly reference forces the compiler to copy the whole struct first and invoke the member on the copy. These defensive copies are silent, per call, and proportional to struct size, so a 'harmless' 40-byte struct accessed through a readonly field inside a loop can quietly dominate a profile. The triggers to enumerate: members invoked on readonly fields of struct type, on in parameters, and on ref readonly locals or returns; foreach iteration variables are readonly too.

Two declarations eliminate the problem at the type level: readonly struct promises the compiler no member mutates state (all fields readonly, enforced), so calls through readonly references need no copy ever, which is why Guid, DateTime, and your value objects should be readonly structs; for structs that must stay mutable, C# 8+ lets you mark individual members readonly (readonly override string ToString()), restoring copy-free calls member by member, and the compiler warns when a readonly member touches mutable state. The in modifier passes by readonly reference: for large structs it avoids the pass-by-value copy at the call site, but pairs dangerously with non-readonly structs (every member call inside makes a defensive copy, potentially worse than plain by-value); the guidance is in + readonly struct together, or neither, and note calls with in are chosen by overload resolution even without the keyword at the call site. ref struct is the orthogonal concept: a type forced to live on the stack (Span<T>, ReadOnlySpan<T>, Utf8JsonReader, and since C# 13 usable in more generic and async positions via allows ref struct and relaxed iterator rules), existing so interior pointers never escape to the heap; ref structs cannot be boxed, cannot be fields of classes, and combine as readonly ref struct for immutable stack-only views. Verification technique worth naming: inspect codegen on sharplab.io or benchmark with [MemoryDiagnoser] plus a size sweep; the hidden-copy cost shows up as call-site time scaling with struct size while the readonly struct version stays flat.

// BAD: mutable struct + readonly field = silent copy on EVERY call
public struct Stats { public long Hits; public double Ratio() => Hits / 100.0; }

public class Tracker
{
    private readonly Stats _stats;            // readonly view of mutable struct
    public double R() => _stats.Ratio();      // defensive copy of Stats here
}

// GOOD: readonly struct: provably non-mutating, never copied defensively
public readonly struct Stats2(long hits)
{
    public long Hits { get; } = hits;
    public double Ratio() => Hits / 100.0;    // no copy through readonly refs
}

// 'in' + large readonly struct: no call-site copy, no defensive copies
public readonly struct Matrix4x4Big { /* 64 bytes of floats */ }
public static float Determinant(in Matrix4x4Big m) => /* reads only */ 0f;

// ref struct: stack-only, interior pointers can never escape to the heap
public ref struct Utf8Cursor(ReadOnlySpan<byte> data)
{
    private ReadOnlySpan<byte> _rest = data;
    public bool TryReadByte(out byte b)
    {
        if (_rest.IsEmpty) { b = 0; return false; }
        b = _rest[0]; _rest = _rest[1..]; return true;
    }
}
Q54

How do expression trees power dynamic query building, and how would you build a safe dynamic filter for an EF Core endpoint?

AdvancedLINQ & Expressions

Answer

An Expression<Func<T, bool>> is code as data: the compiler emits a tree of Expression nodes (ParameterExpression, MemberExpression, BinaryExpression, MethodCallExpression) instead of IL, which providers like EF Core walk to generate SQL. That reification is why IQueryable composes into one SQL statement, and it is also an API you can drive yourself with the Expression factory methods: Expression.Parameter, Expression.Property, Expression.Equal, Expression.Lambda. The canonical production use case is a filter endpoint: the client sends field/operator/value triples (status eq Active, salary ge 1500000), and you translate them into a predicate at runtime, without string concatenation into SQL (injection-safe by construction, since values become ConstantExpressions that EF parameterises) and without writing a combinatorial explosion of Where overloads.

The implementation discipline that makes it safe: whitelist filterable properties explicitly (a dictionary from API field names to PropertyInfo or lambda accessors), never reflect over arbitrary client-supplied names, or a caller filters on PasswordHash or navigates into lazy-loaded graphs; validate operators per property type; convert values with the property's type (Expression.Constant(value, propertyType), minding Nullable<T> via Expression.Convert); and combine predicates with Expression.AndAlso over a shared ParameterExpression, using an ExpressionVisitor to rebind parameters when composing lambdas built separately (the classic PredicateBuilder problem: two lambdas each have their own ParameterExpression, and naive AndAlso produces 'variable x referenced from scope' errors). Two more things interviewers dig into: compilation, .Compile() turns a tree into a delegate via Lambda compilation (Reflection.Emit; interpreted fallback where Emit is unavailable, and note AOT constraints), so caching compiled delegates makes reflection-speed property access approach direct calls; and the limits, expression trees cannot contain statements beyond a single expression in C# (no blocks, loops, or assignments from lambda syntax, though the factory API can build them), cannot represent async, and grow unreadable fast, which is why teams wrap this machinery in a small specification/filter layer with unit tests asserting both the generated SQL (ToQueryString()) and the results.

public static class JobFilters
{
    // Whitelist: client field name -> typed accessor. Nothing else is filterable.
    private static readonly Dictionary<string, Expression<Func<Job, object>>> Allowed =
        new(StringComparer.OrdinalIgnoreCase)
        {
            ["city"] = j => j.City,
            ["salary"] = j => j.AnnualSalaryInr,
            ["isRemote"] = j => j.IsRemote,
        };

    public static IQueryable<Job> ApplyEquals(
        IQueryable<Job> source, string field, string rawValue)
    {
        if (!Allowed.TryGetValue(field, out var accessor))
            throw new ValidationException($"cannot filter on '{field}'");

        // unwrap  j => (object)j.Prop  to the member access + its real type
        var body = accessor.Body is UnaryExpression u ? u.Operand : accessor.Body;
        var member = (MemberExpression)body;
        var propType = member.Type;

        var value = Convert.ChangeType(rawValue, Nullable.GetUnderlyingType(propType) ?? propType);
        var constant = Expression.Constant(value, propType);   // parameterised by EF

        var predicate = Expression.Lambda<Func<Job, bool>>(
            Expression.Equal(member, constant),
            accessor.Parameters);                               // reuse SAME parameter

        return source.Where(predicate);   // folds into the single SQL statement
    }
}

// q = JobFilters.ApplyEquals(db.Jobs, "city", "Pune");
// Console.WriteLine(q.ToQueryString());   // verify the SQL in tests
Q55

How does AssemblyLoadContext enable plugin architectures, and what causes the type-identity and unloading pitfalls?

AdvancedRuntime & Loading

Answer

AssemblyLoadContext (ALC) is .NET's replacement for the AppDomain isolation story: a named scope for loading assemblies with its own resolution logic and, optionally, collectible unloading. A plugin host creates a custom ALC per plugin, overrides Load(AssemblyName) to resolve the plugin's dependencies via an AssemblyDependencyResolver pointed at the plugin's .deps.json, and loads the plugin assembly inside it; this lets two plugins carry conflicting versions of the same dependency (plugin A with Newtonsoft 12, plugin B with 13) without clobbering each other or the host, which the single default context cannot do. The first pitfall is type identity: a Type is identified by assembly plus context, so if the plugin ALC loads its own copy of the contracts assembly containing IPlugin, the host's IPlugin and the plugin's IPlugin are different types, casts fail with the maddening InvalidCastException 'Unable to cast object of type PluginImpl to type IPlugin' even though the names match.

The fix is ensuring shared contracts resolve to the host's copy: return null from Load for the contract assembly so resolution falls through to the default context (and keep the contract assembly version-stable). The second pitfall is unloading: constructing the ALC with isCollectible: true and calling Unload() only releases the assemblies when nothing roots them, and roots hide everywhere: a static event in the host holding a plugin delegate, a cached MethodInfo, a Task still running plugin code, a JsonSerializerOptions caching plugin-type metadata. Diagnosing a stuck unload means taking a dotnet-gcdump and hunting the retention path to the LoadContext. Practical notes for full marks: hostfxr/hosting APIs and McMaster.NETCore.Plugins wrap this machinery for real products; collectible ALCs forbid some constructs (no thread statics pinned by design plans that outlive the ALC); Native AOT has no runtime loading at all, so plugin systems and AOT are architecturally incompatible; and for strong isolation (crash and security boundaries, resource caps) the modern answer is out-of-process plugins over IPC or containers, since ALCs isolate versioning, not security.

public sealed class PluginLoadContext(string pluginPath)
    : AssemblyLoadContext(name: pluginPath, isCollectible: true)
{
    private readonly AssemblyDependencyResolver _resolver = new(pluginPath);

    protected override Assembly? Load(AssemblyName name)
    {
        // Contracts must come from the HOST context or type identity splits
        if (name.Name == "MyApp.PluginContracts") return null;

        var path = _resolver.ResolveAssemblyToPath(name);
        return path is null ? null : LoadFromAssemblyPath(path);
    }
}

// Load, use, unload: keep the reference in a method so it can be collected
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference RunPlugin(string dllPath)
{
    var alc = new PluginLoadContext(dllPath);
    var asm = alc.LoadFromAssemblyPath(dllPath);
    var pluginType = asm.GetTypes().First(t => typeof(IPlugin).IsAssignableFrom(t));
    var plugin = (IPlugin)Activator.CreateInstance(pluginType)!;
    plugin.Execute();
    alc.Unload();                       // completes only when nothing roots it
    return new WeakReference(alc);
}

// for (int i = 0; alcRef.IsAlive && i < 10; i++) { GC.Collect(); GC.WaitForPendingFinalizers(); }
Q56

Your production .NET container's memory climbs until OOMKilled. Walk through the diagnosis with dotnet-counters, dotnet-gcdump, and dotnet-dump.

AdvancedProduction Diagnostics

Answer

First, classify the growth: dotnet-counters monitor -p 1 System.Runtime (PID 1 in most containers) and compare gc-heap-size against the container's working set (working-set counter, or kubectl top). If the working set grows while managed heap stays flat, the leak is not managed objects: suspect native memory (unreturned ArrayPool buffers pinned by native code, a native library, too many GC regions or thread stacks from an inflating ThreadPool) and the Server GC footprint itself, where the pre-DATAS answer in small pods was tuning DOTNET_GCHeapHardLimitPercent or switching to Workstation GC, and the current answer is confirming DATAS behavior (default in .NET 9+). If managed heap grows monotonically across Gen 2 collections, it is a managed leak, so capture comparative snapshots: dotnet-gcdump collect -p 1 twice, twenty minutes apart, and diff them in Visual Studio's managed memory analyzer or PerfView; the type whose count only rises is your suspect, and the retention graph shows who roots it.

The classic .NET rooting chains to name: event subscriptions on long-lived publishers (a singleton's event holding every scoped handler ever subscribed, the number-one C# leak), static collections and 'temporary' caches without eviction, IMemoryCache entries with no size limit or expiration (set SizeLimit and per-entry Size, or entries live forever), closures captured by timers and hosted-service loops, ConditionalWeakTable misuse, and the OTel/logging pipeline buffering because an exporter endpoint is down. When gcdump is not enough (need field values, or the process is wedged), take a full dump with dotnet-dump collect and inspect in dotnet-dump analyze: dumpheap -stat for the census, dumpheap -mt <mt> for instances, gcroot <addr> for the exact chain from root to leaked object, and eeheap -gc to see per-generation and LOH layout, LOH fragmentation shows as huge Free blocks and is fixed by pooling buffers rather than by tuning. Wrap up with the prevention posture: continuous dotnet-monitor or OTel runtime metrics (per-generation sizes, allocation rate, time-in-GC) with alerts on trend not threshold, gcdump capture automated on memory pressure, and a load test that runs long enough to expose slope, because a leak of 2 MB per thousand requests passes every ten-minute test and kills the pod on day three.

# 1. Managed heap or native? (inside the container / sidecar)
dotnet-counters monitor -p 1 System.Runtime
#   gc-heap-size (MB)        1840  <- climbing across Gen2 GCs = managed leak
#   working-set (MB)         2100
#   gen-2-gc-count           41

# 2. Two snapshots, twenty minutes apart, then diff by type count
dotnet-gcdump collect -p 1 -o /tmp/first.gcdump
dotnet-gcdump collect -p 1 -o /tmp/second.gcdump
#   diff: OrderPlacedHandler  +48,211 instances   <- suspect

# 3. Exact rooting chain from a full dump
dotnet-dump collect -p 1 -o /tmp/full.dmp
dotnet-dump analyze /tmp/full.dmp
> dumpheap -stat                    # census by type
> dumpheap -mt 00007f8a12c45e10     # addresses of suspect instances
> gcroot 00007f8b44aa01c8
#   -> static OrderEvents.Placed (event) -> OrderPlacedHandler
#   The singleton's event roots every handler ever subscribed.

// 4. The fix pattern: unsubscribe deterministically
public sealed class OrderNotifier : IDisposable
{
    public OrderNotifier(OrderEvents events) { _e = events; _e.Placed += OnPlaced; }
    public void Dispose() => _e.Placed -= OnPlaced;   // was missing
}
Q57

P/Invoke in modern .NET: what does [LibraryImport] change versus [DllImport], and how do you marshal safely?

AdvancedInterop

Answer

Classic [DllImport] generates its marshalling stub at runtime via an IL stub compiler: flexible, but invisible to the trimmer and impossible under Native AOT, with costs paid at first call. [LibraryImport] (.NET 7+) is the source-generated replacement: you declare a static partial method, the generator emits the complete marshalling code at compile time, giving AOT compatibility, inspectable stubs, analyzer-checked signatures (SYSLIB1054 nudges you off DllImport), and no runtime surprise. Its stricter surface is deliberate: string marshalling must be explicit (StringMarshalling.Utf8 or Utf16, no ANSI defaults), classic auto-marshaling of complex classes is unsupported, and custom types marshal through [CustomMarshaller] with marshaller types you write, pushing you toward blittable design where the struct's memory layout is identical on both sides ([StructLayout(LayoutKind.Sequential)], fields of primitive and blittable types), so the marshaller pins instead of copying.

The safety checklist interviewers want to hear: lifetimes, a native function must not keep a pointer past the call unless you allocate with NativeMemory.Alloc or pin with GCHandle.Alloc(obj, GCHandleType.Pinned) and free deterministically; ownership conventions, who frees what, expressed via SafeHandle subclasses so a handle is released exactly once even on thread aborts and finalizer paths (SafeHandle is also the correct P/Invoke parameter type, preventing handle-recycling races that raw IntPtr invites); errors, native errno/GetLastError via Marshal.GetLastPInvokeError with SetLastError = true, never exceptions across the boundary (unwinding native frames corrupts state; catch on the managed side of callbacks); callbacks, pass function pointers via [UnmanagedCallersOnly] static methods or keep delegate instances rooted for the native code's lifetime, since a collected delegate produces a crash when native code finally calls it; and character data, prefer Span<byte>/UTF-8 across the boundary to avoid per-call string copies. For bulk data, avoid marshalling entirely: fixed blocks or GCHandle-pinned arrays passed as pointers plus lengths, or memory-mapped files for really large exchanges. And mention the ecosystem reality: before hand-writing bindings, check for a maintained wrapper package, and for C++ libraries prefer a C shim layer, because C++ name mangling and object layout are not a stable ABI.

using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;

public static partial class LibArchive
{
    // Source-generated stub: AOT-safe, explicit UTF-8, explicit error capture
    [LibraryImport("archive", StringMarshalling = StringMarshalling.Utf8,
        SetLastError = true)]
    private static partial int archive_read_open_filename(
        ArchiveHandle handle, string path, nuint blockSize);

    [LibraryImport("archive")]
    private static partial ArchiveHandle archive_read_new();

    public static ArchiveHandle OpenOrThrow(string path)
    {
        var handle = archive_read_new();
        if (archive_read_open_filename(handle, path, 10240) != 0)
        {
            int err = Marshal.GetLastPInvokeError();
            handle.Dispose();
            throw new IOException($"libarchive open failed (errno {err})");
        }
        return handle;
    }
}

// Ownership encoded once: released exactly once, even on failure paths
public sealed class ArchiveHandle() : SafeHandle(IntPtr.Zero, ownsHandle: true)
{
    public override bool IsInvalid => handle == IntPtr.Zero;
    protected override bool ReleaseHandle() => archive_read_free(handle) == 0;

    [LibraryImport("archive")]
    private static partial int archive_read_free(IntPtr h);
}
Q58

Explain tiered compilation, Dynamic PGO, and ReadyToRun: how does the JIT decide what to optimise, and which knobs matter in production?

AdvancedRuntime & JIT

Answer

Modern .NET compiles most methods twice. Tier 0 is a fast, barely-optimised compile so startup is not blocked by expensive codegen; call counters watch each method, and around thirty calls (or loop iterations via on-stack replacement, which lets a hot loop be re-JITted mid-execution and switched over) the method is recompiled at Tier 1 with full optimisation. Dynamic PGO, on by default since .NET 8, makes Tier 1 smarter: Tier 0 code is instrumented to record actual behavior, which virtual/interface call sites see which concrete types, which branches are taken, and Tier 1 uses that profile for guarded devirtualisation (emit a fast type-check-then-direct-call, often inlined, with a fallback virtual call), branch layout, and better inlining decisions.

This is why 'interfaces are slow' is mostly dead folklore: a hot IEnumerable<T> whose runtime type is always List<T> gets specialised code, and it is also why microbenchmarks must warm up, steady-state performance is not first-iteration performance. It also explains a production behavior worth naming: a service is slightly slow for its first seconds after deploy (Tier 0 plus instrumentation) and speeds up as tiering settles. ReadyToRun (R2R) attacks that window from the other side: crossgen2 precompiles IL to native code at publish (<PublishReadyToRun>true</PublishReadyToRun>), so startup skips JIT for precompiled bodies; the code is more conservative than Tier 1 (versioning constraints limit cross-assembly inlining), so tiering still recompiles hot methods later, meaning R2R buys startup latency at the cost of larger binaries, with steady state unchanged.

The decision matrix: latency-sensitive services and serverless favour R2R (or full Native AOT when the ecosystem allows); long-running throughput services can even disable R2R usage and let PGO shine; and the env knobs worth knowing exist: DOTNET_TieredCompilation, DOTNET_TieredPGO, DOTNET_ReadyToRun, and DOTNET_TieredCompilation=0 for benchmarking full-opt-first (never in production, startup suffers badly). Two adjacent facts that impress: sealed classes genuinely help devirtualisation (the JIT can prove the concrete type; sealing by default is a real guideline with measurable effects), and [MethodImpl(MethodImplOptions.AggressiveInlining/AggressiveOptimization)] are hints for exceptional cases, not sprinkles, AggressiveOptimization skips tiering and therefore skips PGO, frequently making things slower.

Key Points

  • Tier 0 fast compile; ~30 calls or OSR promotes to optimised Tier 1
  • Dynamic PGO (default since .NET 8) drives guarded devirtualisation from real profiles
  • ReadyToRun precompiles for startup; tiering still re-optimises hot paths
  • sealed enables devirtualisation; AggressiveOptimization disables PGO, often a pessimisation
  • Warm-up phases in benchmarks and canary deploys exist because of tiering
Q59

What problems do Kestrel and System.IO.Pipelines solve at the I/O layer, and when do you drop down to them?

AdvancedHigh-Performance I/O

Answer

Kestrel is ASP.NET Core's cross-platform server: a libuv-free, Socket-based, fully asynchronous engine where connection I/O is scheduled over the ThreadPool with no thread per connection, TLS via SslStream, HTTP/1.1, HTTP/2 (multiplexed streams, HPACK) and HTTP/3 over QUIC (msquic) supported, and hard limits configured through KestrelServerOptions: MaxConcurrentConnections, MaxRequestBodySize (default 30 MB, the setting behind '413 Payload Too Large' surprises), request header limits, and the MinRequestBodyDataRate slow-client protections that defend against slowloris-style attacks. In real deployments Kestrel either fronts directly (supported, common in containers behind a cloud load balancer) or sits behind a reverse proxy (nginx, YARP, an ALB) that terminates TLS, in which case UseForwardedHeaders with ForwardedHeaders.XForwardedFor | XForwardedProto must be configured or your app sees the proxy's IP and scheme, a frequent 'redirect loop to https' production bug. Underneath request bodies and responses sits System.IO.Pipelines, the abstraction extracted from Kestrel's own internals to solve the two chronic problems of Stream-based parsing: buffer management (who allocates, how do you handle a message spanning two reads, when do you compact) and backpressure (what stops a fast producer from buffering unboundedly).

A PipeWriter hands you memory (GetMemory/Advance) from a pooled buffer chain; a PipeReader gives you a ReadOnlySequence<byte>, possibly multi-segment, over which you parse with SequenceReader<byte>, then report consumed and examined positions via AdvanceTo, unconsumed bytes stay buffered without copying, and the pipe's PauseWriterThreshold suspends the producer when the consumer lags. You drop to this layer when writing custom protocol servers (Redis-like TCP protocols, MQTT brokers, FIX gateways), high-throughput parsers of framed formats, or middleware streaming large bodies without LOH churn; HttpContext exposes BodyReader/BodyWriter so middleware can parse without wrapping streams. For ordinary REST endpoints you never touch it, model binding and System.Text.Json already ride Pipelines internally, and the interview-worthy summary is exactly that: the framework's default path is already the zero-copy path, so custom pipeline code needs a protocol-shaped justification and benchmarks.

// Kestrel hardening in Program.cs
builder.WebHost.ConfigureKestrel(k =>
{
    k.Limits.MaxRequestBodySize = 10 * 1024 * 1024;      // 10 MB, not the 30 MB default
    k.Limits.MaxConcurrentConnections = 5000;
    k.AddServerHeader = false;
});

// Behind a proxy: trust forwarded scheme/IP or auth redirects break
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
    ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
});

// Pipelines: parse newline-framed messages with no copies, with backpressure
async Task ReadFramesAsync(PipeReader reader, CancellationToken ct)
{
    while (true)
    {
        ReadResult result = await reader.ReadAsync(ct);
        ReadOnlySequence<byte> buffer = result.Buffer;

        while (TryReadLine(ref buffer, out ReadOnlySequence<byte> line))
            ProcessFrame(line);                       // may span pooled segments

        // consumed: start of unparsed data; examined: everything we looked at
        reader.AdvanceTo(buffer.Start, buffer.End);
        if (result.IsCompleted) break;
    }
    await reader.CompleteAsync();
}

static bool TryReadLine(ref ReadOnlySequence<byte> buf, out ReadOnlySequence<byte> line)
{
    var sr = new SequenceReader<byte>(buf);
    if (sr.TryReadTo(out line, (byte)'\n'))
    { buf = buf.Slice(sr.Position); return true; }
    return false;
}
Q60

How do you make a C# service resilient to partial failure: timeouts, retries, circuit breakers, and idempotency done correctly?

AdvancedResilience & Architecture

Answer

Resilience questions test whether you have owned a service through an incident, so structure the answer as policy layers with their failure modes. Timeouts first, because unbounded waits convert a slow dependency into your own outage via thread and connection pool exhaustion: every outbound call needs a per-attempt timeout (in the resilience pipeline) inside an overall budget (HttpClient.Timeout), and the budget must be smaller than your caller's timeout or you do work nobody awaits; CancellationToken linking (caller token plus CancellationTokenSource(delay)) implements the budget. Retries second, with the discipline that naive retries amplify outages: retry only transient, safe failures (HttpRequestException, 408, 429 honoring Retry-After, 5xx on idempotent verbs), never on 400-class logic errors, with exponential backoff and jitter (decorrelated jitter prevents synchronized retry storms from many instances), a small max attempt count, and a retry budget so a dependency at 50% failure does not double your traffic to it.

In .NET the current standard is Polly v8's ResiliencePipeline or its packaged form, Microsoft.Extensions.Http.Resilience's AddStandardResilienceHandler, which composes rate limiter, total timeout, retry, circuit breaker, and attempt timeout in a vetted order. Circuit breakers third: after a failure-rate threshold in a sampling window, the breaker opens and calls fail fast (BrokenCircuitException) without touching the dependency, giving it room to recover; half-open probes decide re-closing, and breakers must be per-endpoint or per-host (one shared breaker lets a broken reporting API take down payments). Idempotency is the piece that makes retries safe end to end: network failure is ambiguous (the request may have succeeded), so any retried mutation needs an idempotency key, client-generated, stored uniquely (a unique index on the key with the response cached against it), so the second attempt returns the recorded outcome instead of double-charging; this is exactly how payment APIs like Razorpay's and Stripe's contract with callers works, and the same reasoning gives you exactly-once-effect (not exactly-once-delivery) in queue consumers via deduplication tables or upserts keyed on message id. Close with the observability tie-in: every layer emits events (Polly telemetry, breaker state metrics), and an opened breaker should page, because it is your earliest, cheapest outage signal.

// Polly v8 pipeline: order matters (outermost first)
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddTimeout(TimeSpan.FromSeconds(10))                       // total budget
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        MaxRetryAttempts = 3,
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true,                                        // decorrelated jitter
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .Handle<HttpRequestException>()
            .HandleResult(r => (int)r.StatusCode is 408 or 429 or >= 500),
    })
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
    {
        FailureRatio = 0.5,
        SamplingDuration = TimeSpan.FromSeconds(30),
        MinimumThroughput = 20,
        BreakDuration = TimeSpan.FromSeconds(15),
    })
    .AddTimeout(TimeSpan.FromSeconds(2))                         // per attempt
    .Build();

// Idempotent mutation: retries can never double-charge
public async Task<PayoutResult> CreatePayoutAsync(PayoutRequest req, string idempotencyKey)
{
    var existing = await _db.IdempotencyRecords.FindAsync(idempotencyKey);
    if (existing is not null)
        return JsonSerializer.Deserialize<PayoutResult>(existing.ResponseJson)!;

    var result = await _gateway.ExecuteAsync(req);               // through 'pipeline'
    _db.IdempotencyRecords.Add(new(idempotencyKey, JsonSerializer.Serialize(result)));
    await _db.SaveChangesAsync();   // unique index on key: concurrent retry loses cleanly
    return result;
}

Companies Hiring C#

Microsoft
Optum
Siemens
Honeywell
TCS
Infosys
Accenture
Wipro

Salary Insights

Average in India
₹6-22 LPA

Frequently Asked Questions

What does a C# developer earn in India in 2026?

The broad band is ₹6-22 LPA. Freshers at services companies (TCS, Infosys, Wipro, Capgemini) start around ₹3.5-7 LPA on .NET projects. With 3-5 years of solid ASP.NET Core plus Azure or AWS experience, ₹12-20 LPA is a normal range at GCCs and product companies (Optum, Siemens, Honeywell, Philips, banks' tech centres), and Microsoft India itself pays well above the band for SDE roles where C# is incidental to the bar. Unity game developers track a separate curve, roughly ₹5-18 LPA depending on the studio. The premium goes to engineers who can talk GC behavior, async internals, and production diagnostics, exactly the advanced third of this guide, because most applicants stop at syntax.

How long should I prepare for a C# interview, and where should the time go?

If you already work in C# daily, two to three focused weeks is enough: one week on the runtime topics you use but never articulate (async state machines, GC generations, value-type semantics, DI lifetimes), one week on your weakest pillar (usually EF Core query behavior or concurrency), and a few days of mock answers out loud, since explaining ConfigureAwait clearly is a different skill from using it. Coming from another language, budget six to eight weeks: the syntax transfers in days, but interviews are decided by .NET-specific machinery, ownership of IDisposable, Span<T>, the options pattern, middleware order, that has no direct analogue elsewhere. In both cases, build one small ASP.NET Core service end to end with tests and a background worker; a majority of the intermediate questions in this guide map to decisions you will make in that build.

What is the expectation gap between freshers and experienced hires in C# interviews?

Freshers are tested on language fundamentals and reasoning: value versus reference semantics, collections, LINQ behavior, exception handling, and increasingly one honesty check on modern syntax (records, pattern matching) to see whether you learned C# from 2012 tutorials. A DSA round in C# is common at product companies. At 3-5 years, the centre of gravity shifts to ASP.NET Core and EF Core in production: DI lifetimes and the captive dependency trap, middleware order, AsNoTracking and N+1, async pitfalls, and testing strategy, with at least one 'tell me about a production issue you debugged' story expected. At senior levels, expect systems questions wearing C# clothes: ThreadPool starvation diagnosis, GC tuning in containers, resilience patterns, idempotency, and architecture trade-offs like AOT versus JIT, where the interviewer cares more about your decision process than the trivia.

Is C# still worth learning in 2026 compared with Java, Go, or Node.js?

Yes, with clear eyes about where the jobs are. C#'s Indian demand is concentrated in enterprise services, GCCs of US and European corporations, healthcare and BFSI backends, and Unity game development, steady, high-volume hiring rather than startup-scene hype. Against Java: near-parity in role count in India with Java ahead in raw volume, but .NET roles cluster in the same salary bands and modern C# is arguably the more pleasant language; skills transfer both ways easily. Against Go: Go wins mindshare in infrastructure startups, but C#'s ecosystem (EF Core, ASP.NET Core, first-party everything) ships product faster for business applications. Against Node.js: TypeScript teams hire more broadly in Indian startups, but C# pays comparably at the companies that use it seriously. The strategic play is C# plus Azure plus Kubernetes, that trio matches a very large and stable slice of the Indian enterprise market.

Do I need to know .NET Framework, or is .NET 8+ enough?

Learn modern .NET first and default to it: .NET 8 and .NET 10 are the LTS releases, and everything in this guide targets them. But in India specifically, a meaningful share of enterprise codebases still run .NET Framework 4.8 under Windows and IIS, especially in BFSI and manufacturing, so migration literacy is a paid skill: knowing what blocks a port (WCF server-side, WebForms, AppDomains, System.Drawing), what the bridges are (CoreWCF, YARP-fronted strangler migrations, Windows Compatibility Pack), and how Framework's runtime differs (no Server GC by default in ASP.NET classic, different SynchronizationContext behavior, so the .Result deadlock questions apply there with full force). You do not need Framework depth to pass a product-company interview, but for services-company and GCC roles, being able to discuss a 4.8-to-.NET-10 migration plan credibly sets you apart from candidates who only know one side.

How much Azure or cloud knowledge do C# interviews assume?

For pure language roles (Unity, desktop, libraries), little to none. For backend roles, which are most C# openings, assume at least conversational cloud: how your app reads configuration and secrets in a cloud environment (Key Vault or Secrets Manager references, managed identity over connection strings in code), where logs and metrics go (Application Insights or an OTel pipeline), how containers change GC behavior (memory limits, Server GC footprint), and one deployment story you can narrate end to end (App Service, AKS, or ECS with health probes and rolling deploys). Azure is the statistically likely cloud in .NET shops, so knowing Service Bus versus Storage Queues, Azure SQL connection resilience, and Functions' cold-start trade-offs pays off disproportionately. You are not expected to be a certified architect at mid-level; you are expected to have deployed something real and to know what broke.

Introduction

C# in 2026 is a very different language from the one taught in most Indian engineering colleges. With .NET 8 and .NET 10 as the long-term support releases and C# 14 as the current language version, interviews now assume you know records, nullable reference types, Span<T>, pattern matching, and the async/await machinery under the hood, not just classes and inheritance. The runtime story matters too: interviewers at product companies probe garbage collector generations, ThreadPool starvation, and why ValueTask exists, because these are the things that actually break .NET services in production at scale.

The Indian market for C# splits into three lanes. Enterprise services firms (TCS, Infosys, Accenture, Wipro, Capgemini) hire in volume for ASP.NET Core and Azure work and test fundamentals plus EF Core. Product and GCC roles (Microsoft India, Optum, Siemens, Honeywell, Philips, Mastercard tech centres) pay significantly more and dig into memory layout, concurrency, and diagnostics tooling like dotnet-counters and dotnet-dump. Game studios hiring for Unity test C# itself heavily: structs versus classes, garbage collection pressure, and allocation-free code paths, since a frame budget leaves no room for Gen 2 pauses.

This guide contains 60 questions ordered from basic through advanced, written to match what 2026 interview loops actually ask. Every answer explains how the feature behaves at runtime, the gotcha that trips up production code, and what the interviewer is really checking. Work through the basic set to close syntax gaps, then spend most of your preparation time on the intermediate and advanced sections: async internals, the GC, Span<T>, EF Core query behavior, and the C# 12 to C# 14 features that signal you have kept current.

Ready to practice C# interviews?

Don't just read, practice these C# questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview