🟣 .NET Interview Questions

40 questions covering C#, ASP.NET Core, EF Core, and LINQ — with theory, real code, real-world scenarios, common mistakes and follow-up questions.

50Questions
5Levels
6Answer Sections
240Total Answers
Showing 50 of 50 questions
0 of 50 viewed
01 What is .NET and how does .NET Core differ from .NET Framework? basic

.NET is a free, open-source developer platform created by Microsoft for building web, desktop, mobile, cloud, and IoT applications. It provides the Common Language Runtime (CLR) for memory management, garbage collection, and type safety, plus the Base Class Library (BCL) with thousands of pre-built classes.

.NET Framework (2002) is Windows-only, monolithic, tied to IIS, and is now in maintenance mode — security patches only, no new features. .NET Core (2016, rebranded to just ".NET" from version 5+) is cross-platform (Windows, Linux, macOS), modular, CLI-first, and delivers dramatically better performance. It supports side-by-side versioning, runs in Docker containers, and uses the high-performance Kestrel web server.

Key differences: .NET 8 supports ahead-of-time (AOT) compilation, runs in containers as small as 15 MB (Alpine), uses top-level statements eliminating boilerplate, and benchmarks at 7M+ requests/second on TechEmpower. .NET Framework requires Windows Server, uses the legacy System.Web pipeline, and cannot be containerized efficiently.

// .NET 8 Console App — top-level statements (no Main needed)
Console.WriteLine("Hello from .NET 8!");

var languages = new[] { "C#", "F#", "VB.NET" };
foreach (var lang in languages)
{
    Console.WriteLine($".NET supports {lang}");
}

// ASP.NET Core Minimal API (entire Program.cs)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();

var app = builder.Build();

app.MapGet("/", () => "Welcome to .NET 8 API");
app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Framework = ".NET 8" }));
app.MapPost("/orders", (Order order) => Results.Created($"/orders/{order.Id}", order));

app.Run();

record Order(int Id, string Product, decimal Amount);

A Fortune 500 bank migrated 200+ microservices from .NET Framework 4.8 on IIS/Windows Server to .NET 8 on Kubernetes/Linux. Docker image size dropped from 3.5 GB (Windows Server Core) to 85 MB (Alpine + AOT). Cold-start time fell from 12 seconds to 0.8 seconds, request throughput improved 340%, and annual Windows Server licensing savings were $180K across 24 VMs.

.NET is the modern, cross-platform successor to .NET Framework. All new projects should target .NET 8+ for performance, container support, and cross-platform deployment. Framework is maintenance-mode only.
⚠️ Common Mistake

Candidates say ".NET Framework is dead and nobody uses it." This is wrong — millions of enterprise apps still run on Framework 4.8 and Microsoft supports it indefinitely:

❌ Wrong — dismissing Framework entirely
// ".NET Framework is dead, just rewrite everything"
// Reality:
// - WCF services still run on Framework
// - WebForms apps cannot be auto-migrated
// - System.Web has no .NET 8 equivalent
// - Enterprise migrations take years
✅ Correct — nuanced answer
// .NET Framework 4.8 is in MAINTENANCE MODE:
// ✅ Security patches continue indefinitely
// ✅ Ships with every Windows version
// ❌ No new features (no C# 12, no Span<T>)
// ❌ No cross-platform, no containers

// Migration strategy: strangler fig pattern
// New services → .NET 8
// Legacy monolith → gradual migration
// Use YARP reverse proxy to route between old and new
🔁 Follow-Up Question

What is the role of the CLR (Common Language Runtime) in .NET?

02 Explain value types vs reference types in C#. basic

Value types (int, float, bool, char, decimal, struct, enum) directly contain their data and are stored on the stack when used as local variables. Assignment copies the entire value — modifying one variable does not affect the other.

Reference types (class, string, array, delegate, interface) store a reference (pointer) to data on the managed heap. Assignment copies the reference — both variables point to the same object, so changes through one are visible through the other.

Boxing converts a value type to object (allocates on heap). Unboxing extracts it back (type-check + copy). Both are expensive — boxing causes GC pressure in hot paths. Generic collections (List<int>) avoid boxing unlike the old ArrayList which stored everything as object.

// Value type: copy semantics
int a = 42;
int b = a;       // b gets a COPY of 42
b = 100;
Console.WriteLine(a); // 42 — unchanged

// Reference type: shared reference
var list1 = new List<int> { 1, 2, 3 };
var list2 = list1;     // both point to SAME object
list2.Add(4);
Console.WriteLine(list1.Count); // 4 — both see the change

// Struct (value) vs Class (reference)
struct PointStruct { public int X, Y; }
class PointClass  { public int X, Y; }

var s1 = new PointStruct { X = 10, Y = 20 };
var s2 = s1;        // full copy
s2.X = 99;
Console.WriteLine(s1.X); // 10 — original unchanged

var c1 = new PointClass { X = 10, Y = 20 };
var c2 = c1;        // same reference
c2.X = 99;
Console.WriteLine(c1.X); // 99 — original modified!

// Boxing and unboxing
int value = 42;
object boxed = value;       // BOXING — heap allocation
int unboxed = (int)boxed;   // UNBOXING — type-check + copy

A high-frequency trading system processing 50,000 orders/second experienced GC pauses of 15ms during peak hours. Profiling showed the OrderTicket class allocated heavily on the heap. Replacing it with a readonly struct eliminated heap allocations — GC Gen0 collections dropped from 120/minute to 3/minute, and worst-case latency fell from 15ms to under 1ms.

Value types hold data directly (stack, copy semantics). Reference types hold a pointer to heap data (shared reference). Boxing wraps a value type in a heap object — avoid it in hot paths by using generics.
⚠️ Common Mistake

Candidates say "value types are always stored on the stack." This is a common oversimplification — value types that are fields of a class live on the heap with that object:

❌ Wrong — oversimplified
// "int is a value type so it is always on the stack"
class Customer
{
    public int Age;     // This int is on the HEAP
    public bool Active; // This bool is on the HEAP too
}
// Customer is a class (heap-allocated).
// Its value-type fields live WITH it on the heap.
✅ Correct — location depends on context
// Value types on the STACK:
void Calculate()
{
    int x = 10;  // stack — local variable
    var p = new PointStruct(); // stack — local struct
}

// Value types on the HEAP:
class Order
{
    public int Quantity;  // heap — field of a class
    public decimal Price; // heap — field of a class
}
// Also heap: boxed values, closure-captured, arrays
🔁 Follow-Up Question

What is boxing and unboxing? What are the performance implications?

03 What is the difference between a class and a struct in C#? basic

Classes are reference types allocated on the heap. They support inheritance, can be null, have finalizers, and are passed by reference. Structs are value types typically on the stack (when local). They cannot inherit from other structs/classes (but can implement interfaces), cannot be null (unless Nullable<T>), and are copied on assignment.

Use structs for small, immutable data containers (under ~16 bytes) that represent a single value — like Point, Color, DateTime, Guid. Use classes for objects with identity, complex behavior, inheritance, or large size. C# 10 introduced record struct for value-type records with value equality.

Key performance insight: structs avoid heap allocation and GC pressure, but large structs are expensive to copy. The readonly struct modifier prevents defensive copies when passed by in reference, and ref struct (like Span<T>) is stack-only and cannot be boxed.

// Struct — small, immutable, value semantics
public readonly struct Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Currency mismatch");
        return new Money(Amount + other.Amount, Currency);
    }

    public override string ToString() => $"{Currency} {Amount:N2}";
}

// Class — complex behavior, identity, inheritance
public class Employee
{
    public int Id { get; init; }
    public string Name { get; set; }
    public Department Department { get; set; }
    public List<string> Skills { get; } = new();

    public virtual decimal CalculateBonus() => 5000m;
}

public class Manager : Employee  // inheritance works
{
    public List<Employee> Reports { get; } = new();
    public override decimal CalculateBonus() => 5000m + Reports.Count * 1000m;
}

// Usage — copy semantics demo
var price1 = new Money(100, "USD");
var price2 = price1;  // full copy, independent
// Modifying price2 does NOT affect price1

Unity game engine uses structs for Vector3, Quaternion, and Color — millions of these are created per frame. If these were classes, each frame would generate millions of heap allocations causing constant GC pauses and frame-rate drops. With structs, they live on the stack and are reclaimed instantly when the method returns — zero GC pressure.

Use struct for small (<16 bytes), immutable, short-lived value types. Use class for complex objects with identity, behavior, and inheritance. Mark structs readonly to avoid defensive copies.
⚠️ Common Mistake

Candidates cannot explain when to choose struct over class. The rule of thumb is the "struct suitability" checklist:

❌ Wrong — struct for complex mutable objects
// Bad: mutable struct causes subtle bugs
struct MutablePoint
{
    public int X, Y;
    public void Move(int dx, int dy) { X += dx; Y += dy; }
}

// This is a bug! The method modifies a COPY, not the original:
MutablePoint p = new() { X = 1, Y = 2 };
var list = new List<MutablePoint> { p };
// list[0].Move(5, 5);  // Compiler error — prevents the bug
// If it compiled, it would modify a copy, not list[0]
✅ Correct — readonly struct for safety
// Good: immutable struct, clear value semantics
public readonly struct Point
{
    public int X { get; }
    public int Y { get; }
    public Point(int x, int y) => (X, Y) = (x, y);
    public Point Translate(int dx, int dy) => new(X + dx, Y + dy);
}

// Use struct when ALL of these are true:
// ✅ Size < 16 bytes
// ✅ Immutable (readonly)
// ✅ Short-lived (no long references)
// ✅ No inheritance needed
// ✅ Represents a single logical value
🔁 Follow-Up Question

Can a struct implement an interface? What happens performance-wise when it does?

04 Explain access modifiers in C# — public, private, protected, internal, and more. basic

C# has six access modifiers that control visibility: public — accessible from anywhere. private — only within the declaring class/struct. protected — within the class and its derived classes. internal — within the same assembly (DLL/EXE). protected internal — same assembly OR derived classes (union). private protected — same assembly AND derived classes (intersection).

Default access: class members default to private, top-level types default to internal. Interfaces members are public by default. C# 11 added file scoped types — visible only within the same source file (used by source generators).

Good API design uses the principle of least privilege: expose only what consumers need. Internal classes prevent external misuse, private fields enforce encapsulation, and protected allows extension points for inheritance.

// Demonstrating all access modifiers
public class PaymentGateway  // public: accessible from any assembly
{
    private readonly string _apiKey;          // private: only this class
    private protected decimal _feeRate;       // same assembly AND derived
    protected int MaxRetries { get; set; }    // this class + derived
    internal string MerchantId { get; set; }  // same assembly only
    protected internal ILogger Logger { get; set; } // same assembly OR derived
    public string GatewayName { get; }        // accessible everywhere

    public PaymentGateway(string apiKey, string name)
    {
        _apiKey = apiKey;       // only this class can read the key
        GatewayName = name;
        MaxRetries = 3;
        _feeRate = 0.029m;     // 2.9%
    }

    public PaymentResult ProcessPayment(decimal amount)
    {
        // Public method — the only entry point for consumers
        ValidateAmount(amount);
        return ExecuteCharge(amount);
    }

    private void ValidateAmount(decimal amount)  // private helper
    {
        if (amount <= 0) throw new ArgumentException("Amount must be positive");
    }

    private PaymentResult ExecuteCharge(decimal amount) =>
        new(true, amount - (amount * _feeRate));
}

internal class PaymentResult  // internal: hidden from external assemblies
{
    public bool Success { get; }
    public decimal NetAmount { get; }
    internal PaymentResult(bool success, decimal net) =>
        (Success, NetAmount) = (success, net);
}

A payments SDK used internal for all core processing classes and exposed only the PaymentGateway facade as public. This prevented consumers from calling internal validation or retry methods directly — reducing incorrect-usage support tickets by 40% and enabling the team to refactor internals freely without breaking changes.

public = everywhere. private = same class. protected = derived classes. internal = same assembly. Default: members are private, types are internal. Apply the principle of least privilege.
⚠️ Common Mistake

Candidates confuse protected internal (OR — broader access) with private protected (AND — narrower access):

❌ Wrong — confusing the two compound modifiers
// "protected internal means accessible only in derived
//  classes within the same assembly" — WRONG!

// protected internal = same assembly OR derived class
// (this is the BROADER one — union of both)
protected internal void BroadAccess() { }

// Accessible from:
// ✅ Any class in same assembly (even non-derived)
// ✅ Derived classes in OTHER assemblies
✅ Correct — understanding both
// protected internal = OR (broader)
protected internal void BroadAccess() { }
// ✅ Same assembly, any class
// ✅ Derived class, any assembly

// private protected = AND (narrower)
private protected void NarrowAccess() { }
// ✅ Derived class IN same assembly only
// ❌ Non-derived class in same assembly — NO
// ❌ Derived class in other assembly — NO

// Mnemonic: "private protected" has more words = MORE restrictive
🔁 Follow-Up Question

What is the default access modifier for class members? For top-level classes?

05 How do properties work in C#? Explain auto-properties, init-only, and computed properties. basic

Properties provide controlled access to an object's data through get and set accessors. Unlike public fields, properties can include validation logic, trigger events, or compute values on the fly. Auto-properties ({ get; set; }) generate a hidden backing field automatically — no boilerplate.

C# 9 introduced init-only properties ({ get; init; }) that can only be set during object initialization (constructor or object initializer) — making immutable objects easy to create. Required properties (C# 11) force callers to set them during initialization.

Computed properties have only a getter with no backing field — they calculate a value on each access. Expression-bodied properties (=>) make them concise. Properties can also have different access levels on get/set: public string Name { get; private set; } is readable externally but only settable internally.

public class Product
{
    // Auto-property with private setter
    public int Id { get; private set; }

    // Required property (C# 11) — must be set at creation
    public required string Name { get; set; }

    // Init-only property (C# 9) — set once, then immutable
    public string Sku { get; init; }

    // Property with validation in setter
    private decimal _price;
    public decimal Price
    {
        get => _price;
        set
        {
            if (value < 0)
                throw new ArgumentException("Price cannot be negative");
            _price = value;
        }
    }

    // Computed property — no backing field
    public decimal PriceWithTax => Price * 1.18m;

    // Computed property with logic
    public string Status => Price switch
    {
        0 => "Free",
        < 100 => "Budget",
        < 1000 => "Standard",
        _ => "Premium"
    };
}

// Usage
var product = new Product
{
    Name = "Wireless Keyboard",   // required — compile error if missing
    Sku = "KB-2024-001",          // init — cannot change after this
    Price = 2499.00m
};

// product.Sku = "NEW";  // Compile ERROR — init-only
Console.WriteLine(product.PriceWithTax);  // 2948.82
Console.WriteLine(product.Status);         // "Premium"

In an e-commerce platform processing 200K products, adding validation in the Price property setter (rejecting negative values and values over $1M) caught 3,200 data corruption incidents per month that were previously entering the database from a buggy CSV import process — saving an estimated $50K in manual data cleanup costs.

Properties encapsulate fields with get/set logic. Use auto-properties for simple cases, init for immutability, required (C# 11) to enforce initialization, and computed properties for derived values.
⚠️ Common Mistake

Candidates use public fields instead of properties. Fields cannot be used in interfaces, data binding, serialization attributes, or mocking frameworks:

❌ Wrong — public fields
public class User
{
    public string Name;   // field — no encapsulation
    public int Age;       // cannot add validation later
    public string Email;  // breaks binary compatibility if changed
}

// Problems:
// ❌ Cannot add validation without breaking callers
// ❌ Cannot use in interface definitions
// ❌ No data binding support (WPF, Blazor)
// ❌ Changing to property = breaking change (recompile needed)
✅ Correct — properties
public class User
{
    public required string Name { get; set; }
    public int Age { get; set; }
    public required string Email { get; init; }
}

// Benefits:
// ✅ Add validation anytime without breaking callers
// ✅ Works in interfaces: interface IUser { string Name { get; } }
// ✅ Data binding, serialization, EF Core mapping all work
// ✅ Can add logging, lazy loading, computed logic later
🔁 Follow-Up Question

What is the difference between a property and a field? Why should you prefer properties for public APIs?

06 What is the difference between String and StringBuilder in C#? basic

string in C# is immutable — every modification (concatenation, replace, trim) creates a brand-new string object on the heap. The old string becomes garbage. For a few concatenations this is fine, but in a loop with thousands of iterations, you create thousands of temporary string objects causing massive GC pressure.

StringBuilder is a mutable buffer that modifies characters in place. It pre-allocates a char array and expands as needed — no intermediate allocations. Use StringBuilder when concatenating in loops or building large strings dynamically (HTML, SQL, CSV output).

Modern guidance: for 1-5 concatenations, use string interpolation ($"...") — the compiler optimizes it. For loops or unbounded concatenation, use StringBuilder. For joining collections, use string.Join(). The threshold where StringBuilder wins is typically around 5-10 concatenations.

// ❌ String concatenation in a loop — creates 10,000 temporary strings
string result = "";
for (int i = 0; i < 10_000; i++)
{
    result += $"Item {i}, ";  // new string allocated EVERY iteration
}
// Memory: ~10,000 temporary string objects for GC to collect

// ✅ StringBuilder — one mutable buffer, zero intermediate allocations
var sb = new StringBuilder(256);  // pre-allocate capacity
for (int i = 0; i < 10_000; i++)
{
    sb.Append($"Item {i}, ");  // appends to internal buffer
}
string final = sb.ToString();  // single allocation at the end

// String interpolation — fine for small, known concatenations
string name = "Priya";
int age = 28;
string greeting = $"Hello, {name}! You are {age} years old.";
// Compiler optimizes this — no StringBuilder needed

// string.Join — best for collections
var tags = new[] { "csharp", "dotnet", "interview" };
string csv = string.Join(", ", tags);  // "csharp, dotnet, interview"

// Benchmark results (10,000 iterations):
// String concat:  ~450ms, ~50MB allocated
// StringBuilder:  ~2ms,   ~0.1MB allocated

A report generator building HTML tables for 50,000 database rows took 45 seconds using string concatenation — creating 50K temporary string objects per column (200K+ total garbage). Switching to StringBuilder with pre-allocated capacity reduced generation time to 0.3 seconds and memory usage from 2.1 GB peak to 12 MB.

String is immutable — each modification allocates a new object. Use StringBuilder for loops/dynamic building. Use string interpolation for simple formatting. Use string.Join for collections.
⚠️ Common Mistake

Candidates always use StringBuilder even when string interpolation is cleaner and equally fast for simple cases:

❌ Wrong — over-engineering simple formatting
// Unnecessary StringBuilder for simple concatenation
var sb = new StringBuilder();
sb.Append("Hello, ");
sb.Append(user.Name);
sb.Append("! Your order #");
sb.Append(order.Id);
sb.Append(" is confirmed.");
string message = sb.ToString();
// Harder to read, no performance benefit here
✅ Correct — use interpolation for clarity
// String interpolation — compiled to efficient code
string message = $"Hello, {user.Name}! Your order #{order.Id} is confirmed.";
// Clean, readable, and the compiler optimizes it

// Use StringBuilder only when:
// ✅ Concatenating in a loop (unknown iterations)
// ✅ Building large dynamic content (HTML, JSON, SQL)
// ✅ Performance-critical paths with 10+ concatenations
🔁 Follow-Up Question

What is string interning in .NET? How does it affect memory and equality checks?

07 How does exception handling work in C#? Explain try, catch, finally, throw, and when filters. basic

C# uses structured exception handling with try/catch/finally. Code that might fail goes in try. One or more catch blocks handle specific exceptions — always catch the most specific first. finally always executes regardless of whether an exception occurred — use it for cleanup (closing connections, releasing resources).

throw; re-throws the current exception preserving the original stack trace. throw ex; resets the stack trace — making debugging impossible (this is a common bug). C# 6 introduced exception filters: catch (Exception ex) when (condition) — the filter is evaluated without unwinding the stack, so you can conditionally catch without losing context.

Best practices: catch specific exceptions, never swallow exceptions silently, use using statements (IDisposable) instead of finally for resource cleanup, create custom exceptions for domain-specific errors, and always include inner exceptions when wrapping.

// Complete exception handling example
public async Task<Order> ProcessOrderAsync(OrderRequest request)
{
    try
    {
        ValidateRequest(request);
        var order = await _repository.CreateOrderAsync(request);
        await _paymentService.ChargeAsync(order.Total);
        return order;
    }
    catch (ValidationException ex)
    {
        _logger.LogWarning("Validation failed: {Error}", ex.Message);
        throw;  // re-throw preserving stack trace
    }
    catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
    {
        // Exception filter — only catches 429 rate-limit errors
        _logger.LogWarning("Rate limited, retrying...");
        await Task.Delay(1000);
        return await ProcessOrderAsync(request);  // retry
    }
    catch (PaymentException ex)
    {
        // Wrap with context, preserve inner exception
        throw new OrderProcessingException(
            $"Payment failed for order {request.ProductId}", ex);
    }
    finally
    {
        // Always runs — cleanup
        _metrics.RecordOrderAttempt();
    }
}

// Custom exception with proper constructors
public class OrderProcessingException : Exception
{
    public string OrderId { get; }
    public OrderProcessingException(string message, Exception inner)
        : base(message, inner) { }
}

A microservices team added exception filters — catch (HttpRequestException ex) when (ex.StatusCode == 429) — to only retry rate-limited requests. Previously, a bare catch retried ALL HTTP errors including 404s and 500s, creating retry storms that amplified outages. The targeted filter reduced cascade failures by 80% during peak traffic.

Catch specific exceptions. Use throw (not throw ex) to preserve stack traces. Use finally for cleanup. Exception filters (when) allow conditional catching without stack unwinding.
⚠️ Common Mistake

Using throw ex; instead of throw; destroys the stack trace — the #1 debugging killer in production:

❌ Wrong — throw ex resets stack trace
try
{
    await _db.SaveChangesAsync();
}
catch (DbUpdateException ex)
{
    _logger.LogError(ex, "Save failed");
    throw ex;  // ❌ RESETS stack trace!
    // Stack trace now points HERE, not to the actual failure
    // Impossible to debug in production logs
}
✅ Correct — throw preserves stack trace
try
{
    await _db.SaveChangesAsync();
}
catch (DbUpdateException ex)
{
    _logger.LogError(ex, "Save failed");
    throw;  // ✅ Preserves original stack trace
    // Stack trace shows the EXACT line in SaveChangesAsync
    // that caused the failure — easy to debug
}
🔁 Follow-Up Question

What is the difference between throw and throw ex? When would you use exception filters?

08 Explain nullable types, null-coalescing (??) and null-conditional (?.) operators in C#. basic

Nullable<T> (shorthand T?) allows value types to represent null: int? age = null;. Has .HasValue and .Value properties. This solved the problem of "no value" for value types (previously you had to use magic numbers like -1).

Null operators: ?? (null-coalescing) returns the left operand if non-null, otherwise the right. ??= assigns only if the variable is currently null. ?. (null-conditional) short-circuits to null if the object is null instead of throwing NullReferenceException. ?[] is the null-conditional indexer.

Nullable Reference Types (NRT) — C# 8 introduced a compiler feature that tracks null flow for reference types. Enable with <Nullable>enable</Nullable> in .csproj. The compiler emits warnings for potential null dereferences, null assignments to non-nullable types, and missing null checks. This is the most impactful null-safety feature in C# — it catches bugs at compile time.

// Nullable value types
int? stock = null;          // can be null
int available = stock ?? 0; // ?? provides default
Console.WriteLine(available); // 0

// ??= assigns only if null
string? cachedName = null;
cachedName ??= FetchNameFromDb(); // only calls DB if null

// ?. null-conditional operator — no more NullReferenceException
Order? order = GetOrderOrNull();
string? city = order?.Customer?.Address?.City; // null if any part is null
int? itemCount = order?.Items?.Count;          // null if order or Items is null

// ?[] null-conditional indexer
int? firstItem = order?.Items?[0]?.Quantity;

// Combining operators for safe access with default
string displayCity = order?.Customer?.Address?.City ?? "Unknown";
int totalItems = order?.Items?.Count ?? 0;

// Nullable Reference Types (C# 8+) — compile-time null safety
#nullable enable
public class UserService
{
    // Non-nullable: compiler warns if you return null
    public User GetUser(int id)
    {
        return _db.Users.Find(id)
            ?? throw new NotFoundException($"User {id} not found");
    }

    // Nullable: callers must check for null
    public User? FindUserByEmail(string email)
    {
        return _db.Users.FirstOrDefault(u => u.Email == email);
    }
}

Enabling Nullable Reference Types on a 200K-line enterprise codebase surfaced 1,400+ potential NullReferenceException bugs at compile time. The team fixed them over 3 sprints, and production null-reference crashes dropped from ~50/day to near zero — the single most impactful code quality improvement that year.

Use T? for nullable value types, ?? for defaults, ?. for safe member access. Enable Nullable Reference Types (enable) to catch null bugs at compile time.
⚠️ Common Mistake

Candidates only know int? but are unaware of Nullable Reference Types — the most important null-safety feature in modern C#:

❌ Wrong — ignoring NRT, defensive null checks everywhere
// Without Nullable Reference Types — null could be anywhere
public string GetDisplayName(User user)
{
    if (user == null) return "Unknown";
    if (user.Name == null) return "Anonymous";
    if (user.Name.First == null) return user.Name.Last ?? "Unknown";
    return $"{user.Name.First} {user.Name.Last}";
    // 4 null checks — no compile-time help
    // Miss one? NullReferenceException in production
}
✅ Correct — Nullable Reference Types with compiler enforcement
#nullable enable
public class User
{
    public required string Email { get; init; }    // never null
    public string? DisplayName { get; set; }       // may be null
    public Address? Address { get; set; }          // may be null
}

public string GetDisplayName(User user)
{
    // Compiler KNOWS: user is non-null (parameter type)
    // Compiler WARNS: DisplayName might be null
    return user.DisplayName ?? user.Email;
}
// Compile-time guarantee — no runtime surprises
🔁 Follow-Up Question

What are Nullable Reference Types and how do they differ from Nullable Value Types?

09 What are the main collection types in C#? Explain List<T>, Dictionary<TKey,TValue>, and HashSet<T>. basic

List<T> is a dynamic array — O(1) access by index, O(1) amortized Add (to end), O(n) Insert/Remove (middle). Best for ordered collections with frequent index access.

Dictionary<TKey, TValue> is a hash table — O(1) average lookup, add, and remove by key. Keys must be unique and have proper GetHashCode()/Equals(). Best for key-value mappings and fast lookups.

HashSet<T> stores unique elements with O(1) Contains/Add/Remove. Supports set operations: UnionWith, IntersectWith, ExceptWith. Best for deduplication and membership testing.

Other collections: Queue<T> (FIFO), Stack<T> (LIFO), LinkedList<T> (O(1) insert at ends), SortedDictionary<K,V> (sorted by key, O(log n)). For public APIs, return IReadOnlyList<T> or IReadOnlyDictionary<K,V> to prevent external mutation.

// List<T> — ordered, indexed, allows duplicates
var orders = new List<Order>();
orders.Add(new Order(1, "Laptop", 75000m));
orders.Add(new Order(2, "Mouse", 500m));
var expensive = orders.Where(o => o.Amount > 1000).ToList();
var first = orders[0]; // O(1) index access

// Dictionary<TKey, TValue> — O(1) key lookup
var productPrices = new Dictionary<string, decimal>
{
    ["Laptop"] = 75000m,
    ["Mouse"] = 500m,
    ["Keyboard"] = 2500m
};

// Always use TryGetValue (not indexer) to avoid KeyNotFoundException
if (productPrices.TryGetValue("Laptop", out decimal price))
{
    Console.WriteLine($"Laptop costs {price:C}");
}

// HashSet<T> — unique elements, O(1) membership
var processedOrderIds = new HashSet<int>();
foreach (var order in incomingOrders)
{
    if (processedOrderIds.Add(order.Id))  // returns false if duplicate
    {
        ProcessOrder(order);  // only processes unique orders
    }
}

// Set operations
var premiumUsers = new HashSet<string> { "user1", "user3", "user5" };
var activeUsers = new HashSet<string> { "user1", "user2", "user3" };
premiumUsers.IntersectWith(activeUsers); // { "user1", "user3" }

// IReadOnlyList for public APIs — prevent mutation
public IReadOnlyList<Order> GetRecentOrders() => _orders.AsReadOnly();

An analytics pipeline deduplicating 10M user events daily used List.Contains() (O(n) linear scan) to check for duplicates. With 10M items, each Contains call scanned up to 10M elements. Switching to HashSet<string> (O(1) lookup) reduced daily processing time from 4 hours to 12 minutes — a 20x improvement with a one-line change.

List for ordered/indexed access. Dictionary for key-value lookups. HashSet for uniqueness and membership testing. Always use TryGetValue on Dictionary — never the indexer for uncertain keys.
⚠️ Common Mistake

Using dictionary[key] which throws KeyNotFoundException when the key doesn't exist:

❌ Wrong — indexer throws on missing key
var settings = new Dictionary<string, string>();
// This throws KeyNotFoundException if "Theme" not found!
string theme = settings["Theme"];

// Or wrapping in try/catch — wasteful
try { theme = settings["Theme"]; }
catch (KeyNotFoundException) { theme = "default"; }
✅ Correct — TryGetValue or GetValueOrDefault
var settings = new Dictionary<string, string>();

// Option 1: TryGetValue — most explicit
if (settings.TryGetValue("Theme", out string? theme))
{
    ApplyTheme(theme);
}

// Option 2: GetValueOrDefault (.NET 7+)
string resolvedTheme = settings.GetValueOrDefault("Theme", "dark");

// Option 3: ContainsKey + indexer (two lookups, less ideal)
if (settings.ContainsKey("Theme"))
    theme = settings["Theme"];
🔁 Follow-Up Question

What is the difference between IEnumerable, ICollection, and IList? When should you use each as a parameter or return type?

10 What is the difference between an interface and an abstract class in C#? basic

An interface defines a contract — method signatures, properties, and events that implementing classes must provide. A class can implement multiple interfaces. Since C# 8, interfaces support default interface methods (DIM) — providing implementation that implementors inherit for free, enabling interface evolution without breaking changes.

An abstract class provides partial implementation — it can have abstract methods (must override), concrete methods (shared logic), fields, constructors, and static members. A class can inherit from only one abstract class (single inheritance). Use abstract classes when derived types share significant implementation.

When to choose: Interface for "can do" contracts across unrelated types (IDisposable, IComparable, ISerializable). Abstract class for "is a" hierarchies sharing code (Vehicle → Car/Truck). In modern C# with DIM, prefer interfaces unless you need fields, constructors, or protected state.

// Interface — contract for unrelated types
public interface IExportable
{
    string ExportToCsv();
    byte[] ExportToPdf();
    // Default method (C# 8) — free implementation for all
    string GetExportTimestamp() => DateTime.UtcNow.ToString("O");
}

public interface ISearchable
{
    IEnumerable<SearchResult> Search(string query);
}

// A class can implement MULTIPLE interfaces
public class Order : IExportable, ISearchable
{
    public string ExportToCsv() => $"{Id},{Customer},{Total}";
    public byte[] ExportToPdf() => PdfGenerator.Create(this);
    public IEnumerable<SearchResult> Search(string q) => /* ... */ ;
}

// Abstract class — shared implementation for related types
public abstract class NotificationSender
{
    protected readonly ILogger _logger;
    private readonly RateLimiter _limiter;

    protected NotificationSender(ILogger logger)
    {
        _logger = logger;
        _limiter = new RateLimiter(maxPerMinute: 100);
    }

    // Template method pattern
    public async Task SendAsync(Notification notification)
    {
        if (!await _limiter.AllowAsync()) return;
        _logger.LogInformation("Sending {Type}", GetType().Name);
        await DeliverAsync(notification);  // subclass implements
    }

    protected abstract Task DeliverAsync(Notification notification);
}

public class EmailSender : NotificationSender
{
    public EmailSender(ILogger logger) : base(logger) { }
    protected override Task DeliverAsync(Notification n)
        => _smtpClient.SendAsync(n.ToEmail());
}

public class SmsSender : NotificationSender
{
    public SmsSender(ILogger logger) : base(logger) { }
    protected override Task DeliverAsync(Notification n)
        => _twilioClient.SendAsync(n.ToSms());
}

A document management platform defined IPlugin as an interface for third-party extensions. Using an abstract class had forced vendors to inherit from PluginBase, preventing them from using their own base classes. Switching to IPlugin (interface) allowed vendors to integrate without restructuring their inheritance — the plugin ecosystem grew from 4 vendors to 23 in one year.

Interfaces = contracts for unrelated types (multiple implementation). Abstract classes = shared implementation for related types (single inheritance). Use default interface methods (C# 8+) to evolve interfaces without breaking implementors.
⚠️ Common Mistake

Candidates say "interfaces cannot have implementation" — outdated since C# 8 default interface methods:

❌ Wrong — outdated understanding (pre-C# 8)
// "Interfaces only have method signatures, nothing else"
public interface ILogger
{
    void Log(string message);
    // "Cannot have a method body here" — WRONG since C# 8!
}
✅ Correct — C# 8+ default interface methods
public interface ILogger
{
    void Log(string message);

    // Default methods — implementors get these for free
    void LogWarning(string msg) => Log($"[WARN] {msg}");
    void LogError(string msg) => Log($"[ERROR] {msg}");

    // Added in v2 without breaking ANY existing implementors
    void LogStructured(string template, params object[] args)
        => Log(string.Format(template, args));
}

// Existing class works unchanged — no recompile needed
public class ConsoleLogger : ILogger
{
    public void Log(string message) => Console.WriteLine(message);
    // LogWarning, LogError, LogStructured auto-available
}
🔁 Follow-Up Question

What are default interface methods in C# 8? When would you use them vs abstract classes?

11 What is LINQ? Explain query syntax vs method syntax with examples. intermediate

LINQ (Language Integrated Query) provides a unified query syntax for collections, databases, XML, and more — directly in C#. It supports two syntaxes: Query syntax (from x in collection where ... select ...) resembles SQL. Method syntax (collection.Where(...).Select(...)) uses extension methods and lambdas — this is more common in production code.

LINQ uses deferred execution — a query builds an expression tree or iterator but doesn't execute until enumerated (foreach, ToList(), Count(), First()). This means you can compose queries incrementally without hitting the data source until you actually need results. Key methods: Where, Select, OrderBy, GroupBy, Join, Aggregate, Any, All, First/FirstOrDefault, SelectMany.

Immediate execution operators like ToList(), ToArray(), Count(), Sum() force evaluation right away. Understanding deferred vs immediate execution is critical — it determines when your database query runs and how many times.

var products = new List<Product>
{
    new("Laptop", "Electronics", 75000m, 45),
    new("Mouse", "Electronics", 500m, 200),
    new("Desk Chair", "Furniture", 15000m, 30),
    new("Monitor", "Electronics", 25000m, 60),
    new("Bookshelf", "Furniture", 8000m, 15),
};

// ── Query syntax (SQL-like) ──
var expensiveQuery = from p in products
                     where p.Price > 10000
                     orderby p.Price descending
                     select new { p.Name, p.Price };

// ── Method syntax (more common in production) ──
var expensiveMethod = products
    .Where(p => p.Price > 10000)
    .OrderByDescending(p => p.Price)
    .Select(p => new { p.Name, p.Price });

// GroupBy — sales by category
var byCategory = products
    .GroupBy(p => p.Category)
    .Select(g => new
    {
        Category = g.Key,
        Count = g.Count(),
        TotalValue = g.Sum(p => p.Price * p.Stock),
        AvgPrice = g.Average(p => p.Price)
    });

// Deferred execution — query hasn't run yet!
var query = products.Where(p => p.Stock < 20); // no execution
var results = query.ToList();  // NOW it executes

// Chaining for complex queries
var report = products
    .Where(p => p.Price > 1000)
    .GroupBy(p => p.Category)
    .Where(g => g.Count() >= 2)
    .OrderBy(g => g.Key)
    .ToDictionary(g => g.Key, g => g.Sum(p => p.Price));

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

A reporting team replaced 500 lines of nested foreach loops with 15 LINQ queries for generating monthly sales analytics. Code review time dropped from 2 hours to 20 minutes, and the refactoring uncovered 3 bugs in the original loop logic — an off-by-one error in grouping, a missing null check, and a double-counting issue in the subtotal calculation.

LINQ provides a unified query API for any data source. Method syntax is dominant in production. Deferred execution means queries don't run until enumerated — compose first, execute last.
⚠️ Common Mistake

Calling .ToList() too early defeats deferred execution and wastes memory:

❌ Wrong — materializes ALL data, then filters in memory
// Loads ENTIRE table into memory, then filters in C#
var allOrders = dbContext.Orders.ToList();  // 1M rows loaded!
var recent = allOrders.Where(o => o.Date > cutoff);
var topTen = recent.OrderByDescending(o => o.Total).Take(10);
// Result: 1M rows transferred, 999,990 wasted
✅ Correct — build query first, materialize last
// Filter at database, only 10 rows transferred
var topTen = dbContext.Orders
    .Where(o => o.Date > cutoff)       // WHERE in SQL
    .OrderByDescending(o => o.Total)   // ORDER BY in SQL
    .Take(10)                          // TOP 10 in SQL
    .ToList();                         // NOW execute
// Result: database does the heavy lifting
🔁 Follow-Up Question

What is deferred execution in LINQ and why does it matter for performance?

12 Explain delegates, events, and lambda expressions in C#. intermediate

A delegate is a type-safe function pointer — it holds a reference to a method with a matching signature. Built-in delegates: Action<T> (void return), Func<T, TResult> (with return value), Predicate<T> (returns bool). Delegates enable callbacks, strategy pattern, and LINQ.

Lambda expressions (=>) are anonymous functions that create delegate instances inline: x => x > 5. They can capture variables from their enclosing scope (closures). Lambdas are the backbone of LINQ, event handlers, and functional programming in C#.

Events are a restricted delegate pattern — only the declaring class can raise (invoke) them, but any class can subscribe (+=) or unsubscribe (-=). This encapsulation prevents external code from clearing all subscribers or invoking the event directly. The standard pattern uses EventHandler<TEventArgs>.

// ── Built-in delegates ──
Func<int, int, int> add = (a, b) => a + b;
Action<string> log = message => Console.WriteLine($"[LOG] {message}");
Predicate<int> isEven = n => n % 2 == 0;

Console.WriteLine(add(3, 7));          // 10
log("Application started");            // [LOG] Application started
Console.WriteLine(isEven(4));           // True

// Using delegates with LINQ
var numbers = new[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var evens = numbers.Where(n => isEven(n)).ToList(); // [2, 4, 6, 8]

// ── Events — publisher/subscriber pattern ──
public class OrderService
{
    // Event declaration
    public event EventHandler<OrderEventArgs>? OrderPlaced;

    public void PlaceOrder(Order order)
    {
        // Process order...
        // Raise event — only this class can invoke it
        OrderPlaced?.Invoke(this, new OrderEventArgs(order));
    }
}

public record OrderEventArgs(Order Order);

// Subscribing
var service = new OrderService();
service.OrderPlaced += (sender, args) =>
    Console.WriteLine($"Order {args.Order.Id} placed!");
service.OrderPlaced += (sender, args) =>
    SendEmailConfirmation(args.Order);

// ── Closure — lambda captures outer variable ──
int threshold = 1000;
Func<Order, bool> isHighValue = order => order.Total > threshold;
threshold = 5000;  // changes the captured variable!
// isHighValue now uses 5000, not 1000 — closures capture the VARIABLE

An order processing system used events (OrderPlaced, PaymentProcessed, ShipmentCreated) to decouple 8 modules. Adding a new loyalty-points module took just 2 hours — subscribe to the OrderPlaced event, calculate points, done. Without events, it would have required modifying the OrderService class and retesting all existing modules — estimated at 2 weeks.

Delegates = type-safe function pointers (Func, Action, Predicate). Lambdas = inline anonymous functions. Events = restricted delegates (only owner can invoke). Unsubscribe (-=) to prevent memory leaks.
⚠️ Common Mistake

Not unsubscribing from events causes memory leaks — the publisher holds a reference to the subscriber, preventing garbage collection:

❌ Wrong — event subscription leak
public class Dashboard : IDisposable
{
    public Dashboard(OrderService service)
    {
        // Subscribe but never unsubscribe!
        service.OrderPlaced += OnOrderPlaced;
        // Even after Dashboard is "disposed", OrderService
        // still holds a reference → Dashboard is NEVER GC'd
    }

    private void OnOrderPlaced(object? s, OrderEventArgs e) { }
    public void Dispose() { /* forgot to unsubscribe */ }
}
✅ Correct — unsubscribe in Dispose
public class Dashboard : IDisposable
{
    private readonly OrderService _service;

    public Dashboard(OrderService service)
    {
        _service = service;
        _service.OrderPlaced += OnOrderPlaced;
    }

    private void OnOrderPlaced(object? s, OrderEventArgs e) { }

    public void Dispose()
    {
        _service.OrderPlaced -= OnOrderPlaced;  // ✅ unsubscribe
        // Now Dashboard can be garbage collected
    }
}
🔁 Follow-Up Question

What is the difference between Func, Action, and Predicate? When would you use each?

13 How does async/await work in C#? Explain Task, Task<T>, and the state machine. intermediate

async marks a method as asynchronous. await yields control back to the caller until the awaited Task completes — the thread is released to do other work (handle more HTTP requests, keep the UI responsive). The compiler transforms async methods into a state machine (IAsyncStateMachine) that tracks progress across await points.

Task represents an async operation with no return value. Task<T> returns a value of type T. ValueTask<T> is a stack-allocated alternative for hot paths where the result is often available synchronously (avoids Task heap allocation). Task.WhenAll() runs multiple tasks concurrently. CancellationToken enables cooperative cancellation.

Key rules: async is about freeing threads for I/O, not parallelism. Never use async void (except event handlers) — exceptions become unobservable. Never call .Result or .Wait() — causes deadlocks in UI/ASP.NET contexts. Use ConfigureAwait(false) in library code to avoid capturing the SynchronizationContext.

// Basic async/await — non-blocking I/O
public async Task<UserProfile> GetUserProfileAsync(int userId,
    CancellationToken ct = default)
{
    // Thread is released during await — can serve other requests
    var user = await _dbContext.Users
        .Include(u => u.Address)
        .FirstOrDefaultAsync(u => u.Id == userId, ct)
        ?? throw new NotFoundException($"User {userId}");

    var orders = await _orderService.GetRecentOrdersAsync(userId, ct);

    return new UserProfile(user, orders);
}

// Task.WhenAll — parallel I/O operations
public async Task<DashboardData> GetDashboardAsync(CancellationToken ct)
{
    // All three run CONCURRENTLY — total time = slowest one
    var statsTask = _analyticsService.GetStatsAsync(ct);
    var ordersTask = _orderService.GetRecentAsync(10, ct);
    var alertsTask = _alertService.GetActiveAsync(ct);

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

    return new DashboardData(
        statsTask.Result,   // safe after WhenAll
        ordersTask.Result,
        alertsTask.Result
    );
}

// CancellationToken — cooperative cancellation
public async Task ProcessBatchAsync(IEnumerable<Order> orders,
    CancellationToken ct)
{
    foreach (var order in orders)
    {
        ct.ThrowIfCancellationRequested();  // check before heavy work
        await ProcessSingleOrderAsync(order, ct);
    }
}

A web API handling 5,000 concurrent users switched from synchronous database calls (.Result blocking) to proper async/await. Thread pool usage dropped from 500 threads to 50 — each await released the thread back to the pool. P99 latency improved from 2.1 seconds to 180ms, and the server handled 3x more concurrent requests on the same hardware.

async/await frees threads during I/O — not parallelism, but concurrency. Use Task.WhenAll for parallel I/O. Never use async void. Never call .Result or .Wait(). Always pass CancellationToken.
⚠️ Common Mistake

Using async void instead of async Task — exceptions crash the process silently, and the caller cannot await or catch errors:

❌ Wrong — async void: unobservable exceptions
// async void — exception crashes the ENTIRE process!
public async void SendNotification(Order order)
{
    await _emailService.SendAsync(order.Email, "Confirmed");
    // If SendAsync throws, the exception is unobserved
    // No try/catch can catch it from the caller
    // The process may terminate without any log entry
}

// Caller has no idea it failed:
SendNotification(order);  // fire and forget — dangerous
✅ Correct — async Task: observable, awaitable
// async Task — caller can await, catch, log
public async Task SendNotificationAsync(Order order)
{
    await _emailService.SendAsync(order.Email, "Confirmed");
    // If this throws, the Task captures the exception
    // Caller can await and handle it properly
}

// Caller can await and handle errors:
try
{
    await SendNotificationAsync(order);
}
catch (SmtpException ex)
{
    _logger.LogError(ex, "Notification failed for {OrderId}", order.Id);
}
🔁 Follow-Up Question

What is ConfigureAwait(false) and when should you use it?

14 How does Dependency Injection work in ASP.NET Core? intermediate

Dependency Injection (DI) is a first-class, built-in feature in ASP.NET Core. Instead of classes creating their own dependencies (new SomeService()), dependencies are registered in a container and injected via constructors. This enables loose coupling, testability, and single responsibility.

Services are registered in Program.cs with three lifetimes: AddTransient<T> — new instance every time it's requested. AddScoped<T> — one instance per HTTP request (most common for DbContext, repositories). AddSingleton<T> — one instance for the entire application lifetime (configuration, caching).

The DI container (IServiceProvider) resolves the dependency graph automatically. Constructor injection is the primary pattern. .NET 8 added keyed services for registering multiple implementations of the same interface by name. Use IOptions<T> for configuration injection.

// ── Program.cs — register services ──
var builder = WebApplication.CreateBuilder(args);

// Transient — new instance every time
builder.Services.AddTransient<IEmailService, SmtpEmailService>();

// Scoped — one per HTTP request (ideal for DbContext)
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

// Singleton — one for app lifetime
builder.Services.AddSingleton<ICacheService, RedisCacheService>();

// Keyed services (.NET 8) — multiple implementations
builder.Services.AddKeyedSingleton<INotifier, EmailNotifier>("email");
builder.Services.AddKeyedSingleton<INotifier, SmsNotifier>("sms");

var app = builder.Build();

// ── Constructor injection — automatic resolution ──
public class OrderController : ControllerBase
{
    private readonly IOrderRepository _repo;
    private readonly IEmailService _email;
    private readonly ILogger<OrderController> _logger;

    // DI container provides all three automatically
    public OrderController(
        IOrderRepository repo,
        IEmailService email,
        ILogger<OrderController> logger)
    {
        _repo = repo;
        _email = email;
        _logger = logger;
    }

    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderDto dto)
    {
        var order = await _repo.CreateAsync(dto);
        await _email.SendConfirmationAsync(order);
        _logger.LogInformation("Order {Id} created", order.Id);
        return CreatedAtAction(nameof(Get), new { id = order.Id }, order);
    }
}

A monolithic application with 200+ new SomeService() calls scattered throughout was impossible to unit test — every test needed a real database, SMTP server, and payment gateway. After introducing DI with interfaces, unit test setup time dropped from 30 minutes (spinning up infrastructure) to 2 minutes (injecting mocks), and test coverage went from 8% to 72% in one quarter.

DI is built into ASP.NET Core. Transient = new every time. Scoped = one per request. Singleton = one for app lifetime. Constructor injection is the standard pattern. DI enables testability and loose coupling.
⚠️ Common Mistake

Injecting a scoped service into a singleton creates a captive dependency — the scoped service lives forever instead of per-request:

❌ Wrong — scoped inside singleton = captive dependency
// DbContext is Scoped (one per request)
builder.Services.AddScoped<AppDbContext>();

// CacheService is Singleton (lives forever)
builder.Services.AddSingleton<ICacheService, CacheService>();

public class CacheService : ICacheService
{
    private readonly AppDbContext _db; // ❌ Scoped captured by Singleton!

    public CacheService(AppDbContext db)
    {
        _db = db; // This DbContext NEVER gets disposed
        // Stale data, connection leaks, concurrency bugs
    }
}
✅ Correct — use IServiceScopeFactory in singletons
public class CacheService : ICacheService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public CacheService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public async Task RefreshCacheAsync()
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        // Fresh DbContext, properly scoped and disposed
        var data = await db.Products.ToListAsync();
    }
}
// Tip: enable ValidateScopes in development to catch this:
// builder.Host.UseDefaultServiceProvider(o => o.ValidateScopes = true);
🔁 Follow-Up Question

What is the difference between transient, scoped, and singleton lifetimes? Give a real example of when each is appropriate.

15 What is Entity Framework Core? Explain DbContext, migrations, and querying. intermediate

Entity Framework Core (EF Core) is Microsoft's ORM — it maps C# classes to database tables and translates LINQ queries to SQL. The central class is DbContext, which represents a database session and contains DbSet<T> properties for each table. EF Core supports code-first (define C# classes → generate schema) and database-first (reverse-engineer from existing DB).

Migrations track schema changes in code: dotnet ef migrations add InitialCreate generates a migration file, dotnet ef database update applies it. Migrations are versioned and reversible — enabling CI/CD-friendly database evolution.

Querying uses LINQ which EF Core translates to SQL. Include() enables eager loading (JOINs). AsNoTracking() disables change tracking for read-only queries (faster). FirstOrDefaultAsync() returns null instead of throwing. The change tracker monitors entity states (Added, Modified, Deleted, Unchanged) and generates SQL on SaveChangesAsync().

// ── Entity classes ──
public class Order
{
    public int Id { get; set; }
    public DateTime OrderDate { get; set; }
    public decimal Total { get; set; }
    public int CustomerId { get; set; }
    public Customer Customer { get; set; } = null!;
    public List<OrderItem> Items { get; set; } = new();
}

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Email { get; set; } = "";
    public List<Order> Orders { get; set; } = new();
}

// ── DbContext ──
public class AppDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>(e =>
        {
            e.HasOne(o => o.Customer).WithMany(c => c.Orders);
            e.Property(o => o.Total).HasPrecision(18, 2);
        });
    }
}

// ── Querying with LINQ → SQL ──
public class OrderRepository
{
    private readonly AppDbContext _db;
    public OrderRepository(AppDbContext db) => _db = db;

    // Eager loading with Include
    public async Task<Order?> GetWithItemsAsync(int id)
        => await _db.Orders
            .Include(o => o.Items)
            .Include(o => o.Customer)
            .FirstOrDefaultAsync(o => o.Id == id);

    // Read-only query — faster without change tracking
    public async Task<List<Order>> GetRecentAsync(int count)
        => await _db.Orders
            .AsNoTracking()
            .OrderByDescending(o => o.OrderDate)
            .Take(count)
            .ToListAsync();

    // Create
    public async Task<Order> CreateAsync(Order order)
    {
        _db.Orders.Add(order);         // State = Added
        await _db.SaveChangesAsync();  // INSERT INTO Orders...
        return order;                  // Id is auto-populated
    }
}

// CLI commands:
// dotnet ef migrations add InitialCreate
// dotnet ef database update
// dotnet ef migrations add AddOrderStatus

A SaaS platform used EF Core migrations to deploy 300+ schema changes over 2 years with zero downtime. Each migration was generated, tested in CI, reviewed in PR, and applied to staging before production. Rollback scripts were auto-generated. The team never had to write raw ALTER TABLE statements or coordinate manual database changes across 4 environments.

EF Core maps C# classes to tables via DbContext. Use Include() for eager loading, AsNoTracking() for reads, and migrations for schema evolution. Always use SaveChangesAsync() — it batches all tracked changes into a single transaction.
⚠️ Common Mistake

Not using AsNoTracking() for read-only queries adds unnecessary overhead — the change tracker monitors every entity for modifications:

❌ Wrong — tracking entities you'll never update
// Dashboard loads 10K orders just for display
var orders = await _db.Orders
    .Include(o => o.Customer)
    .OrderByDescending(o => o.OrderDate)
    .Take(10_000)
    .ToListAsync();
// Change tracker is monitoring ALL 10K entities
// Extra memory, extra CPU — for data you'll never modify
// Query time: ~800ms
✅ Correct — AsNoTracking for read-only queries
// No change tracking — much faster for read-only scenarios
var orders = await _db.Orders
    .AsNoTracking()                    // ← skip change tracking
    .Include(o => o.Customer)
    .OrderByDescending(o => o.OrderDate)
    .Take(10_000)
    .ToListAsync();
// No tracking overhead — faster materialization
// Query time: ~120ms (6.7x faster)

// Pro tip: set globally for read-heavy contexts
// _db.ChangeTracker.QueryTrackingBehavior =
//     QueryTrackingBehavior.NoTracking;
🔁 Follow-Up Question

What is the N+1 query problem in EF Core and how do you solve it?

16 Explain generics and constraints in C#. intermediate

Generics allow you to write type-safe code that works with any data type — List<T>, Dictionary<TKey, TValue>, custom Repository<T>. The type parameter T is specified at usage time, giving you compile-time type checking without boxing or casting.

Constraints restrict what types T can be: where T : class (reference type), where T : struct (value type), where T : new() (parameterless constructor), where T : IComparable<T> (implements interface), where T : BaseClass (inherits from class), where T : notnull (non-nullable). Multiple constraints can be combined.

Generics avoid the performance penalty of boxing (value types stored as object) and eliminate runtime casting errors. The runtime creates specialized versions for value types (no boxing) and shares one version for all reference types. Covariant (out T) and contravariant (in T) modifiers on interfaces/delegates enable flexible type assignments.

// Generic repository with constraints
public interface IEntity
{
    int Id { get; }
    DateTime CreatedAt { get; }
}

public class Repository<T> where T : class, IEntity
{
    private readonly AppDbContext _db;

    public Repository(AppDbContext db) => _db = db;

    public async Task<T?> GetByIdAsync(int id)
        => await _db.Set<T>().FindAsync(id);

    public async Task<List<T>> GetRecentAsync(int count)
        => await _db.Set<T>()
            .OrderByDescending(e => e.CreatedAt)  // IEntity guarantees this
            .Take(count)
            .AsNoTracking()
            .ToListAsync();

    public async Task<T> AddAsync(T entity)
    {
        _db.Set<T>().Add(entity);
        await _db.SaveChangesAsync();
        return entity;
    }
}

// Generic method with multiple constraints
public T CloneAndModify<T>(T original, Action<T> modify)
    where T : class, ICloneable
{
    var clone = (T)original.Clone();
    modify(clone);
    return clone;
}

// Generic with new() constraint — can create instances
public List<T> CreateBatch<T>(int count) where T : new()
{
    return Enumerable.Range(0, count).Select(_ => new T()).ToList();
}

// Usage — compiler enforces type safety
var orderRepo = new Repository<Order>(_db);     // Order : IEntity ✅
var orders = await orderRepo.GetRecentAsync(10);
// var badRepo = new Repository<string>(_db);    // ❌ string is not IEntity

A data access layer with 15 nearly identical repository classes (UserRepository, OrderRepository, ProductRepository, etc.) was refactored into one Repository<T> where T : class, IEntity. Code shrank from 3,000 lines to 300, new entities got CRUD operations instantly, and 12 copy-paste bugs in the individual repositories were eliminated.

Generics provide type-safe reusable code without boxing or casting. Use constraints (where T : class, IEntity) to restrict T and access interface members. Prefer generics over object-based APIs.
⚠️ Common Mistake

Using object instead of generics — loses type safety, causes boxing for value types, and requires unsafe casting:

❌ Wrong — object-based, no type safety
public class ObjectCache
{
    private Dictionary<string, object> _cache = new();

    public void Set(string key, object value) => _cache[key] = value;
    public object Get(string key) => _cache[key];
}

var cache = new ObjectCache();
cache.Set("count", 42);          // BOXING — int → object
int count = (int)cache.Get("count"); // UNBOXING + unsafe cast
cache.Set("name", "Alice");
int wrong = (int)cache.Get("name");  // Runtime InvalidCastException!
✅ Correct — generic, type-safe, no boxing
public class TypedCache
{
    private Dictionary<string, object> _cache = new();

    public void Set<T>(string key, T value) => _cache[key] = value!;
    public T Get<T>(string key) => (T)_cache[key];
}

var cache = new TypedCache();
cache.Set("count", 42);              // type inferred as int
int count = cache.Get<int>("count"); // type-safe retrieval
// cache.Get<int>("name");           // still runtime error, but
// caller explicitly chose the type — their responsibility
🔁 Follow-Up Question

What are covariance and contravariance in generics? Give a practical example.

17 What is the difference between IEnumerable<T> and IQueryable<T>? intermediate

IEnumerable<T> is for in-memory iteration. LINQ operators use Func<T, bool> delegates — the data is already in memory and filtering/sorting happens in C#. It's the base interface for all collections (List, Array, HashSet).

IQueryable<T> is for remote data sources (databases, APIs). LINQ operators use Expression<Func<T, bool>> — expression trees that can be translated to SQL, OData, or other query languages. Filtering happens at the data source, not in memory.

The critical difference: with IEnumerable, .Where() pulls all data into memory first, then filters. With IQueryable, .Where() adds a WHERE clause to the SQL query — only matching rows are transferred. This can be the difference between loading 1M rows vs 10 rows. However, returning IQueryable from repositories is controversial — it leaks query concerns outside the data layer.

// ── IEnumerable — filtering happens in C# (memory) ──
IEnumerable<Order> allOrders = await _db.Orders.ToListAsync(); // loads ALL
var filtered = allOrders.Where(o => o.Total > 1000);
// SQL generated: SELECT * FROM Orders  (no WHERE!)
// Then C# filters 1M rows in memory — slow and wasteful

// ── IQueryable — filtering happens in SQL (database) ──
IQueryable<Order> query = _db.Orders;  // no execution yet
var filtered = query.Where(o => o.Total > 1000);
// SQL generated: SELECT * FROM Orders WHERE Total > 1000
// Only matching rows transferred — fast!

var results = await filtered.ToListAsync();  // executes NOW

// ── Building queries incrementally with IQueryable ──
public async Task<PagedResult<Order>> SearchOrdersAsync(
    OrderFilter filter, int page, int pageSize)
{
    IQueryable<Order> query = _db.Orders.AsNoTracking();

    // Each condition adds to the SQL WHERE clause
    if (filter.MinAmount.HasValue)
        query = query.Where(o => o.Total >= filter.MinAmount.Value);

    if (!string.IsNullOrEmpty(filter.CustomerName))
        query = query.Where(o => o.Customer.Name.Contains(filter.CustomerName));

    if (filter.FromDate.HasValue)
        query = query.Where(o => o.OrderDate >= filter.FromDate.Value);

    // Count and paginate — both at database level
    var total = await query.CountAsync();
    var items = await query
        .OrderByDescending(o => o.OrderDate)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .ToListAsync();

    return new PagedResult<Order>(items, total, page, pageSize);
}

A report page loaded all 100K orders using IEnumerable (ToList() first, then LINQ filtering in C#). The page took 8 seconds and used 1.2 GB of memory. Changing the return type to IQueryable and applying .Where() before .ToListAsync() reduced the SQL result set from 100K to 500 rows — page load dropped to 200ms and memory to 3 MB.

IEnumerable = in-memory, uses delegates. IQueryable = remote, uses expression trees translated to SQL. Always filter on IQueryable BEFORE calling ToList() — let the database do the work.
⚠️ Common Mistake

Calling .ToList() before filtering converts IQueryable to IEnumerable — all filtering happens in memory instead of SQL:

❌ Wrong — ToList() first, filter in memory
public async Task<List<Order>> GetExpensiveOrders()
{
    var allOrders = await _db.Orders.ToListAsync();  // 1M rows loaded!
    return allOrders.Where(o => o.Total > 10000).ToList();
    // SQL: SELECT * FROM Orders  ← no WHERE clause
    // C# filters 1M objects in memory
    // Memory: ~800MB, Time: ~8s
}
✅ Correct — filter first, materialize last
public async Task<List<Order>> GetExpensiveOrders()
{
    return await _db.Orders
        .Where(o => o.Total > 10000)   // SQL WHERE clause
        .ToListAsync();                 // only matching rows
    // SQL: SELECT * FROM Orders WHERE Total > 10000
    // Only 500 rows transferred
    // Memory: ~3MB, Time: ~200ms
}
🔁 Follow-Up Question

Should repositories return IQueryable or IEnumerable? What are the trade-offs?

18 How does the middleware pipeline work in ASP.NET Core? intermediate

Middleware are components that form a pipeline to handle HTTP requests and responses. Each middleware can process the request, call next() to pass to the next middleware, or short-circuit by not calling next (e.g., returning 401 for unauthorized requests). The pipeline is bidirectional — code before await next() runs on the request path, code after runs on the response path.

Order matters critically. The standard order is: UseExceptionHandler → UseHsts → UseHttpsRedirection → UseStaticFiles → UseRouting → UseCors → UseAuthentication → UseAuthorization → UseEndpoints/MapControllers. Putting UseAuthorization before UseRouting breaks authorization because routing context isn't available yet.

Built-in middleware: exception handling, HTTPS redirect, static files, CORS, authentication, authorization, response caching, rate limiting (.NET 7+). Custom middleware can be convention-based (class with InvokeAsync) or factory-based (implements IMiddleware with DI support).

// ── Program.cs — middleware pipeline order ──
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();

var app = builder.Build();

// Order is CRITICAL — each wraps the next
app.UseExceptionHandler("/error");    // 1. Catch all unhandled exceptions
app.UseHttpsRedirection();            // 2. Redirect HTTP → HTTPS
app.UseStaticFiles();                 // 3. Serve wwwroot files (short-circuits)
app.UseRouting();                     // 4. Match URL to endpoint
app.UseCors("AllowFrontend");         // 5. CORS headers
app.UseAuthentication();              // 6. Who are you? (reads JWT/cookie)
app.UseAuthorization();               // 7. Can you access this? (checks policies)
app.MapControllers();                 // 8. Execute matched endpoint

app.Run();

// ── Custom middleware — request timing ──
public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;

    public RequestTimingMiddleware(RequestDelegate next,
        ILogger<RequestTimingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var sw = Stopwatch.StartNew();

        // Code BEFORE next() = request path
        context.Response.Headers["X-Request-Id"] = Guid.NewGuid().ToString();

        await _next(context);  // call next middleware

        // Code AFTER next() = response path
        sw.Stop();
        _logger.LogInformation("{Method} {Path} responded {StatusCode} in {Ms}ms",
            context.Request.Method,
            context.Request.Path,
            context.Response.StatusCode,
            sw.ElapsedMilliseconds);
    }
}

// Register: app.UseMiddleware<RequestTimingMiddleware>();

// ── Inline middleware with app.Use() ──
app.Use(async (context, next) =>
{
    if (context.Request.Path == "/health")
    {
        context.Response.StatusCode = 200;
        await context.Response.WriteAsync("OK");
        return;  // short-circuit — don't call next
    }
    await next(context);
});

Adding a RequestTimingMiddleware to a production API that logged method, path, status code, and elapsed time for every request identified 12 endpoints with >2-second response times that were invisible in aggregate monitoring dashboards. Three of those endpoints had missing database indexes — adding them reduced p95 latency by 85%.

Middleware forms a bidirectional pipeline — before next() = request, after next() = response. Order matters: routing before authorization, authentication before authorization. Short-circuit by not calling next().
⚠️ Common Mistake

Putting UseAuthorization() before UseRouting() breaks authorization because there's no routing context:

❌ Wrong — authorization before routing
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();    // ❌ BEFORE UseRouting!
app.UseRouting();          // Too late — auth had no endpoint context
app.MapControllers();
// Result: [Authorize] attributes are IGNORED
// All endpoints are accessible without authentication!
✅ Correct — proper middleware order
var app = builder.Build();
app.UseRouting();          // 1. Match URL to endpoint
app.UseAuthentication();   // 2. Read JWT/cookie
app.UseAuthorization();    // 3. Check [Authorize] on matched endpoint
app.MapControllers();      // 4. Execute endpoint
// Result: [Authorize] works correctly, unauthorized
// requests get 401/403 as expected
🔁 Follow-Up Question

How do you write custom middleware in ASP.NET Core? What is the difference between app.Use() and app.Map()?

19 What are extension methods in C#? intermediate

Extension methods add new methods to existing types without modifying their source code or creating derived classes. They are defined as static methods in a static class, with the this keyword on the first parameter indicating the type being extended. Once defined, they appear as instance methods on the extended type through IntelliSense.

LINQ is entirely built with extension methods on IEnumerable<T>Where(), Select(), OrderBy() are all extension methods defined in System.Linq.Enumerable. Extension methods are resolved at compile time (static dispatch) — they cannot override actual instance methods.

Common uses: adding utility methods to string, DateTime, IEnumerable; fluent API design; wrapping third-party types with convenience methods. They cannot access private or protected members of the type they extend.

public static class StringExtensions
{
    // Truncate with ellipsis
    public static string Truncate(this string value, int maxLength)
    {
        if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
            return value;
        return value[..maxLength] + "…";
    }

    // Slug for URLs
    public static string ToSlug(this string value)
    {
        return Regex.Replace(value.ToLower().Trim(), @"[^a-z0-9]+", "-")
                    .Trim('-');
    }
}

public static class DateTimeExtensions
{
    // "2 hours ago", "3 days ago"
    public static string ToRelativeTime(this DateTime dateTime)
    {
        var span = DateTime.UtcNow - dateTime;
        return span switch
        {
            { TotalMinutes: < 1 } => "just now",
            { TotalMinutes: < 60 } => $"{(int)span.TotalMinutes}m ago",
            { TotalHours: < 24 } => $"{(int)span.TotalHours}h ago",
            { TotalDays: < 30 } => $"{(int)span.TotalDays}d ago",
            _ => dateTime.ToString("MMM d, yyyy")
        };
    }
}

public static class EnumerableExtensions
{
    // Batch a sequence into chunks
    public static IEnumerable<IEnumerable<T>> Batch<T>(
        this IEnumerable<T> source, int size)
    {
        var batch = new List<T>(size);
        foreach (var item in source)
        {
            batch.Add(item);
            if (batch.Count == size)
            {
                yield return batch;
                batch = new List<T>(size);
            }
        }
        if (batch.Count > 0) yield return batch;
    }
}

// Usage — looks like native instance methods
string title = "How to Build Scalable .NET Applications".Truncate(30);
// "How to Build Scalable .NET A…"

string slug = "Hello World! C# is Great".ToSlug();
// "hello-world-c-is-great"

var posted = new DateTime(2026, 5, 30, 10, 0, 0, DateTimeKind.Utc);
Console.WriteLine(posted.ToRelativeTime()); // "2h ago"

var batches = Enumerable.Range(1, 100).Batch(25); // 4 batches of 25

A team created StringExtensions with Slugify(), MaskEmail(), ToTitleCase(), and Truncate() — used 500+ times across the codebase. It saved an estimated 40 hours of duplicate utility code and ensured consistent behavior (e.g., email masking always used the same format "p***@gmail.com" across all services).

Extension methods add functionality to existing types without modification. Define as static methods with "this" on the first parameter. LINQ is entirely built on extension methods. They're compile-time, not runtime.
⚠️ Common Mistake

Overusing extension methods for core business logic that belongs in the class itself:

❌ Wrong — business logic in extension method
public static class OrderExtensions
{
    // This is core business logic — belongs IN the Order class
    public static decimal CalculateTotal(this Order order)
    {
        var subtotal = order.Items.Sum(i => i.Price * i.Qty);
        var tax = subtotal * 0.18m;
        var discount = order.CouponCode != null ? subtotal * 0.1m : 0;
        return subtotal + tax - discount;
    }
    // Problem: can't access private fields
    // Problem: logic is separated from the data it operates on
    // Problem: harder to find when reading the Order class
}
✅ Correct — extension for convenience, not core logic
// Core logic in the class itself
public class Order
{
    public decimal CalculateTotal() { /* business logic here */ }
}

// Extension for convenience utilities only
public static class OrderExtensions
{
    public static string ToInvoiceString(this Order o)
        => $"INV-{o.Id:D6} | {o.Customer.Name} | {o.Total:C}";

    public static bool IsHighValue(this Order o)
        => o.Total > 10_000m;
}
// Rule: extensions for cross-cutting utilities,
// not domain-specific business logic
🔁 Follow-Up Question

Can extension methods access private members of the type they extend? Why or why not?

20 Explain pattern matching in C# — is, switch expressions, and property patterns. intermediate

Pattern matching tests a value against a pattern and optionally extracts data. The is keyword combines type-checking and casting in one step: if (obj is string s). Switch expressions (C# 8+) replace verbose switch statements with concise, expression-based syntax that can return values directly.

Property patterns match on object properties: person is { Age: > 18, Country: "US" }. Relational patterns use comparison operators: x is > 0 and < 100. Logical patterns combine with and, or, not. List patterns (C# 11) match array/list elements: [1, 2, ..] matches lists starting with 1, 2.

Pattern matching eliminates manual type-checking, casting, and deeply nested if-else chains. It makes code more expressive, safer (the compiler warns about unhandled cases), and easier to read.

// ── Type pattern with is ──
object data = GetData();
if (data is string text)
    Console.WriteLine($"Got text: {text.ToUpper()}");
else if (data is int number and > 0)
    Console.WriteLine($"Positive number: {number}");

// ── Switch expression — concise value-returning switch ──
string GetStatusEmoji(OrderStatus status) => status switch
{
    OrderStatus.Pending    => "⏳",
    OrderStatus.Processing => "🔄",
    OrderStatus.Shipped    => "📦",
    OrderStatus.Delivered  => "✅",
    OrderStatus.Cancelled  => "❌",
    _ => "❓"  // discard pattern — default case
};

// ── Property pattern — match on object properties ──
decimal CalculateShipping(Order order) => order switch
{
    { Total: > 5000 }                         => 0m,     // free shipping
    { Customer.IsPremium: true }              => 49m,    // premium rate
    { Items.Count: > 10, ShipTo: "Express" }  => 299m,  // bulk express
    { Total: > 1000 }                         => 99m,    // standard
    _                                          => 149m   // default
};

// ── Relational + logical patterns ──
string GetTemperatureAdvice(double celsius) => celsius switch
{
    < 0          => "Freezing — stay indoors",
    >= 0 and < 15 => "Cold — wear a jacket",
    >= 15 and < 30 => "Pleasant — enjoy outside",
    >= 30 and < 40 => "Hot — stay hydrated",
    >= 40         => "Extreme heat — avoid outdoors",
    double.NaN    => "Invalid reading"
};

// ── List pattern (C# 11) ──
string DescribeArray(int[] arr) => arr switch
{
    []           => "empty",
    [var single] => $"single element: {single}",
    [var first, .., var last] => $"from {first} to {last}",
};

// ── Tuple pattern — multiple values ──
string RockPaperScissors(string p1, string p2) => (p1, p2) switch
{
    ("rock", "scissors") => "Player 1 wins",
    ("scissors", "paper") => "Player 1 wins",
    ("paper", "rock") => "Player 1 wins",
    (var a, var b) when a == b => "Tie",
    _ => "Player 2 wins"
};

Replacing a 200-line if-else chain for processing order status transitions with a switch expression reduced the method to 25 lines and eliminated 4 bugs from missed edge cases. The compiler flagged two missing status values that the if-else chain had silently ignored — orders in those states were stuck in limbo for weeks before anyone noticed.

Pattern matching replaces verbose if-else/switch with expressive, compiler-checked patterns. Use switch expressions for value mapping, property patterns for object matching, and list patterns for sequence matching.
⚠️ Common Mistake

Candidates only know basic is type-checking and miss the powerful patterns added in C# 8-11:

❌ Wrong — verbose, old-style type checking
// Old approach — manual type check + cast + nested ifs
public string Describe(object shape)
{
    if (shape is Circle)
    {
        var c = (Circle)shape;
        if (c.Radius > 100) return "Large circle";
        return $"Circle r={c.Radius}";
    }
    else if (shape is Rectangle)
    {
        var r = (Rectangle)shape;
        if (r.Width == r.Height) return $"Square {r.Width}";
        return $"Rect {r.Width}x{r.Height}";
    }
    return "Unknown";
    // 15 lines, manual casting, easy to miss cases
}
✅ Correct — modern pattern matching
public string Describe(object shape) => shape switch
{
    Circle { Radius: > 100 }      => "Large circle",
    Circle c                       => $"Circle r={c.Radius}",
    Rectangle { Width: var w, Height: var h } when w == h
                                   => $"Square {w}",
    Rectangle r                    => $"Rect {r.Width}x{r.Height}",
    null                           => throw new ArgumentNullException(),
    _                              => "Unknown"
};
// 8 lines, type-safe, compiler warns on missing cases
🔁 Follow-Up Question

What are list patterns in C# 11? How do they compare to traditional array indexing?

21 How does memory management work in .NET? Explain stack vs heap and GC generations. advanced

.NET uses a managed memory model with two main storage areas:

  • Stack — stores value types (local variables, structs) and method call frames. Allocation/deallocation is extremely fast (pointer movement). Each thread gets its own stack.
  • Heap — stores reference types (classes, arrays, strings). Managed by the Garbage Collector (GC).

The GC uses a generational strategy with three generations:

  • Gen 0 — newly allocated objects. Collected most frequently (cheap, fast).
  • Gen 1 — objects that survived one Gen 0 collection. Acts as a buffer between short-lived and long-lived objects.
  • Gen 2 — long-lived objects. Collected infrequently (expensive full collection).

The Large Object Heap (LOH) stores objects ≥ 85,000 bytes and is collected with Gen 2. Starting in .NET 6, the LOH can be compacted on demand via GCSettings.LargeObjectHeapCompactionMode.

GC modes: Workstation GC (optimised for UI responsiveness) and Server GC (optimised for throughput with per-CPU heaps).

using System;

// Value type — lives on the stack
int x = 42;

// Reference type — object lives on the heap, reference on the stack
var list = new List<int> { 1, 2, 3 };

// Check GC generation of an object
Console.WriteLine($"Generation: {GC.GetGeneration(list)}"); // 0

GC.Collect(0); // Force Gen 0 collection
Console.WriteLine($"After Gen0 collect: {GC.GetGeneration(list)}"); // 1

GC.Collect(1);
Console.WriteLine($"After Gen1 collect: {GC.GetGeneration(list)}"); // 2

// GC memory info (.NET 5+)
var info = GC.GetGCMemoryInfo();
Console.WriteLine($"Heap size: {info.HeapSizeBytes / 1024}KB");
Console.WriteLine($"Gen0 collections: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen1 collections: {GC.CollectionCount(1)}");
Console.WriteLine($"Gen2 collections: {GC.CollectionCount(2)}");

An ASP.NET Core API was experiencing periodic latency spikes. Profiling showed frequent Gen 2 collections caused by large temporary byte arrays (image processing). The team moved to ArrayPool.Shared.Rent/Return, keeping allocations in Gen 0 and eliminating the LOH pressure. P99 latency dropped from 800ms to 120ms.

Most objects should die young in Gen 0. Avoid promoting objects unnecessarily — pool large objects, use structs for small short-lived data, and prefer Span/stackalloc over heap allocations in hot paths.
⚠️ Common Mistake
// ❌ Allocating large arrays in a hot loop — LOH pressure byte[] buffer = new byte[100_000]; // ≥85KB → goes to LOH
// ✅ Rent from ArrayPool — reuses memory, avoids GC var buffer = ArrayPool<byte>.Shared.Rent(100_000); try { /* use buffer */ } finally { ArrayPool<byte>.Shared.Return(buffer); }
🔁 Follow-Up Question

What is the Pinned Object Heap (POH) introduced in .NET 5? When would you pin objects?

22 What are Span<T> and Memory<T>? How do they enable zero-allocation code? advanced

Span<T> and Memory<T> are types that provide safe, bounds-checked views over contiguous memory without allocating new arrays or copying data.

  • Span<T> — a ref struct that lives only on the stack. Cannot be stored on the heap (no fields, no boxing, no async). Supports arrays, stackalloc, and native memory.
  • Memory<T> — a regular struct that can live on the heap. Safe for async methods and storage in fields. Get a Span from it via .Span property.

Key benefits:

  • Slicing without allocationspan.Slice(start, length) creates a view, not a copy.
  • stackalloc support — allocate on the stack and wrap in a Span for safe access.
  • Unified API — the same parsing/processing code works over arrays, strings (ReadOnlySpan<char>), and native buffers.

ReadOnlySpan<T> and ReadOnlyMemory<T> are immutable counterparts used extensively in string processing.

// Slice without allocation
int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
Span<int> slice = numbers.AsSpan(3, 4); // view of [4,5,6,7]
slice[0] = 99; // modifies original array!

// stackalloc + Span for zero-heap parsing
ReadOnlySpan<char> input = "2026-05-30".AsSpan();
ReadOnlySpan<char> year  = input[..4];   // "2026" — no string allocation
ReadOnlySpan<char> month = input[5..7];  // "05"
ReadOnlySpan<char> day   = input[8..];   // "30"

int y = int.Parse(year);
int m = int.Parse(month);
int d = int.Parse(day);

// stackalloc with Span
Span<byte> buffer = stackalloc byte[256];
buffer[0] = 0xFF;

// Memory<T> — safe in async contexts
async Task ProcessAsync(Memory<byte> data)
{
    await Task.Delay(100);
    var span = data.Span; // get Span when needed
    span[0] = 42;
}

A high-throughput log parser was allocating millions of substrings per second with string.Split and string.Substring. Rewriting it with ReadOnlySpan and MemoryExtensions.Split (or manual slicing) eliminated all intermediate string allocations, reducing GC pauses by 95% and doubling throughput.

Use Span for synchronous hot-path code that slices or parses data. Use Memory when you need to store the buffer or pass it to async methods. Both avoid copying data and reduce GC pressure.
⚠️ Common Mistake
// ❌ Creating substrings in a tight loop — heavy allocation foreach (var line in lines) { string name = line.Substring(0, line.IndexOf(',')); }
// ✅ Zero-allocation with Span foreach (var line in lines) { ReadOnlySpan<char> span = line.AsSpan(); ReadOnlySpan<char> name = span[..span.IndexOf(',')]; // process name without allocating a new string }
🔁 Follow-Up Question

What is the difference between stackalloc and ArrayPool? When would you choose one over the other?

23 What are expression trees in .NET? How are they used in LINQ and dynamic compilation? advanced

Expression trees represent code as a data structure (an abstract syntax tree) rather than executable IL. They live in the System.Linq.Expressions namespace.

  • When you assign a lambda to Expression<Func<T,bool>> instead of Func<T,bool>, the compiler generates an expression tree instead of a delegate.
  • LINQ providers (EF Core, LINQ to SQL) inspect expression trees to translate C# lambdas into SQL queries. This is why IQueryable uses Expression<Func<...>>.
  • You can build expression trees dynamically at runtime using the Expression factory methods, then compile them into delegates with .Compile().

Use cases beyond LINQ: dynamic query builders, rule engines, serialisation helpers, and code generation without Reflection.Emit.

using System.Linq.Expressions;

// Compiler-generated expression tree
Expression<Func<int, bool>> expr = x => x > 5;

// Inspect the tree
var body = (BinaryExpression)expr.Body;
Console.WriteLine($"Left: {body.Left}");     // x
Console.WriteLine($"Op: {body.NodeType}");    // GreaterThan
Console.WriteLine($"Right: {body.Right}");    // 5

// Compile and execute
Func<int, bool> compiled = expr.Compile();
Console.WriteLine(compiled(10)); // True

// Build expression tree dynamically
var param  = Expression.Parameter(typeof(string), "s");
var prop   = Expression.Property(param, "Length");
var five   = Expression.Constant(5);
var gt     = Expression.GreaterThan(prop, five);
var lambda = Expression.Lambda<Func<string, bool>>(gt, param);

// s => s.Length > 5
Func<string, bool> filter = lambda.Compile();
Console.WriteLine(filter("Hello!"));  // True
Console.WriteLine(filter("Hi"));      // False

A reporting module needed user-configurable filters like "Amount > 1000 AND Status == Active". Instead of building raw SQL, the team constructed expression trees dynamically from the filter config and passed them to EF Core's .Where() — keeping the code type-safe, injectable-SQL-free, and fully testable.

Expression trees turn code into data that can be inspected, modified, and translated. LINQ providers rely on them to convert C# lambdas to SQL. For dynamic scenarios, build trees at runtime and compile only once for performance.
⚠️ Common Mistake
// ❌ Compiling expression trees inside a loop — expensive foreach (var rule in rules) { var expr = BuildFilter(rule); var func = expr.Compile(); // JIT compilation every iteration results = results.Where(func); }
// ✅ Compile once and cache the delegate var cache = new Dictionary<string, Func<Item, bool>>(); foreach (var rule in rules) { if (!cache.TryGetValue(rule.Key, out var func)) { func = BuildFilter(rule).Compile(); cache[rule.Key] = func; } results = results.Where(func); }
🔁 Follow-Up Question

How does EF Core translate expression trees into SQL? What happens when it encounters an expression it cannot translate?

24 How does Reflection work in .NET? What are custom attributes and when should you use them? advanced

Reflection allows you to inspect and interact with type metadata at runtime — discovering types, methods, properties, and invoking members dynamically. It lives in System.Reflection.

  • Type inspectiontypeof(T), obj.GetType(), Assembly.GetTypes()
  • Member accessGetProperties(), GetMethods(), GetCustomAttributes()
  • Dynamic invocationMethodInfo.Invoke(), Activator.CreateInstance()

Custom attributes are metadata classes that derive from System.Attribute. They decorate types/members with information that can be read at runtime (or compile time with source generators).

Reflection is slow compared to direct calls (10-100× overhead). Mitigation strategies:

  • Cache PropertyInfo/MethodInfo objects
  • Use compiled expressions or DynamicMethod for repeated invocation
  • Prefer source generators (compile-time reflection) in .NET 6+
using System.Reflection;

// Define a custom attribute
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class MaxLengthAttribute : Attribute
{
    public int Length { get; }
    public MaxLengthAttribute(int length) => Length = length;
}

public class User
{
    [MaxLength(50)]
    public string Name { get; set; } = "";

    [MaxLength(100)]
    public string Email { get; set; } = "";
}

// Read attributes via reflection
var props = typeof(User).GetProperties();
foreach (var prop in props)
{
    var attr = prop.GetCustomAttribute<MaxLengthAttribute>();
    if (attr != null)
        Console.WriteLine($"{prop.Name}: max {attr.Length} chars");
}
// Output:
// Name: max 50 chars
// Email: max 100 chars

// Dynamic invocation
var user = Activator.CreateInstance<User>();
var nameProp = typeof(User).GetProperty("Name")!;
nameProp.SetValue(user, "Alice");
Console.WriteLine(nameProp.GetValue(user)); // Alice

A validation library reads custom attributes like [MaxLength], [Required], and [Range] from model properties at startup, caches the validators per type, and runs them on each request. Frameworks like ASP.NET Core model binding, EF Core, and System.Text.Json all use reflection (or source generators) to map between types and external data.

Use reflection for frameworks, serialisers, and DI containers where types are unknown at compile time. Always cache reflection results and consider source generators for hot paths. Custom attributes are the standard way to attach declarative metadata to code.
⚠️ Common Mistake
// ❌ Reflecting on every call — extremely slow bool Validate(object obj) { foreach (var prop in obj.GetType().GetProperties()) // no caching { var attr = prop.GetCustomAttribute<MaxLengthAttribute>(); // ... } return true; }
// ✅ Cache reflection metadata per type static readonly ConcurrentDictionary<Type, PropertyInfo[]> _cache = new(); bool Validate(object obj) { var props = _cache.GetOrAdd(obj.GetType(), t => t.GetProperties()); // reuse cached PropertyInfo[] return true; }
🔁 Follow-Up Question

What are source generators? How do they provide compile-time reflection and eliminate runtime reflection overhead?

25 What are covariance and contravariance in .NET generics? advanced

Variance describes how generic type parameters relate to inheritance:

  • Covariance (out T) — a generic type can be used where a more derived type is expected. IEnumerable<Dog> can be assigned to IEnumerable<Animal>. The type parameter appears only in output positions (return types).
  • Contravariance (in T) — a generic type can be used where a less derived type is expected. Action<Animal> can be assigned to Action<Dog>. The type parameter appears only in input positions (method parameters).
  • Invariance — the default. List<Dog> cannot be assigned to List<Animal> (List is mutable, so covariance would be unsafe).

Variance is supported only on interfaces and delegates, not on classes or structs. The compiler enforces that out parameters are not used in input positions and in parameters are not used in output positions.

Built-in examples: IEnumerable<out T>, IReadOnlyList<out T> (covariant); Action<in T>, IComparer<in T> (contravariant); Func<in T, out TResult> (both).

class Animal { public virtual string Speak() => "..."; }
class Dog : Animal { public override string Speak() => "Woof!"; }
class Cat : Animal { public override string Speak() => "Meow!"; }

// ── Covariance (out T) ──
IEnumerable<Dog> dogs = new List<Dog> { new Dog(), new Dog() };
IEnumerable<Animal> animals = dogs; // ✅ Covariant — Dog is Animal
foreach (var a in animals) Console.WriteLine(a.Speak());

// ── Contravariance (in T) ──
Action<Animal> printAnimal = a => Console.WriteLine(a.Speak());
Action<Dog> printDog = printAnimal; // ✅ Contravariant
printDog(new Dog()); // "Woof!"

// ── Custom covariant interface ──
interface IProducer<out T> { T Produce(); }

class DogFactory : IProducer<Dog>
{
    public Dog Produce() => new Dog();
}

IProducer<Animal> factory = new DogFactory(); // ✅ Covariant
Animal pet = factory.Produce();

// ── Custom contravariant interface ──
interface IConsumer<in T> { void Consume(T item); }

class AnimalShelter : IConsumer<Animal>
{
    public void Consume(Animal a) => Console.WriteLine($"Sheltered: {a.Speak()}");
}

IConsumer<Dog> dogShelter = new AnimalShelter(); // ✅ Contravariant
dogShelter.Consume(new Dog());

A plugin system defines IEventHandler (contravariant). A handler registered for the base BaseEvent type automatically handles all derived event types (OrderEvent, PaymentEvent) without explicit registration for each subtype. This dramatically simplifies event routing in CQRS architectures.

Use `out T` when a generic interface only produces T (return values) — enables covariance. Use `in T` when it only consumes T (parameters) — enables contravariance. This is type-safe because the compiler prevents misuse.
⚠️ Common Mistake
// ❌ Trying to use List<Dog> as List<Animal> — invariant! List<Dog> dogs = new(); // List<Animal> animals = dogs; // Compile error! // List is mutable, so covariance would allow: animals.Add(new Cat()) // which corrupts the Dog-typed list
// ✅ Use IEnumerable<T> (covariant) or IReadOnlyList<T> List<Dog> dogs = new() { new Dog() }; IEnumerable<Animal> animals = dogs; // ✅ Safe — read-only view IReadOnlyList<Animal> readOnlyAnimals = dogs; // ✅ Also covariant
🔁 Follow-Up Question

Why are arrays covariant in C# even though they are mutable? What runtime exception can this cause?

26 How do you achieve thread safety in .NET? Compare lock, SemaphoreSlim, and ConcurrentDictionary. advanced

Thread safety ensures shared data is accessed correctly by multiple threads. .NET provides several synchronisation primitives:

  • lock (Monitor) — mutual exclusion for short critical sections. Only one thread enters at a time. Simple and low-overhead for quick operations.
  • SemaphoreSlim — limits concurrent access to N threads. Supports WaitAsync() for async-friendly throttling (e.g., limiting concurrent HTTP calls).
  • ConcurrentDictionary<K,V> — a lock-free (fine-grained locking) thread-safe dictionary. Use GetOrAdd, AddOrUpdate, TryRemove for atomic compound operations.
  • Interlocked — atomic operations on primitives (Increment, CompareExchange). Fastest option for simple counters.
  • ReaderWriterLockSlim — allows multiple concurrent readers but exclusive writers. Useful for read-heavy caches.

In async code, never use lock (it blocks the thread). Use SemaphoreSlim(1,1) as an async-compatible mutex instead.

using System.Collections.Concurrent;

// ── lock — simple mutual exclusion ──
private readonly object _lock = new();
private int _count;

void IncrementSafe()
{
    lock (_lock) { _count++; }
}

// ── SemaphoreSlim — async throttling ──
private readonly SemaphoreSlim _semaphore = new(3); // max 3 concurrent

async Task<string> FetchWithThrottle(HttpClient http, string url)
{
    await _semaphore.WaitAsync();
    try { return await http.GetStringAsync(url); }
    finally { _semaphore.Release(); }
}

// ── ConcurrentDictionary — thread-safe cache ──
private readonly ConcurrentDictionary<string, byte[]> _cache = new();

byte[] GetOrLoad(string key)
{
    return _cache.GetOrAdd(key, k =>
    {
        Console.WriteLine($"Loading {k}...");
        return File.ReadAllBytes(k);
    });
}

// ── Interlocked — atomic counter ──
private int _requests;
void OnRequest() => Interlocked.Increment(ref _requests);

An API gateway needed to limit outbound calls to a rate-limited third-party service (max 5 concurrent). SemaphoreSlim(5) was used as an async throttle around HttpClient calls. A ConcurrentDictionary cached responses by key. Interlocked tracked total request counts for metrics — all without blocking threads.

Use lock for quick synchronous critical sections, SemaphoreSlim for async-compatible throttling and mutual exclusion, ConcurrentDictionary for thread-safe key-value operations, and Interlocked for atomic primitives. Never use lock in async code paths.
⚠️ Common Mistake
// ❌ Using lock in async code — blocks the thread pool thread private readonly object _lock = new(); async Task UpdateAsync() { lock (_lock) // Cannot await inside lock; blocks thread { // await _db.SaveChangesAsync(); // Compiler error! } }
// ✅ Use SemaphoreSlim as async mutex private readonly SemaphoreSlim _mutex = new(1, 1); async Task UpdateAsync() { await _mutex.WaitAsync(); try { await _db.SaveChangesAsync(); } finally { _mutex.Release(); } }
🔁 Follow-Up Question

What is the Channel type in .NET? How does it compare to BlockingCollection for producer-consumer patterns?

27 What are ASP.NET Core filters? Explain the filter pipeline and when to use each type. advanced

ASP.NET Core filters are components that run at specific stages of the MVC/Razor Pages request pipeline, allowing cross-cutting logic without repeating code in every action.

The filter pipeline executes in this order:

  1. Authorization filters — run first, short-circuit if unauthorized. Implement IAuthorizationFilter.
  2. Resource filters — run before model binding. Good for caching or short-circuiting. Implement IResourceFilter / IAsyncResourceFilter.
  3. Action filters — run before/after action method execution. Most common type for logging, validation, transformation. Implement IActionFilter.
  4. Exception filters — handle unhandled exceptions from action methods. Implement IExceptionFilter.
  5. Result filters — run before/after action result execution (e.g., modifying the response). Implement IResultFilter.

Filters can be applied as attributes on actions/controllers, registered globally in AddControllers(), or applied via filter factories for DI support.

Filters vs Middleware: Middleware has no knowledge of MVC (runs for all requests). Filters are MVC-aware and have access to ActionContext, model binding, and action arguments.

// ── Action Filter — logging execution time ──
public class TimingFilter : IAsyncActionFilter
{
    private readonly ILogger<TimingFilter> _logger;
    public TimingFilter(ILogger<TimingFilter> logger) => _logger = logger;

    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next)
    {
        var sw = Stopwatch.StartNew();
        var result = await next(); // execute the action
        sw.Stop();

        _logger.LogInformation("{Action} took {Ms}ms",
            context.ActionDescriptor.DisplayName, sw.ElapsedMilliseconds);
    }
}

// ── Exception Filter ──
public class ApiExceptionFilter : IExceptionFilter
{
    public void OnException(ExceptionContext context)
    {
        context.Result = new ObjectResult(new { error = context.Exception.Message })
        {
            StatusCode = 500
        };
        context.ExceptionHandled = true;
    }
}

// Registration
builder.Services.AddControllers(options =>
{
    options.Filters.Add<TimingFilter>();      // global
    options.Filters.Add<ApiExceptionFilter>(); // global
});

// Or as attribute on a controller/action
[ServiceFilter(typeof(TimingFilter))]
public class OrdersController : ControllerBase { }

A multi-tenant SaaS API uses a Resource Filter to resolve the tenant from the request header before model binding, injecting TenantContext into the pipeline. Action Filters handle audit logging. An Exception Filter standardises all error responses into a consistent ProblemDetails JSON format.

Use Authorization filters for access control, Resource filters for caching/short-circuiting, Action filters for cross-cutting logic like logging and validation, Exception filters for standardised error handling, and Result filters for response transformation.
⚠️ Common Mistake
// ❌ Using middleware for MVC-specific concerns app.Use(async (ctx, next) => { // No access to ActionContext, model binding, or action args var userId = ctx.Request.Headers["X-User"]; // raw header parsing await next(); });
// ✅ Use an Action Filter — has full MVC context public class AuditFilter : IActionFilter { public void OnActionExecuting(ActionExecutingContext ctx) { var args = ctx.ActionArguments; // model-bound parameters var user = ctx.HttpContext.User; // ClaimsPrincipal // Log action + args + user for audit trail } public void OnActionExecuted(ActionExecutedContext ctx) { } }
🔁 Follow-Up Question

What is the difference between TypeFilter and ServiceFilter? When would you use a filter factory?

28 What are advanced EF Core features like change tracking, global query filters, and raw SQL? advanced

EF Core provides powerful features beyond basic CRUD:

  • Change Tracking — EF Core tracks entity state (Added, Modified, Deleted, Unchanged, Detached). On SaveChanges(), it generates SQL only for changed entities. Use AsNoTracking() for read-only queries to boost performance.
  • Global Query Filters — model-level filters applied automatically to all queries. Perfect for soft delete (IsDeleted == false) and multi-tenancy (TenantId == currentTenant).
  • Raw SQLFromSqlRaw() / FromSqlInterpolated() for complex queries while still materialising entities. SqlQueryRaw<T>() in EF Core 8 for scalar/DTO projections.
  • Compiled QueriesEF.CompileAsyncQuery() pre-compiles LINQ to avoid expression tree overhead on hot paths.
  • Interceptors — hook into SaveChanges, command execution, and connection lifecycle for audit logging, soft deletes, or query tagging.
// ── Global Query Filter (soft delete) ──
public class AppDbContext : DbContext
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder mb)
    {
        mb.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
    }
}

// All queries automatically exclude deleted items
var active = await db.Products.ToListAsync(); // WHERE IsDeleted = 0

// Bypass when needed
var all = await db.Products.IgnoreQueryFilters().ToListAsync();

// ── Change Tracking ──
var product = await db.Products.FindAsync(1);
product.Price = 29.99m; // state → Modified
await db.SaveChangesAsync(); // generates UPDATE for Price only

// Read-only — no tracking overhead
var list = await db.Products.AsNoTracking().ToListAsync();

// ── Raw SQL with parameterisation ──
var minPrice = 10m;
var expensive = await db.Products
    .FromSqlInterpolated($"SELECT * FROM Products WHERE Price > {minPrice}")
    .ToListAsync(); // parameterised — safe from SQL injection

// ── Compiled Query ──
private static readonly Func<AppDbContext, decimal, IAsyncEnumerable<Product>>
    _byPrice = EF.CompileAsyncQuery(
        (AppDbContext db, decimal min) =>
            db.Products.Where(p => p.Price > min));

// Usage — no expression tree compilation overhead
await foreach (var p in _byPrice(db, 50m))
    Console.WriteLine(p.Name);

A multi-tenant SaaS platform uses a global query filter with TenantId to ensure data isolation — every query automatically includes WHERE TenantId = @current. A SaveChanges interceptor auto-stamps CreatedAt/ModifiedAt. Compiled queries are used on the product listing endpoint handling 10K req/s to eliminate LINQ translation overhead.

Use AsNoTracking() for read-only queries, global query filters for cross-cutting concerns like soft delete and multi-tenancy, compiled queries for hot paths, and interceptors for audit trails. Always use FromSqlInterpolated (not FromSqlRaw with concatenation) to prevent SQL injection.
⚠️ Common Mistake
// ❌ SQL injection via string concatenation var name = userInput; var items = db.Products .FromSqlRaw("SELECT * FROM Products WHERE Name = '" + name + "'") .ToListAsync();
// ✅ Parameterised — safe from injection var items = await db.Products .FromSqlInterpolated($"SELECT * FROM Products WHERE Name = {userInput}") .ToListAsync(); // EF Core converts interpolated string to parameterised query
🔁 Follow-Up Question

What are owned entities and value converters in EF Core? How do they support DDD value objects?

29 What is Clean Architecture in .NET? How does CQRS with MediatR fit in? experienced

Clean Architecture organises code into concentric layers with dependencies pointing inward:

  • Domain (innermost) — entities, value objects, domain events, interfaces. Zero external dependencies.
  • Application — use cases (commands/queries), DTOs, validation, interface definitions (ports). Depends only on Domain.
  • Infrastructure — EF Core, external APIs, email, file system. Implements Application interfaces.
  • Presentation (outermost) — ASP.NET Core controllers/Minimal APIs, Blazor. Depends on Application.

CQRS (Command Query Responsibility Segregation) separates read and write models:

  • Commands — mutate state, return void or Result. CreateOrderCommand
  • Queries — read state, return data. GetOrderByIdQuery

MediatR is the most popular .NET library for implementing CQRS. It dispatches commands/queries to their handlers via an in-process mediator, decoupling controllers from business logic. Pipeline behaviors add cross-cutting concerns (validation, logging, transactions).

// ── Domain Layer ──
public class Order
{
    public int Id { get; private set; }
    public string Customer { get; private set; }
    public decimal Total { get; private set; }
    public OrderStatus Status { get; private set; }

    public static Order Create(string customer, decimal total)
    {
        return new Order { Customer = customer, Total = total, Status = OrderStatus.Pending };
    }

    public void Approve() => Status = OrderStatus.Approved;
}

// ── Application Layer — Command + Handler ──
public record CreateOrderCommand(string Customer, decimal Total) : IRequest<int>;

public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, int>
{
    private readonly IOrderRepository _repo;
    public CreateOrderHandler(IOrderRepository repo) => _repo = repo;

    public async Task<int> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        var order = Order.Create(cmd.Customer, cmd.Total);
        await _repo.AddAsync(order, ct);
        return order.Id;
    }
}

// ── Application Layer — Query + Handler ──
public record GetOrderQuery(int Id) : IRequest<OrderDto?>;

public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto?>
{
    private readonly IOrderRepository _repo;
    public GetOrderHandler(IOrderRepository repo) => _repo = repo;

    public async Task<OrderDto?> Handle(GetOrderQuery q, CancellationToken ct)
        => await _repo.GetDtoByIdAsync(q.Id, ct);
}

// ── Pipeline Behavior (cross-cutting) ──
public class LoggingBehavior<TReq, TRes> : IPipelineBehavior<TReq, TRes>
    where TReq : IRequest<TRes>
{
    private readonly ILogger<LoggingBehavior<TReq, TRes>> _log;
    public LoggingBehavior(ILogger<LoggingBehavior<TReq, TRes>> log) => _log = log;

    public async Task<TRes> Handle(TReq request, RequestHandlerDelegate<TRes> next, CancellationToken ct)
    {
        _log.LogInformation("Handling {Request}", typeof(TReq).Name);
        var result = await next();
        _log.LogInformation("Handled {Request}", typeof(TReq).Name);
        return result;
    }
}

// ── Presentation Layer — Controller ──
[ApiController, Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;
    public OrdersController(IMediator mediator) => _mediator = mediator;

    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderCommand cmd)
        => Ok(await _mediator.Send(cmd));

    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
        => Ok(await _mediator.Send(new GetOrderQuery(id)));
}

A fintech platform uses Clean Architecture with 4 projects (Domain, Application, Infrastructure, API). MediatR dispatches 80+ commands and queries. A ValidationBehavior pipeline runs FluentValidation on every command before it reaches the handler. A TransactionBehavior wraps commands in database transactions. Controllers are thin — just Send(command) — making them trivially testable.

Clean Architecture enforces dependency inversion — business logic never depends on frameworks. CQRS with MediatR decouples controllers from handlers and enables pipeline behaviors for cross-cutting concerns. Start simple and add complexity (event sourcing, separate read/write DBs) only when needed.
⚠️ Common Mistake
// ❌ Business logic in the controller — violates Clean Architecture [HttpPost] public async Task<IActionResult> Create(OrderDto dto) { var order = new Order { Customer = dto.Customer, Total = dto.Total }; _db.Orders.Add(order); await _db.SaveChangesAsync(); await _emailService.SendConfirmation(order); // mixed concerns return Ok(order.Id); }
// ✅ Controller delegates to MediatR — thin and testable [HttpPost] public async Task<IActionResult> Create(CreateOrderCommand cmd) => Ok(await _mediator.Send(cmd)); // Business logic lives in the handler // Email sending is a domain event or pipeline behavior
🔁 Follow-Up Question

When does CQRS become overkill? What are simpler alternatives for small applications?

30 How do you build microservices with .NET? Compare gRPC, REST, and message queues. experienced

Microservices in .NET decompose a system into independently deployable services. Communication patterns:

  • REST (HTTP/JSON) — universal, human-readable, easy to debug. Best for public APIs and browser clients. Use HttpClient with IHttpClientFactory and Polly for resilience.
  • gRPC (HTTP/2 + Protobuf) — binary protocol, 5-10× faster than JSON. Strongly typed contracts via .proto files. Supports streaming (server, client, bidirectional). Built into ASP.NET Core. Best for internal service-to-service calls.
  • Message Queues (async) — decouples producers from consumers. Services publish events to RabbitMQ, Azure Service Bus, or Kafka. MassTransit or Wolverine provides abstraction. Best for eventual consistency and event-driven architectures.

Key patterns for resilient microservices:

  • Service Discovery — Consul, Kubernetes DNS
  • Circuit Breaker — Polly / Microsoft.Extensions.Http.Resilience
  • API Gateway — YARP (Yet Another Reverse Proxy), Ocelot
  • Distributed Tracing — OpenTelemetry with Jaeger/Zipkin
  • Health ChecksMicrosoft.Extensions.Diagnostics.HealthChecks
// ── gRPC Service Definition (order.proto) ──
// syntax = "proto3";
// service OrderService {
//   rpc GetOrder (GetOrderRequest) returns (OrderReply);
//   rpc StreamOrders (Empty) returns (stream OrderReply);
// }

// ── gRPC Server (ASP.NET Core) ──
public class OrderGrpcService : OrderService.OrderServiceBase
{
    private readonly IMediator _mediator;
    public OrderGrpcService(IMediator mediator) => _mediator = mediator;

    public override async Task<OrderReply> GetOrder(
        GetOrderRequest request, ServerCallContext context)
    {
        var order = await _mediator.Send(new GetOrderQuery(request.Id));
        return new OrderReply { Id = order.Id, Customer = order.Customer };
    }
}

// ── gRPC Client ──
var channel = GrpcChannel.ForAddress("https://order-service:5001");
var client = new OrderService.OrderServiceClient(channel);
var reply = await client.GetOrderAsync(new GetOrderRequest { Id = 1 });

// ── REST with Resilience (IHttpClientFactory + Polly) ──
builder.Services.AddHttpClient("catalog", c =>
{
    c.BaseAddress = new Uri("https://catalog-service");
})
.AddStandardResilienceHandler(); // .NET 8 built-in retry + circuit breaker

// ── Message Queue with MassTransit ──
builder.Services.AddMassTransit(x =>
{
    x.AddConsumer<OrderCreatedConsumer>();
    x.UsingRabbitMq((ctx, cfg) =>
    {
        cfg.Host("rabbitmq://localhost");
        cfg.ConfigureEndpoints(ctx);
    });
});

// Publishing an event
public class OrderHandler : IRequestHandler<CreateOrderCommand, int>
{
    private readonly IPublishEndpoint _bus;
    public OrderHandler(IPublishEndpoint bus) => _bus = bus;

    public async Task<int> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        // ... create order ...
        await _bus.Publish(new OrderCreatedEvent(order.Id), ct);
        return order.Id;
    }
}

An e-commerce platform uses REST for its public storefront API, gRPC for internal service-to-service calls (Order→Inventory, Order→Payment) for speed, and RabbitMQ via MassTransit for async events (OrderCreated→EmailService, OrderCreated→AnalyticsService). YARP acts as the API gateway. OpenTelemetry traces requests across all services in Jaeger.

Use REST for public APIs, gRPC for fast internal service calls, and message queues for async event-driven communication. Always implement resilience patterns (retries, circuit breakers, timeouts). Start with a modular monolith and extract microservices only when you have a proven need.
⚠️ Common Mistake
// ❌ Synchronous chain of HTTP calls — fragile cascade var order = await _orderClient.GetAsync("/orders/1"); var inventory = await _inventoryClient.GetAsync($"/stock/{order.ProductId}"); var payment = await _paymentClient.PostAsync("/charge", ...); // If any service is down, everything fails
// ✅ Async events — decoupled and resilient await _bus.Publish(new OrderCreatedEvent(order.Id)); // InventoryService and PaymentService consume independently // Retry policies handle transient failures // System degrades gracefully if a service is temporarily down
🔁 Follow-Up Question

What is the Saga pattern? How does MassTransit implement sagas for distributed transactions?

31 How do you build custom middleware in ASP.NET Core? Explain the request pipeline. experienced

The ASP.NET Core request pipeline is a chain of middleware components. Each middleware can:

  • Process the incoming request
  • Call next() to pass control to the next middleware
  • Process the outgoing response (after next() returns)
  • Short-circuit the pipeline by not calling next()

Middleware ordering matters — the pipeline forms a Russian-doll model (first in, last out). Typical order:

  1. Exception handling
  2. HTTPS redirection
  3. Static files
  4. Routing
  5. CORS
  6. Authentication
  7. Authorization
  8. Custom middleware
  9. Endpoint execution

Three ways to write middleware:

  • Inlineapp.Use(async (ctx, next) => { ... })
  • Convention-based class — constructor takes RequestDelegate next, has InvokeAsync(HttpContext)
  • Factory-based (IMiddleware) — registered in DI, created per-request (supports scoped dependencies)
// ── Convention-based middleware ──
public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;

    public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var sw = Stopwatch.StartNew();

        // Add correlation ID
        var correlationId = context.Request.Headers["X-Correlation-Id"]
            .FirstOrDefault() ?? Guid.NewGuid().ToString();
        context.Items["CorrelationId"] = correlationId;
        context.Response.Headers["X-Correlation-Id"] = correlationId;

        try
        {
            await _next(context); // call next middleware
        }
        finally
        {
            sw.Stop();
            _logger.LogInformation(
                "{Method} {Path} → {Status} in {Ms}ms [CID:{Cid}]",
                context.Request.Method,
                context.Request.Path,
                context.Response.StatusCode,
                sw.ElapsedMilliseconds,
                correlationId);
        }
    }
}

// Extension method for clean registration
public static class MiddlewareExtensions
{
    public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app)
        => app.UseMiddleware<RequestTimingMiddleware>();
}

// Registration — order matters!
var app = builder.Build();
app.UseExceptionHandler("/error");
app.UseRequestTiming();        // early — wraps everything below
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

A production API uses a chain of custom middleware: (1) CorrelationIdMiddleware assigns a trace ID to every request, (2) RequestTimingMiddleware logs latency with structured logging, (3) TenantResolutionMiddleware reads the tenant from a JWT claim and sets TenantContext for downstream DI. All registered before Authentication/Authorization in the pipeline.

Middleware runs for every request in the order registered. Use convention-based middleware for singletons with injected services. Use IMiddleware (factory-based) when you need scoped DI. Always call next() unless you intentionally short-circuit.
⚠️ Common Mistake
// ❌ Registering middleware after MapControllers — never executes app.MapControllers(); app.UseRequestTiming(); // too late! Endpoints already matched app.UseAuthentication(); // also too late
// ✅ Correct order — middleware before endpoint mapping app.UseRequestTiming(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); // endpoints last
🔁 Follow-Up Question

What is the difference between app.Use(), app.Map(), and app.Run()? When would you branch the pipeline?

32 What are Source Generators in .NET? How do they provide compile-time code generation? experienced

Source Generators are compiler plugins (Roslyn analyzers) that generate C# source code at compile time. They inspect your code via the Roslyn syntax/semantic model and emit new partial classes, methods, or types that become part of the compilation.

  • No runtime reflection — generated code is regular C#, fully AOT-compatible and trimmer-friendly.
  • Incremental generators (preferred in .NET 6+) — IIncrementalGenerator caches pipeline stages for fast rebuilds during editing.
  • Debuggable — generated files appear in the IDE under Dependencies → Analyzers.

Microsoft uses source generators extensively:

  • System.Text.Json[JsonSerializable] generates serialisation code, avoiding reflection.
  • LoggerMessage[LoggerMessage] generates high-perf logging methods.
  • Regex[GeneratedRegex] compiles regex to IL at build time (.NET 7+).
  • Minimal APIs — generates request delegate code in .NET 8 for faster startup.

Source generators are additive-only; they cannot modify existing source code (unlike Fody/PostSharp IL weaving).

// ── Using System.Text.Json source generator ──
[JsonSerializable(typeof(WeatherForecast))]
[JsonSerializable(typeof(List<WeatherForecast>))]
public partial class AppJsonContext : JsonSerializerContext { }

// Usage — no reflection, AOT-compatible
var json = JsonSerializer.Serialize(forecast, AppJsonContext.Default.WeatherForecast);
var obj = JsonSerializer.Deserialize(json, AppJsonContext.Default.WeatherForecast);

// ── Using LoggerMessage source generator ──
public static partial class LogMessages
{
    [LoggerMessage(Level = LogLevel.Information,
        Message = "Processing order {OrderId} for {Customer}")]
    public static partial void OrderProcessing(
        ILogger logger, int orderId, string customer);
}
// Usage — zero allocation logging
LogMessages.OrderProcessing(_logger, order.Id, order.Customer);

// ── Using GeneratedRegex (.NET 7+) ──
public partial class Validators
{
    [GeneratedRegex(@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")]
    public static partial Regex EmailRegex();
}
// Usage — compiled to IL at build time
bool isValid = Validators.EmailRegex().IsMatch("user@example.com");

// ── Minimal API with source gen (Program.cs) ──
var app = builder.Build();
app.MapGet("/weather", () => new WeatherForecast("London", 22));
app.Run();

A high-throughput trading API switched from reflection-based System.Text.Json to the source generator approach ([JsonSerializable]). Serialisation throughput increased 40%, startup time dropped because no runtime reflection was needed, and the app became compatible with Native AOT deployment, reducing the Docker image from 200MB to 30MB.

Source generators eliminate reflection overhead by generating code at compile time. Use [JsonSerializable] for fast JSON, [LoggerMessage] for zero-alloc logging, and [GeneratedRegex] for compiled regex. They are essential for Native AOT and trimming scenarios.
⚠️ Common Mistake
// ❌ Runtime reflection for JSON — slow startup, not AOT-compatible var json = JsonSerializer.Serialize(obj); // Uses reflection to discover properties at runtime
// ✅ Source-generated — compile-time, AOT-compatible [JsonSerializable(typeof(MyDto))] public partial class MyJsonCtx : JsonSerializerContext { } var json = JsonSerializer.Serialize(obj, MyJsonCtx.Default.MyDto);
🔁 Follow-Up Question

How would you write your own source generator? What is the difference between ISourceGenerator and IIncrementalGenerator?

33 How does JWT authentication and authorization work in ASP.NET Core? experienced

JWT (JSON Web Token) authentication in ASP.NET Core validates bearer tokens attached to HTTP requests. The flow:

  1. Client authenticates (e.g., login endpoint) → server issues a signed JWT containing claims.
  2. Client sends JWT in Authorization: Bearer <token> header.
  3. ASP.NET Core middleware validates the token (signature, expiry, issuer, audience).
  4. HttpContext.User is populated with the token's claims as a ClaimsPrincipal.

Authorization builds on authentication:

  • [Authorize] — requires any authenticated user.
  • Role-based[Authorize(Roles = "Admin")] checks role claims.
  • Policy-based — custom requirements + handlers. Most flexible. [Authorize(Policy = "CanEditOrders")]
  • Resource-based — authorise against a specific resource (e.g., "can this user edit this order?") using IAuthorizationService.

Best practices: keep tokens short-lived (15-30 min), use refresh tokens, store signing keys securely, validate all standard claims (iss, aud, exp).

// ── Program.cs — JWT Configuration ──
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
        };
    });

// ── Policy-based Authorization ──
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy =>
        policy.RequireRole("Admin"));

    options.AddPolicy("CanEditOrders", policy =>
        policy.RequireClaim("permission", "orders.edit"));
});

// ── Token Generation ──
public class AuthService
{
    public string GenerateToken(User user)
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new Claim(ClaimTypes.Email, user.Email),
            new Claim(ClaimTypes.Role, user.Role),
            new Claim("permission", "orders.edit")
        };

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: _config["Jwt:Issuer"],
            audience: _config["Jwt:Audience"],
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(30),
            signingCredentials: creds);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

// ── Controller with policies ──
[ApiController, Route("api/orders")]
[Authorize] // all endpoints require authentication
public class OrdersController : ControllerBase
{
    [HttpGet]
    public IActionResult GetAll() => Ok(/* ... */);

    [HttpPut("{id}")]
    [Authorize(Policy = "CanEditOrders")]
    public IActionResult Update(int id) => Ok(/* ... */);

    [HttpDelete("{id}")]
    [Authorize(Policy = "AdminOnly")]
    public IActionResult Delete(int id) => Ok(/* ... */);
}

A SaaS platform issues JWTs with tenant ID, user ID, role, and permission claims. A custom authorization handler verifies that the user's tenant claim matches the requested resource's tenant — preventing cross-tenant data access. Refresh tokens are stored in an HTTP-only cookie with a 7-day expiry. Token revocation is handled via a Redis-backed blacklist checked in a custom middleware.

Configure JWT validation with strict parameters (issuer, audience, signing key, lifetime). Use policy-based authorization for fine-grained access control. Keep tokens short-lived with refresh token rotation. Never store JWTs in localStorage — use HTTP-only cookies.
⚠️ Common Mistake
// ❌ Disabling token validation — accepts any token! options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = false, // expired tokens accepted! ValidateIssuerSigningKey = false };
// ✅ Strict validation — all checks enabled options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = config["Jwt:Issuer"], ValidAudience = config["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey(keyBytes), ClockSkew = TimeSpan.FromMinutes(1) // reduce default 5min skew };
🔁 Follow-Up Question

What is refresh token rotation? How do you implement token revocation without a database lookup on every request?

34 How do background services work in .NET? Explain IHostedService and BackgroundService. experienced

Hosted services are long-running background tasks managed by the .NET Generic Host. Two main approaches:

  • IHostedService — interface with StartAsync and StopAsync. Full control over lifecycle. Good for startup/shutdown hooks.
  • BackgroundService — abstract class (implements IHostedService) with a single ExecuteAsync method. Simplifies the common "loop forever" pattern.

Key considerations:

  • Hosted services are singletons — they live for the app's lifetime.
  • To use scoped services (like DbContext), create a scope via IServiceScopeFactory.
  • ExecuteAsync receives a CancellationToken that signals when the host is shutting down — always respect it.
  • In .NET 8+, unhandled exceptions in hosted services crash the host by default (configurable via HostOptions.BackgroundServiceExceptionBehavior).

Use cases: queue processors, scheduled jobs, cache warming, health-check polling, WebSocket connection managers.

// ── Simple recurring task ──
public class CleanupService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<CleanupService> _logger;

    public CleanupService(IServiceScopeFactory scopeFactory, ILogger<CleanupService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                using var scope = _scopeFactory.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

                var cutoff = DateTime.UtcNow.AddDays(-30);
                var deleted = await db.Logs
                    .Where(l => l.CreatedAt < cutoff)
                    .ExecuteDeleteAsync(stoppingToken);

                _logger.LogInformation("Cleaned up {Count} old logs", deleted);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                _logger.LogError(ex, "Cleanup failed");
            }

            await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
        }
    }
}

// ── Queue processor ──
public class OrderProcessor : BackgroundService
{
    private readonly Channel<Order> _channel;

    public OrderProcessor(Channel<Order> channel) => _channel = channel;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var order in _channel.Reader.ReadAllAsync(stoppingToken))
        {
            // Process order asynchronously
            await ProcessOrderAsync(order);
        }
    }
}

// ── Registration ──
builder.Services.AddHostedService<CleanupService>();
builder.Services.AddHostedService<OrderProcessor>();

An e-commerce API uses three background services: (1) OrderProcessor reads from a Channel and sends confirmation emails, (2) InventorySyncService polls an external warehouse API every 5 minutes, (3) CacheWarmupService preloads frequently accessed data on startup. All use IServiceScopeFactory to access scoped DbContext instances safely.

Use BackgroundService for looping background work and IHostedService for startup/shutdown hooks. Always create a DI scope for scoped services, respect the CancellationToken, and handle exceptions to prevent host crashes.
⚠️ Common Mistake
// ❌ Injecting scoped service directly — singleton can't hold scoped public class BadService : BackgroundService { private readonly AppDbContext _db; // scoped! Will throw at runtime public BadService(AppDbContext db) => _db = db; }
// ✅ Create a scope per iteration public class GoodService : BackgroundService { private readonly IServiceScopeFactory _sf; public GoodService(IServiceScopeFactory sf) => _sf = sf; protected override async Task ExecuteAsync(CancellationToken ct) { using var scope = _sf.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); } }
🔁 Follow-Up Question

How does .NET 8's PeriodicTimer differ from Task.Delay for scheduled work? What about Quartz.NET or Hangfire for complex scheduling?

35 How do you implement structured logging, health checks, and observability in .NET? experienced

Production .NET applications need three observability pillars:

1. Structured Logging — log events as structured data (key-value pairs), not plain strings. Built-in ILogger supports message templates with named placeholders. Serilog and NLog are popular enriched providers that output to Seq, Elasticsearch, or Application Insights.

2. Health ChecksMicrosoft.Extensions.Diagnostics.HealthChecks provides a framework for liveness and readiness probes. Custom health checks verify database connectivity, external APIs, disk space, etc. Kubernetes uses these endpoints (/healthz, /ready) for pod management.

3. Distributed TracingOpenTelemetry is the industry standard. The .NET SDK (OpenTelemetry.Extensions.Hosting) collects traces, metrics, and logs, exporting to Jaeger, Zipkin, Prometheus, or OTLP collectors. Activity and ActivitySource are the built-in .NET APIs for creating spans.

.NET 8+ has first-class OpenTelemetry support with builder.Services.AddOpenTelemetry().

// ── Structured Logging with Serilog ──
builder.Host.UseSerilog((ctx, cfg) => cfg
    .ReadFrom.Configuration(ctx.Configuration)
    .Enrich.FromLogContext()
    .WriteTo.Console(new JsonFormatter())
    .WriteTo.Seq("http://localhost:5341"));

// Usage — structured, not string interpolation
_logger.LogInformation("Order {OrderId} placed by {Customer} for {Total:C}",
    order.Id, order.Customer, order.Total);
// Output: {"OrderId":42,"Customer":"Alice","Total":"$99.00",...}

// ── Health Checks ──
builder.Services.AddHealthChecks()
    .AddSqlServer(connectionString, name: "database")
    .AddRedis(redisConnection, name: "cache")
    .AddCheck<ExternalApiHealthCheck>("payment-api");

// Custom health check
public class ExternalApiHealthCheck : IHealthCheck
{
    private readonly HttpClient _http;
    public ExternalApiHealthCheck(HttpClient http) => _http = http;

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext ctx, CancellationToken ct = default)
    {
        try
        {
            var r = await _http.GetAsync("/health", ct);
            return r.IsSuccessStatusCode
                ? HealthCheckResult.Healthy()
                : HealthCheckResult.Degraded("Payment API slow");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Payment API down", ex);
        }
    }
}

app.MapHealthChecks("/healthz");

// ── OpenTelemetry ──
builder.Services.AddOpenTelemetry()
    .WithTracing(t => t
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddEntityFrameworkCoreInstrumentation()
        .AddOtlpExporter())
    .WithMetrics(m => m
        .AddAspNetCoreInstrumentation()
        .AddRuntimeInstrumentation()
        .AddOtlpExporter());

A microservices platform uses Serilog with Seq for centralised structured logging, custom health checks for each dependency (SQL, Redis, RabbitMQ, third-party APIs), and OpenTelemetry exported to Jaeger for distributed tracing. Kubernetes uses /healthz for liveness probes and /ready (which includes dependency checks) for readiness probes. Grafana dashboards visualise .NET metrics (request rate, error rate, GC pauses) from Prometheus.

Use structured logging (message templates, not string interpolation) for queryable logs. Add health checks for every external dependency. Instrument with OpenTelemetry for distributed tracing and metrics. These three pillars are essential for diagnosing production issues in distributed systems.
⚠️ Common Mistake
// ❌ String interpolation — destroys structured data _logger.LogInformation($"Order {order.Id} placed by {order.Customer}"); // Logged as a flat string — cannot query by OrderId
// ✅ Message template — structured, queryable _logger.LogInformation("Order {OrderId} placed by {Customer}", order.Id, order.Customer); // Logged with OrderId and Customer as separate searchable fields
🔁 Follow-Up Question

What are .NET Meters and the new System.Diagnostics.Metrics API? How do custom metrics work with Prometheus?

36 How do you benchmark .NET code with BenchmarkDotNet? What common pitfalls does it prevent? performance

BenchmarkDotNet is the standard .NET micro-benchmarking library. It eliminates common measurement mistakes by:

  • Warmup iterations — ensures JIT compilation is complete before measuring.
  • Multiple invocations — runs the benchmark many times and reports statistical summaries (mean, median, std dev, percentiles).
  • Preventing dead-code elimination — the runtime can optimise away code with unused results; BenchmarkDotNet prevents this.
  • GC collection reporting — shows Gen 0/1/2 collections and allocated bytes per operation.
  • Multiple runtimes — compare the same benchmark across .NET 6, 8, 9 and Framework in one run.

Key attributes: [Benchmark] marks methods, [Params] parameterises inputs, [MemoryDiagnoser] tracks allocations, [GlobalSetup] / [GlobalCleanup] for fixture setup.

Never benchmark with Stopwatch in a console app — you'll miss JIT effects, GC noise, and CPU frequency scaling.

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]
[RankColumn]
public class StringBenchmarks
{
    private string[] _names = null!;

    [Params(100, 1000)]
    public int N;

    [GlobalSetup]
    public void Setup()
    {
        _names = Enumerable.Range(0, N).Select(i => $"User_{i}").ToArray();
    }

    [Benchmark(Baseline = true)]
    public string Concatenation()
    {
        var result = "";
        foreach (var name in _names) result += name + ",";
        return result;
    }

    [Benchmark]
    public string StringBuilder()
    {
        var sb = new System.Text.StringBuilder();
        foreach (var name in _names) sb.Append(name).Append(',');
        return sb.ToString();
    }

    [Benchmark]
    public string StringJoin()
    {
        return string.Join(",", _names);
    }
}

// Run: dotnet run -c Release
// BenchmarkRunner.Run<StringBenchmarks>();

// Sample output:
// |         Method |    N |       Mean | Allocated |
// |--------------- |----- |-----------:|----------:|
// | Concatenation  |  100 |  12.450 us |   52.3 KB |
// | StringBuilder  |  100 |   1.230 us |    1.2 KB |
// | StringJoin     |  100 |   0.980 us |    0.8 KB |
// | Concatenation  | 1000 | 850.300 us | 4900.0 KB |
// | StringBuilder  | 1000 |  11.200 us |   10.5 KB |
// | StringJoin     | 1000 |   9.800 us |    8.1 KB |

Before optimising a JSON serialiser, the team wrote BenchmarkDotNet benchmarks comparing System.Text.Json (reflection), System.Text.Json (source-gen), and Newtonsoft.Json. Results showed source-gen was 3× faster with zero allocations. The benchmarks were added to CI to catch performance regressions — any PR increasing mean time by >10% failed the gate.

Always benchmark with BenchmarkDotNet in Release mode, not Debug. Use [MemoryDiagnoser] to track allocations. Compare approaches side-by-side with [Params]. Never trust Stopwatch measurements for micro-benchmarks — JIT warmup, GC, and dead-code elimination will mislead you.
⚠️ Common Mistake
// ❌ Benchmarking with Stopwatch — unreliable var sw = Stopwatch.StartNew(); for (int i = 0; i < 1000; i++) DoWork(); // JIT not warmed up, GC noise sw.Stop(); Console.WriteLine($"Elapsed: {sw.ElapsedMilliseconds}ms");
// ✅ Use BenchmarkDotNet — statistically sound [MemoryDiagnoser] public class MyBenchmarks { [Benchmark] public void DoWork() { /* ... */ } } // Run: dotnet run -c Release // Gets warmup, multiple iterations, GC stats, allocations
🔁 Follow-Up Question

How do you add BenchmarkDotNet to a CI pipeline to catch performance regressions automatically?

37 What is object pooling in .NET? How do ObjectPool<T> and ArrayPool<T> reduce GC pressure? performance

Object pooling reuses expensive-to-create objects instead of allocating and garbage-collecting them repeatedly. .NET provides two built-in pooling APIs:

  • ArrayPool<T> (System.Buffers) — pools arrays. ArrayPool<T>.Shared is a global pool. Call Rent(size) to borrow an array (may be larger than requested) and Return(array) when done. Essential for avoiding Large Object Heap allocations.
  • ObjectPool<T> (Microsoft.Extensions.ObjectPool) — pools arbitrary objects. Configured with a PooledObjectPolicy<T> that defines Create() and Return() logic. Used internally by ASP.NET Core for StringBuilder instances.

When to pool:

  • Frequent allocation of large arrays (≥85KB → LOH)
  • Expensive object creation (database connections, HTTP clients — though IHttpClientFactory handles this)
  • High-throughput hot paths where GC Gen 0 collections become measurable

RecyclableMemoryStream (Microsoft.IO) is a pooled MemoryStream replacement that avoids LOH fragmentation — used by ASP.NET Core internally.

using System.Buffers;
using Microsoft.Extensions.ObjectPool;

// ── ArrayPool<T> — rent and return byte arrays ──
var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(4096); // may return 4096 or larger
try
{
    int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, 4096));
    ProcessData(buffer.AsSpan(0, bytesRead));
}
finally
{
    pool.Return(buffer, clearArray: true); // clear sensitive data
}

// ── ObjectPool<T> — pool StringBuilder instances ──
var provider = new DefaultObjectPoolProvider();
var policy = new StringBuilderPooledObjectPolicy();
var sbPool = provider.Create(policy);

var sb = sbPool.Get();
try
{
    sb.Append("Hello, ");
    sb.Append("pooled ");
    sb.Append("world!");
    Console.WriteLine(sb.ToString());
}
finally
{
    sbPool.Return(sb); // StringBuilder is cleared and returned to pool
}

// ── Custom pooled object ──
public class ExpensiveResource { /* costly setup */ }

public class ResourcePolicy : PooledObjectPolicy<ExpensiveResource>
{
    public override ExpensiveResource Create() => new ExpensiveResource();
    public override bool Return(ExpensiveResource obj)
    {
        // Reset state, return true to keep in pool
        return true;
    }
}

// Registration in DI
builder.Services.AddSingleton<ObjectPool<ExpensiveResource>>(sp =>
{
    var provider = sp.GetRequiredService<ObjectPoolProvider>();
    return provider.Create(new ResourcePolicy());
});

A file upload API was allocating 1MB byte arrays per request for buffering, causing frequent LOH allocations and Gen 2 GC pauses under load. Switching to ArrayPool.Shared.Rent(1_048_576) reduced allocations by 99%. A RecyclableMemoryStreamManager replaced MemoryStream for response buffering, eliminating LOH fragmentation and reducing P99 latency from 200ms to 15ms.

Use ArrayPool.Shared for temporary byte/char arrays (especially ≥85KB to avoid LOH). Use ObjectPool for expensive-to-create objects that are used frequently. Always return rented objects in a finally block. Pool only when benchmarks prove GC pressure is the bottleneck.
⚠️ Common Mistake
// ❌ Forgetting to return — pool exhaustion and memory leak byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); await stream.ReadAsync(buffer); // forgot to Return! Pool grows unbounded
// ✅ Always return in a finally block byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); try { await stream.ReadAsync(buffer); } finally { ArrayPool<byte>.Shared.Return(buffer); }
🔁 Follow-Up Question

What is RecyclableMemoryStream? Why is it preferred over MemoryStream in high-throughput scenarios?

38 What are Minimal APIs in .NET? How do they compare to Controllers for performance? performance

Minimal APIs (introduced in .NET 6) provide a lightweight alternative to MVC controllers for building HTTP APIs. They use top-level statements and lambda-based endpoint mapping.

  • Less ceremony — no controllers, no attributes, no base classes. Endpoints are lambdas or method groups mapped with MapGet, MapPost, etc.
  • Faster startup — no MVC pipeline overhead (controller discovery, action descriptors, filter pipeline). Source-generated request delegates in .NET 8 eliminate reflection.
  • Lower per-request overhead — no model binding through the MVC pipeline, no filter chain (unless added).

When to use Controllers:

  • Large APIs with many endpoints benefiting from grouping and inheritance
  • Need for action filters, model validation via attributes, content negotiation
  • Teams familiar with MVC patterns

When to use Minimal APIs:

  • Microservices with a few focused endpoints
  • High-performance APIs where every microsecond matters
  • Simple CRUD services, API gateways, BFF layers

.NET 7+ added endpoint filters (equivalent to action filters), route groups, and typed results — closing the feature gap with controllers.

// ── Minimal API (Program.cs) ──
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IProductService, ProductService>();

var app = builder.Build();

// Route group with shared prefix and filter
var products = app.MapGroup("/api/products")
    .AddEndpointFilter<ValidationFilter>()
    .RequireAuthorization();

products.MapGet("/", async (IProductService svc) =>
    Results.Ok(await svc.GetAllAsync()));

products.MapGet("/{id:int}", async (int id, IProductService svc) =>
    await svc.GetByIdAsync(id) is { } product
        ? Results.Ok(product)
        : Results.NotFound());

products.MapPost("/", async (CreateProductDto dto, IProductService svc) =>
{
    var product = await svc.CreateAsync(dto);
    return Results.Created($"/api/products/{product.Id}", product);
});

products.MapDelete("/{id:int}", async (int id, IProductService svc) =>
{
    await svc.DeleteAsync(id);
    return Results.NoContent();
}).RequireAuthorization("AdminOnly");

app.Run();

// ── Endpoint Filter (equivalent to Action Filter) ──
public class ValidationFilter : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext ctx,
        EndpointFilterDelegate next)
    {
        // Pre-processing
        var dto = ctx.Arguments.OfType<CreateProductDto>().FirstOrDefault();
        if (dto is not null && string.IsNullOrEmpty(dto.Name))
            return Results.BadRequest("Name is required");

        return await next(ctx); // call the endpoint
    }
}

// ── Equivalent Controller for comparison ──
[ApiController, Route("api/products")]
[Authorize]
public class ProductsController : ControllerBase
{
    private readonly IProductService _svc;
    public ProductsController(IProductService svc) => _svc = svc;

    [HttpGet]
    public async Task<IActionResult> GetAll()
        => Ok(await _svc.GetAllAsync());

    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
        => await _svc.GetByIdAsync(id) is { } p ? Ok(p) : NotFound();
}

A team building a BFF (Backend for Frontend) for a React SPA used Minimal APIs with 12 endpoints. Startup time was 40% faster than the MVC equivalent (no controller discovery). Combined with Native AOT (.NET 8), the service started in 25ms and had a 12MB Docker image — ideal for Kubernetes scale-to-zero scenarios.

Minimal APIs are faster to start up and have less per-request overhead than controllers. Use them for microservices and performance-critical APIs. Use controllers for large APIs that benefit from MVC infrastructure (filters, model validation, conventions). Both can coexist in the same application.
⚠️ Common Mistake
// ❌ Putting all logic inline — becomes unmaintainable app.MapPost("/api/orders", async (OrderDto dto, AppDbContext db) => { // 50 lines of validation, business logic, mapping... var order = new Order { /* ... */ }; db.Orders.Add(order); await db.SaveChangesAsync(); await SendEmail(order); return Results.Created($"/api/orders/{order.Id}", order); });
// ✅ Delegate to services — keep endpoints thin app.MapPost("/api/orders", async (OrderDto dto, IMediator mediator) => { var id = await mediator.Send(new CreateOrderCommand(dto)); return Results.Created($"/api/orders/{id}", id); });
🔁 Follow-Up Question

How does Native AOT compilation work with Minimal APIs? What limitations exist?

39 How does caching work in ASP.NET Core? Compare in-memory, distributed, and output caching. performance

ASP.NET Core provides three caching layers:

  • In-Memory Cache (IMemoryCache) — stores data in the application's memory. Fastest option. Lost on app restart. Suitable for single-instance deployments. Supports absolute and sliding expiration, size limits, and eviction callbacks.
  • Distributed Cache (IDistributedCache) — stores data in an external store (Redis, SQL Server, NCache). Survives app restarts and is shared across multiple app instances. Essential for load-balanced deployments. Stores byte arrays (you serialise/deserialise).
  • Output Caching (.NET 7+) — caches entire HTTP responses. Configured per-endpoint with policies. Supports cache invalidation by tag. Replaces the older Response Caching middleware. Can use in-memory or distributed stores.

Caching strategy: Cache-Aside — check cache first, if miss, load from source, store in cache, return. Use GetOrCreateAsync for atomic cache-aside in IMemoryCache.

Additional patterns: HybridCache (.NET 9) combines L1 (in-memory) and L2 (distributed) with stampede protection built in.

// ── In-Memory Cache ──
builder.Services.AddMemoryCache();

app.MapGet("/api/products", async (IMemoryCache cache, AppDbContext db) =>
{
    var products = await cache.GetOrCreateAsync("all-products", async entry =>
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
        entry.SlidingExpiration = TimeSpan.FromMinutes(1);
        return await db.Products.AsNoTracking().ToListAsync();
    });
    return Results.Ok(products);
});

// ── Distributed Cache (Redis) ──
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
    options.InstanceName = "myapp:";
});

app.MapGet("/api/user/{id}", async (int id, IDistributedCache cache, IUserService svc) =>
{
    var key = $"user:{id}";
    var cached = await cache.GetStringAsync(key);
    if (cached is not null)
        return Results.Ok(JsonSerializer.Deserialize<UserDto>(cached));

    var user = await svc.GetByIdAsync(id);
    if (user is null) return Results.NotFound();

    await cache.SetStringAsync(key, JsonSerializer.Serialize(user),
        new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
        });
    return Results.Ok(user);
});

// ── Output Caching (.NET 7+) ──
builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(b => b.Expire(TimeSpan.FromSeconds(30)));
    options.AddPolicy("ByIdCache", b =>
        b.SetVaryByRouteValue("id").Tag("products"));
});

app.MapGet("/api/products", () => GetProducts()).CacheOutput();
app.MapGet("/api/products/{id}", (int id) => GetProduct(id))
   .CacheOutput("ByIdCache");

// Invalidate by tag
app.MapPost("/api/products", async (IOutputCacheStore store) =>
{
    // ... create product ...
    await store.EvictByTagAsync("products", default);
    return Results.Created();
});

An e-commerce catalogue uses a 3-tier caching strategy: Output Caching (30s) for the product listing page to avoid hitting the API entirely, IMemoryCache (5 min) for product detail lookups in the API, and Redis distributed cache (10 min) for user session and cart data shared across 4 load-balanced instances. Cache invalidation uses Redis pub/sub to notify all instances when a product is updated.

Use IMemoryCache for fast single-instance caching, IDistributedCache (Redis) for multi-instance shared state, and Output Caching for full HTTP response caching. Always set expiration policies. Invalidate proactively when source data changes rather than waiting for expiry.
⚠️ Common Mistake
// ❌ No expiration — stale data forever, memory leak cache.Set("products", products); // never expires! // Data grows unbounded, never refreshed
// ✅ Always set expiration and size limits cache.Set("products", products, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5), SlidingExpiration = TimeSpan.FromMinutes(1), Size = 1 // for size-limited caches });
🔁 Follow-Up Question

What is cache stampede (thundering herd)? How does HybridCache in .NET 9 solve it?

40 What are IAsyncEnumerable<T> and Channels in .NET? How do they enable streaming and async pipelines? performance

IAsyncEnumerable<T> enables asynchronous iteration — producing items one at a time with await foreach. Unlike returning Task<List<T>> (which buffers everything in memory), it streams results as they become available.

  • Use yield return in async IAsyncEnumerable<T> methods.
  • EF Core supports AsAsyncEnumerable() for streaming database results.
  • ASP.NET Core can return IAsyncEnumerable<T> from endpoints — streams JSON array items as they're produced.
  • Supports CancellationToken via [EnumeratorCancellation] attribute.

Channel<T> (System.Threading.Channels) is a high-performance async producer-consumer queue:

  • BoundedChannel.CreateBounded<T>(capacity) applies backpressure when full.
  • UnboundedChannel.CreateUnbounded<T>() grows dynamically.
  • Lock-free, allocation-free reads/writes. Faster than BlockingCollection<T>.
  • Ideal for decoupling producers from consumers in background processing pipelines.
// ── IAsyncEnumerable — streaming from database ──
async IAsyncEnumerable<Product> StreamProducts(
    AppDbContext db,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var product in db.Products.AsAsyncEnumerable()
        .WithCancellation(ct))
    {
        yield return product; // streamed one at a time
    }
}

// Consumer
await foreach (var product in StreamProducts(db))
{
    Console.WriteLine(product.Name);
}

// ASP.NET Core endpoint — streams JSON
app.MapGet("/api/products/stream", (AppDbContext db) =>
    db.Products.AsAsyncEnumerable());
// Response: [{"id":1,...},{"id":2,...},...] — streamed incrementally

// ── Channel<T> — producer/consumer pipeline ──
var channel = Channel.CreateBounded<Order>(new BoundedChannelOptions(100)
{
    FullMode = BoundedChannelFullMode.Wait // backpressure
});

// Producer (e.g., API endpoint)
app.MapPost("/api/orders", async (Order order) =>
{
    await channel.Writer.WriteAsync(order);
    return Results.Accepted();
});

// Consumer (background service)
public class OrderProcessor : BackgroundService
{
    private readonly Channel<Order> _channel;
    public OrderProcessor(Channel<Order> channel) => _channel = channel;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await foreach (var order in _channel.Reader.ReadAllAsync(ct))
        {
            await ProcessOrderAsync(order);
        }
    }
}

// ── Multi-stage pipeline ──
var stage1 = Channel.CreateUnbounded<RawData>();
var stage2 = Channel.CreateUnbounded<ProcessedData>();

// Stage 1: Read → Parse
async Task ParseStage(CancellationToken ct)
{
    await foreach (var raw in stage1.Reader.ReadAllAsync(ct))
    {
        var parsed = Parse(raw);
        await stage2.Writer.WriteAsync(parsed, ct);
    }
    stage2.Writer.Complete();
}

A real-time analytics API uses IAsyncEnumerable to stream large query results (100K+ rows) to the client without buffering the entire result set in memory. A data ingestion pipeline uses a 3-stage Channel pipeline: Stage 1 reads CSV files, Stage 2 validates and transforms rows, Stage 3 bulk-inserts to the database. Bounded channels apply backpressure when the database writer falls behind, preventing OOM.

Use IAsyncEnumerable for streaming data to callers without buffering entire collections. Use Channels for high-performance async producer-consumer pipelines with optional backpressure. Both avoid large memory allocations and enable responsive, scalable data processing.
⚠️ Common Mistake
// ❌ Buffering everything in memory — OOM risk with large data var allProducts = await db.Products.ToListAsync(); // loads 1M rows into RAM return Ok(allProducts);
// ✅ Stream with IAsyncEnumerable — constant memory usage app.MapGet("/api/products", (AppDbContext db) => db.Products.AsAsyncEnumerable()); // Streams rows incrementally — memory stays flat regardless of data size
🔁 Follow-Up Question

How does System.IO.Pipelines compare to Channels? When would you use PipeReader/PipeWriter?

41 List<T> vs LinkedList<T> vs Array — when should you use each and why? intermediate

This is the .NET equivalent of Java's "ArrayList vs LinkedList" — one of the most common tricky interview questions.

  • Array (T[]) — fixed size, contiguous memory. Fastest random access (O(1)). Cannot resize. Use when size is known at compile time and never changes.
  • List<T> — dynamic array backed by an internal T[]. Doubles capacity when full (copies to a new array). Random access O(1), append O(1) amortised, insert/remove at arbitrary index O(n) because elements must shift. This is your default choice 95% of the time.
  • LinkedList<T> — doubly-linked list. Insert/remove at a known node is O(1), but finding that node is O(n). No random access. Each node is a separate heap allocation → poor cache locality.

The tricky part: candidates often say "use LinkedList for frequent insertions" — but in practice, List<T> is almost always faster because of CPU cache locality. Modern CPUs prefetch contiguous memory; linked list nodes scattered on the heap cause cache misses that dominate the cost.

When LinkedList actually wins: only when you hold a LinkedListNode<T> reference and do many insertions/removals at that node (e.g., LRU cache implementation).

// ── Array — fixed size, fastest ──
int[] arr = new int[5] { 1, 2, 3, 4, 5 };
arr[2] = 99; // O(1) direct access
// arr.Add(6); // ❌ Cannot resize

// ── List<T> — dynamic, default choice ──
var list = new List<int> { 1, 2, 3, 4, 5 };
list.Add(6);          // O(1) amortised — may resize internally
list.Insert(0, 0);    // O(n) — shifts all elements right
list.RemoveAt(3);     // O(n) — shifts elements left
var x = list[2];      // O(1) random access

// Check internal capacity
Console.WriteLine($"Count: {list.Count}, Capacity: {list.Capacity}");

// Pre-allocate to avoid resizing (if you know approximate size)
var preAllocated = new List<int>(10_000);

// ── LinkedList<T> — rare use case ──
var linked = new LinkedList<int>(new[] { 1, 2, 3, 4, 5 });
var node = linked.Find(3)!;        // O(n) to find
linked.AddAfter(node, 99);          // O(1) insert at known node
linked.Remove(node);                // O(1) remove known node
// linked[2]; // ❌ No indexer — no random access

// ── Benchmark results (typical) ──
// Sequential iteration:  List<T> 3× faster than LinkedList<T> (cache locality)
// Insert at index 0:     LinkedList 10× faster (no shifting)
// Insert at known node:  LinkedList O(1) vs List O(n)
// Random access [i]:     List O(1) vs LinkedList O(n)

A team switched from LinkedList to List for a message queue buffer. Despite the queue needing frequent head removals (theoretically O(n) for List), List was 5× faster because CPU cache prefetching made sequential memory access dominate. They only kept LinkedList for an LRU cache where they stored node references in a Dictionary for O(1) eviction.

Default to List. Use Array for fixed-size, performance-critical buffers. Use LinkedList only when you hold node references and do frequent insert/remove at known positions (LRU cache). CPU cache locality makes List faster than LinkedList in almost every real scenario.
⚠️ Common Mistake
// ❌ "I'd use LinkedList for frequent insertions" // Wrong — without a node reference, Find() is O(n) + insert is O(1) // Total is still O(n), but with terrible cache locality var linked = new LinkedList<int>(); for (int i = 0; i < 100_000; i++) linked.AddFirst(i); // each node = separate heap object = cache miss
// ✅ List<T> with pre-allocation — faster in practice var list = new List<int>(100_000); // pre-allocate capacity for (int i = 0; i < 100_000; i++) list.Add(i); // contiguous memory = CPU cache friendly // Even Insert(0, x) is often faster than LinkedList.AddFirst due to cache
🔁 Follow-Up Question

What is the internal growth strategy of List? What happens when capacity is exceeded?

42 How does Dictionary<TKey, TValue> work internally? Explain hashing, buckets, and collision resolution. advanced

This is the .NET equivalent of Java's "How does HashMap work internally" — the #1 tricky interview question.

Dictionary<TKey, TValue> uses a hash table with separate chaining:

  1. GetHashCode() is called on the key → returns an int.
  2. The hash is mapped to a bucket index: hash % buckets.Length.
  3. The bucket stores the index into an entries array (Entry[]) that holds {hashCode, next, key, value}.
  4. Collision resolution: when two keys map to the same bucket, entries are chained via the next field (linked list within the entries array — not separate heap objects).
  5. Lookup: compute bucket → walk the chain → call Equals() on each key until a match is found.

Resizing: when the entries array is full, Dictionary doubles to the next prime number size and rehashes all entries (O(n) operation).

Key facts:

  • Average lookup/insert: O(1). Worst case (all keys in one bucket): O(n).
  • In .NET, buckets and entries are arrays (not linked list nodes) — better cache locality than Java's HashMap.
  • Ordering is not guaranteed — do not rely on enumeration order.
// ── Internal structure (simplified) ──
// Dictionary internally has:
//   int[] _buckets;     // maps hash → first entry index
//   Entry[] _entries;   // {hashCode, next, key, value}
//
// Add("Alice", 25):
//   1. hash = "Alice".GetHashCode()   → e.g. 2081854784
//   2. bucket = hash % _buckets.Length → e.g. 3
//   3. _entries[count] = { hash, next:-1, "Alice", 25 }
//   4. _buckets[3] = count
//
// Add("Bob", 30) where "Bob" also maps to bucket 3 (collision):
//   1. _entries[count] = { hash, next: previousIndex, "Bob", 30 }
//   2. _buckets[3] = count  (points to newest entry in chain)

// ── Demonstrating hash collisions ──
var dict = new Dictionary<string, int>();
dict["Alice"] = 25;
dict["Bob"]   = 30;

// Look up — O(1) average
if (dict.TryGetValue("Alice", out var age))
    Console.WriteLine($"Alice is {age}"); // 25

// ── Pre-size to avoid rehashing ──
var largeDict = new Dictionary<string, int>(capacity: 10_000);

// ── Count vs Capacity ──
Console.WriteLine($"Count: {dict.Count}");
// No public Capacity property, but EnsureCapacity(.NET 6+) exists
dict.EnsureCapacity(100); // pre-allocate to avoid rehashing

// ── Custom IEqualityComparer ──
var caseInsensitive = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
caseInsensitive["Hello"] = 1;
Console.WriteLine(caseInsensitive["hello"]); // 1 — same bucket!

A caching layer was experiencing O(n) lookups under load. Profiling revealed all keys were URLs with the same domain prefix, and a naive GetHashCode() was producing collisions for similar strings. Switching to a Dictionary with a custom IEqualityComparer using XxHash distributed keys evenly, dropping P99 lookup time from 50ms to 0.1ms.

Dictionary uses hash-based bucket lookup with chaining for collisions. Average O(1), worst O(n). Pre-size with capacity to avoid rehashing. Always implement GetHashCode() consistently with Equals() for custom key types. Use TryGetValue instead of ContainsKey + indexer.
⚠️ Common Mistake
// ❌ ContainsKey + indexer — double hash lookup if (dict.ContainsKey(key)) // 1st lookup { var val = dict[key]; // 2nd lookup — same hash computed again }
// ✅ TryGetValue — single hash lookup if (dict.TryGetValue(key, out var val)) { // use val — one lookup only } // ✅ .NET 6+: GetValueRefOrAddDefault for hot paths ref var entry = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, key, out bool exists); if (!exists) entry = ComputeExpensiveValue(key);
🔁 Follow-Up Question

What happens if you mutate a key object after adding it to a Dictionary? Why is this dangerous?

43 Why must GetHashCode() and Equals() be overridden together? What breaks if you don't? advanced

This is a classic trick question that trips up even experienced developers. The GetHashCode/Equals contract:

  1. If a.Equals(b) is true, then a.GetHashCode() == b.GetHashCode() must be true.
  2. If hash codes are equal, Equals() may return false (collisions are allowed).
  3. If a.Equals(b) is false, hash codes may be the same or different.

What breaks if you override Equals() but not GetHashCode():

  • Two logically equal objects get different hash codes (default GetHashCode() uses object reference).
  • They land in different buckets in a Dictionary/HashSet.
  • dict[equalKey] returns KeyNotFoundException even though an equal key exists — it's looking in the wrong bucket!

Records (record class, record struct) auto-generate both correctly based on all properties. This is one of the strongest reasons to use records for value-equality types.

Rules for a good GetHashCode(): deterministic, fast, well-distributed. Use HashCode.Combine() in modern .NET.

// ── BROKEN: Equals without GetHashCode ──
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";

    public override bool Equals(object? obj)
        => obj is Product p && p.Id == Id;

    // ❌ GetHashCode NOT overridden — uses object reference!
}

var p1 = new Product { Id = 1, Name = "Widget" };
var p2 = new Product { Id = 1, Name = "Widget" };

Console.WriteLine(p1.Equals(p2));  // True ✓
Console.WriteLine(p1.GetHashCode() == p2.GetHashCode()); // False ✗ — PROBLEM!

var set = new HashSet<Product> { p1 };
Console.WriteLine(set.Contains(p2)); // False ✗ — looks in wrong bucket!

var dict = new Dictionary<Product, string> { [p1] = "found" };
// dict[p2] → KeyNotFoundException! p2 is in a different bucket

// ── FIXED: Override both ──
public class ProductFixed
{
    public int Id { get; set; }
    public string Name { get; set; } = "";

    public override bool Equals(object? obj)
        => obj is ProductFixed p && p.Id == Id;

    public override int GetHashCode()
        => HashCode.Combine(Id); // ✅ consistent with Equals
}

// ── BEST: Use a record — auto-generates both ──
public record ProductRecord(int Id, string Name);

var r1 = new ProductRecord(1, "Widget");
var r2 = new ProductRecord(1, "Widget");
Console.WriteLine(r1 == r2);             // True
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // True
var rSet = new HashSet<ProductRecord> { r1 };
Console.WriteLine(rSet.Contains(r2));     // True ✅

A deduplication service used a HashSet to detect duplicate orders. Orders were compared by OrderNumber (overridden Equals) but GetHashCode was not overridden. Duplicate orders with the same OrderNumber but different object references passed through the HashSet undetected, causing double-charges. Adding GetHashCode = HashCode.Combine(OrderNumber) fixed the bug immediately.

Always override GetHashCode() when you override Equals(). Use HashCode.Combine() for clean, well-distributed hashes. Better yet, use records — they generate both correctly. If an object is used as a Dictionary key or in a HashSet, the hash code must never change while it's in the collection.
⚠️ Common Mistake
// ❌ Override Equals but forget GetHashCode public class Order { public string OrderNo { get; set; } public override bool Equals(object? obj) => obj is Order o && o.OrderNo == OrderNo; // GetHashCode uses default (object reference) — different for equal objects! } var set = new HashSet<Order>(); set.Add(new Order { OrderNo = "A001" }); set.Contains(new Order { OrderNo = "A001" }); // False! Wrong bucket
// ✅ Override both — or use a record public class Order { public string OrderNo { get; set; } public override bool Equals(object? obj) => obj is Order o && o.OrderNo == OrderNo; public override int GetHashCode() => HashCode.Combine(OrderNo); // consistent with Equals } // Even better: public record Order(string OrderNo);
🔁 Follow-Up Question

What happens if GetHashCode() returns the same value for every object? What is the performance impact?

44 ConcurrentDictionary<K,V> vs Dictionary<K,V> + lock — how do their internals differ? advanced

Another tricky question — most candidates know ConcurrentDictionary is "thread-safe" but can't explain how.

Dictionary + lock:

  • A single lock around the entire dictionary. Only one thread can read or write at a time.
  • Simple but creates a bottleneck under high concurrency — all threads serialize on the lock.

ConcurrentDictionary internals:

  • Uses striped locking — the hash table is divided into segments (stripes), each with its own lock.
  • Default: Environment.ProcessorCount stripes. Two threads accessing different stripes never block each other.
  • Reads are lock-free (uses Volatile.Read) — zero contention for lookups.
  • Writes lock only the specific stripe containing the target bucket.
  • Atomic compound operations: GetOrAdd, AddOrUpdate, TryRemove — no external locking needed.

The gotcha: the valueFactory in GetOrAdd(key, valueFactory) may execute multiple times for the same key (it's not locked). If the factory is expensive, use Lazy<T> as the value.

// ── Dictionary + lock — simple but bottleneck ──
private readonly Dictionary<string, int> _dict = new();
private readonly object _lock = new();

void AddSafe(string key, int value)
{
    lock (_lock) // ALL threads block here — even readers
    {
        _dict[key] = value;
    }
}

int GetSafe(string key)
{
    lock (_lock) // readers also block — unnecessary contention
    {
        return _dict.TryGetValue(key, out var v) ? v : -1;
    }
}

// ── ConcurrentDictionary — striped locking ──
private readonly ConcurrentDictionary<string, int> _concurrent = new();

// Lock-free read — zero contention
int Get(string key) => _concurrent.TryGetValue(key, out var v) ? v : -1;

// Locks only the stripe containing this key's bucket
void Add(string key, int value) => _concurrent[key] = value;

// ── Atomic compound operations ──
var cache = new ConcurrentDictionary<string, byte[]>();

// GetOrAdd — atomic but valueFactory may run multiple times!
var data = cache.GetOrAdd("key", k => File.ReadAllBytes(k));

// ── Gotcha: expensive factory called multiple times ──
// ❌ Factory runs on multiple threads, wasting work
var bad = cache.GetOrAdd("key", k => ExpensiveCompute(k));

// ✅ Use Lazy<T> to ensure factory runs only once
var lazyCache = new ConcurrentDictionary<string, Lazy<byte[]>>();
var result = lazyCache.GetOrAdd("key",
    k => new Lazy<byte[]>(() => ExpensiveCompute(k))).Value;

A token validation service cached parsed JWT claims in a Dictionary protected by lock. Under 10K req/s, the lock became a bottleneck — threads spent 40% of time waiting. Switching to ConcurrentDictionary made reads lock-free and writes stripe-locked, reducing contention to near zero. A Lazy wrapper ensured the expensive token parsing ran exactly once per key.

ConcurrentDictionary uses striped locking (writes lock only one stripe) and lock-free reads. It's vastly better than Dictionary+lock under concurrency. Use GetOrAdd/AddOrUpdate for atomic operations. Wrap expensive factories in Lazy to prevent duplicate computation.
⚠️ Common Mistake
// ❌ Locking around ConcurrentDictionary — defeats the purpose lock (_lock) { if (!_concurrent.ContainsKey(key)) _concurrent[key] = ComputeValue(key); } // External lock makes striped locking useless — same as Dictionary+lock
// ✅ Use built-in atomic operation — no external lock needed var value = _concurrent.GetOrAdd(key, k => ComputeValue(k)); // Striped locking handles thread safety internally
🔁 Follow-Up Question

What is the FrozenDictionary in .NET 8? When would you use it over ConcurrentDictionary?

45 HashSet<T> vs List<T> for lookups — when does the choice matter and why? intermediate

This is a frequent "gotcha" question. Candidates often use List<T>.Contains() without realising it's O(n) — it scans every element. HashSet<T>.Contains() is O(1).

OperationList<T>HashSet<T>
Contains()O(n) — linear scanO(1) — hash lookup
Add()O(1) amortisedO(1) amortised
Index access [i]O(1)❌ Not supported
Maintains orderYesNo
Allows duplicatesYesNo

When it matters: with 10 items, both are instant. With 10,000 items, List.Contains() checks up to 10,000 elements; HashSet.Contains() checks ~1.

Set operations: HashSet provides UnionWith, IntersectWith, ExceptWith, IsSubsetOf — ideal for membership testing and deduplication.

IEqualityComparer<T>: controls how HashSet/Dictionary determine equality. Use StringComparer.OrdinalIgnoreCase for case-insensitive sets, or implement a custom comparer for domain objects.

// ── List<T>.Contains() — O(n) linear scan ──
var list = new List<string> { "apple", "banana", "cherry" /* ...10K items */ };
bool found = list.Contains("mango"); // scans ALL elements

// ── HashSet<T>.Contains() — O(1) hash lookup ──
var set = new HashSet<string> { "apple", "banana", "cherry" /* ...10K items */ };
bool found2 = set.Contains("mango"); // instant hash lookup

// ── Performance comparison (10,000 items, 1,000 lookups) ──
// List<T>.Contains:    ~12ms (scans up to 10K per call)
// HashSet<T>.Contains: ~0.02ms (hash + 1-2 comparisons)

// ── Case-insensitive HashSet ──
var emails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
emails.Add("Alice@example.com");
Console.WriteLine(emails.Contains("alice@EXAMPLE.COM")); // True

// ── Set operations — deduplication ──
var list1 = new List<int> { 1, 2, 3, 4, 5 };
var list2 = new List<int> { 4, 5, 6, 7, 8 };

var set1 = new HashSet<int>(list1);
set1.IntersectWith(list2);
// set1 = { 4, 5 }

var unique = new HashSet<int>(list1);
unique.UnionWith(list2);
// unique = { 1, 2, 3, 4, 5, 6, 7, 8 }

// ── Custom IEqualityComparer<T> ──
public class ProductBySkuComparer : IEqualityComparer<Product>
{
    public bool Equals(Product? x, Product? y) => x?.Sku == y?.Sku;
    public int GetHashCode(Product obj) => HashCode.Combine(obj.Sku);
}

var uniqueProducts = new HashSet<Product>(new ProductBySkuComparer());

A blacklist filter checked each incoming request URL against 50,000 blocked paths using List.Contains(). At 5K req/s, this consumed 30% CPU. Converting to a HashSet(StringComparer.OrdinalIgnoreCase) made each lookup O(1), dropping CPU usage to under 1%. A FrozenSet (.NET 8) was used later for even better read performance since the list rarely changed.

Use HashSet whenever you need fast Contains(), Add(), or Remove() and don't need indexing or ordering. Use List when you need index access, ordering, or duplicates. For more than ~20 items, the O(n) vs O(1) difference in Contains() is measurable.
⚠️ Common Mistake
// ❌ Using List for membership checks — O(n) per call var blocklist = new List<string>(File.ReadAllLines("blocked.txt")); // 50K items foreach (var request in requests) { if (blocklist.Contains(request.Path)) // O(50,000) per check! Block(request); }
// ✅ Use HashSet — O(1) per call var blocklist = new HashSet<string>( File.ReadAllLines("blocked.txt"), StringComparer.OrdinalIgnoreCase); foreach (var request in requests) { if (blocklist.Contains(request.Path)) // O(1) — instant Block(request); }
🔁 Follow-Up Question

What is FrozenSet in .NET 8? How does it achieve faster reads than HashSet?

46 Why are strings immutable in .NET? What is string interning and how can it trick you? intermediate

Strings are immutable in .NET — once created, a string's content can never change. Every "modification" creates a new string object on the heap.

Why immutable?

  • Thread safety — immutable objects can be shared across threads without locking.
  • Hash code caching — string's hash code is computed once and cached. Safe for Dictionary keys.
  • Security — connection strings, URLs, and file paths can't be tampered with after creation.

String interning: the CLR maintains an intern pool — a table of unique string references. All compile-time string literals are automatically interned. Two variables pointing to the same literal share the same reference.

The trick question: object.ReferenceEquals("hello", "hello") returns true (same interned reference), but object.ReferenceEquals("hello", new string('h','e','l','l','o')) returns false (runtime-created string, not interned). Use string.Intern() to manually add runtime strings to the pool.

// ── Immutability — every operation creates a new string ──
string s = "Hello";
string s2 = s.ToUpper(); // "HELLO" — new object
Console.WriteLine(ReferenceEquals(s, s2)); // False — different objects
Console.WriteLine(s); // "Hello" — original unchanged

// ── String interning — compile-time literals share references ──
string a = "hello";
string b = "hello";
Console.WriteLine(ReferenceEquals(a, b)); // True! Same intern pool reference

// ── Runtime-created strings are NOT interned ──
string c = new string(new[] { 'h', 'e', 'l', 'l', 'o' });
Console.WriteLine(a == c);                // True  (value equality)
Console.WriteLine(ReferenceEquals(a, c)); // False (different references)

// ── Manual interning ──
string d = string.Intern(c);
Console.WriteLine(ReferenceEquals(a, d)); // True! Now same reference

// ── Concatenation in a loop — string interning won't save you ──
string result = "";
for (int i = 0; i < 1000; i++)
    result += i.ToString(); // 1000 intermediate strings created and discarded!

// ── StringBuilder — mutable buffer, no intermediate strings ──
var sb = new System.Text.StringBuilder();
for (int i = 0; i < 1000; i++)
    sb.Append(i);
string final = sb.ToString(); // one allocation at the end

// ── String.Create (.NET 6+) — allocate once, write in place ──
string stars = string.Create(10, '*', (span, c) =>
{
    span.Fill(c); // writes directly into the string's buffer
});

A log parser was reading millions of log lines where the "level" field repeated ("INFO", "WARN", "ERROR"). Each line created a new string for the level. Using string.Intern() on the level field reduced memory from 800MB to 200MB because all identical levels shared one reference. However, interning long unique strings (like URLs) would leak memory — the intern pool is never garbage collected.

Strings are immutable — every modification allocates a new string. Compile-time literals are interned (shared references). Use StringBuilder for loops, string.Intern() for repeated values, but never intern unique strings (they leak memory). Use == for value comparison, ReferenceEquals only for identity checks.
⚠️ Common Mistake
// ❌ Assuming == checks references (like Java) // In C#, == on strings is VALUE equality (overloaded operator) string a = "hello"; string b = new string(new[] { 'h', 'e', 'l', 'l', 'o' }); Console.WriteLine(a == b); // True — value equality // Some candidates say "False" thinking C# == is reference-based
// ✅ C# string == compares VALUES, not references // Use ReferenceEquals() for reference comparison (rarely needed) // Use string.Equals(a, b, StringComparison.Ordinal) for explicit control Console.WriteLine(string.Equals(a, b, StringComparison.Ordinal)); // True Console.WriteLine(ReferenceEquals(a, b)); // False — different objects
🔁 Follow-Up Question

What is the difference between String.Empty and ""? Are they the same interned instance?

47 How does the IDisposable pattern work? When must you implement it and what does "using" actually do? intermediate

IDisposable is .NET's pattern for deterministic cleanup of unmanaged resources (file handles, database connections, sockets, native memory).

The using statement is syntactic sugar that calls Dispose() in a finally block — guaranteeing cleanup even if an exception is thrown. C# 8 added using declarations (no braces, disposes at end of scope).

The full Dispose pattern (for classes holding native resources directly):

  • Implement IDisposable.Dispose() — calls Dispose(true) + GC.SuppressFinalize(this).
  • Add a finalizer (~ClassName()) — calls Dispose(false). Safety net if Dispose is not called.
  • Dispose(bool disposing) — if true, clean up managed + unmanaged resources. If false (finalizer), clean up only unmanaged.

IAsyncDisposable (await using) is for async cleanup — e.g., flushing an async stream before closing.

Most developers only need the simple pattern: implement IDisposable, clean up in Dispose(), no finalizer. Finalizers are only needed when wrapping raw OS handles directly.

// ── using statement — guarantees Dispose() ──
using (var stream = new FileStream("data.txt", FileMode.Open))
{
    // work with stream
} // stream.Dispose() called here — even if exception thrown

// ── using declaration (C# 8+) — disposes at end of scope ──
using var connection = new SqlConnection(connString);
await connection.OpenAsync();
// ... use connection ...
// Dispose() called when method exits

// ── What "using" actually compiles to ──
var stream2 = new FileStream("data.txt", FileMode.Open);
try
{
    // work with stream2
}
finally
{
    stream2?.Dispose(); // always called
}

// ── Simple IDisposable implementation ──
public class DatabaseService : IDisposable
{
    private SqlConnection? _conn;
    private bool _disposed;

    public DatabaseService(string connString)
    {
        _conn = new SqlConnection(connString);
    }

    public void Dispose()
    {
        if (!_disposed)
        {
            _conn?.Dispose();
            _conn = null;
            _disposed = true;
        }
    }
}

// ── IAsyncDisposable — async cleanup ──
public class AsyncFileWriter : IAsyncDisposable
{
    private readonly StreamWriter _writer;

    public AsyncFileWriter(string path)
        => _writer = new StreamWriter(path);

    public async Task WriteAsync(string data)
        => await _writer.WriteLineAsync(data);

    public async ValueTask DisposeAsync()
    {
        await _writer.FlushAsync();  // async flush before close
        _writer.Dispose();
    }
}

// Usage
await using var writer = new AsyncFileWriter("log.txt");
await writer.WriteAsync("Hello!");
// FlushAsync + Dispose called at end of scope

A web API was leaking database connections under load. Each request created a SqlConnection but exception paths skipped the manual .Close() call. Wrapping all connections in `using` statements ensured Dispose() was always called, even during exceptions. Connection pool exhaustion dropped from 50 incidents/day to zero.

Always wrap IDisposable objects in using statements/declarations. This guarantees Dispose() even on exceptions. For async resources (streams, HTTP clients), use await using with IAsyncDisposable. Only implement the full Dispose pattern with finalizer if you directly hold OS handles.
⚠️ Common Mistake
// ❌ Manual Dispose — easy to miss on exception paths var conn = new SqlConnection(connString); conn.Open(); var result = ExecuteQuery(conn); // throws exception! conn.Dispose(); // never reached — connection leaked!
// ✅ using declaration — Dispose guaranteed using var conn = new SqlConnection(connString); await conn.OpenAsync(); var result = ExecuteQuery(conn); // even if this throws... // conn.Dispose() is called when scope exits — always
🔁 Follow-Up Question

Why should you call GC.SuppressFinalize(this) in Dispose()? What happens to finalizable objects during GC?

48 What is boxing and unboxing in .NET? Where are the hidden performance traps? intermediate

Boxing wraps a value type (stack) into an object reference (heap). Unboxing extracts the value type back from the object. Both are expensive:

  • Boxing: allocates a new object on the heap, copies the value into it. Triggers GC pressure.
  • Unboxing: type-checks the object, copies the value back to the stack.

Hidden boxing traps — these box without any explicit cast:

  • object x = 42; — assigning value type to object.
  • string.Format("{0}", myInt)params object[] boxes each int.
  • ArrayList.Add(42) — non-generic collection stores object.
  • Calling ToString(), GetHashCode(), or Equals() on a struct through an interface reference boxes the struct.
  • IComparable comp = myStruct; — assigning struct to its interface boxes it.

Generics solved this: List<int> stores ints directly (no boxing), unlike ArrayList which stores object. This is a key reason generics were added to .NET 2.0.

// ── Explicit boxing/unboxing ──
int value = 42;
object boxed = value;     // BOXING: int copied to heap object
int unboxed = (int)boxed; // UNBOXING: type-check + copy back

// ── Hidden boxing #1: string interpolation (before .NET 6) ──
int count = 5;
// Pre-.NET 6: boxes count into object for String.Format
string s1 = string.Format("Count: {0}", count); // boxes!
// .NET 6+: interpolated strings use handlers — no boxing
string s2 = $"Count: {count}"; // NO boxing in .NET 6+

// ── Hidden boxing #2: struct implementing interface ──
public struct Point : IEquatable<Point>
{
    public int X, Y;
    public bool Equals(Point other) => X == other.X && Y == other.Y;
}

Point p = new Point { X = 1, Y = 2 };
IEquatable<Point> eq = p; // BOXING! Struct → interface = heap allocation
bool result = eq.Equals(p); // operates on boxed copy

// ── Hidden boxing #3: non-generic collections ──
var arrayList = new System.Collections.ArrayList();
arrayList.Add(42);    // BOXING — stored as object
arrayList.Add(3.14);  // BOXING
int val = (int)arrayList[0]; // UNBOXING

// ── No boxing: generic collections ──
var list = new List<int>();
list.Add(42);         // NO boxing — stored as int directly
int val2 = list[0];   // NO unboxing — direct access

// ── Benchmark ──
// Boxing 1M ints:   ~15ms + 1M heap allocations
// List<int> 1M add: ~3ms  + 0 heap allocations (one array resize)

A high-frequency trading system was logging trade data using string.Format("{0}: {1} @ {2}", symbol, quantity, price). With 100K trades/second, the three numeric parameters were boxed per call — 300K extra heap allocations/second. Switching to $"..." interpolation on .NET 6+ (which uses DefaultInterpolatedStringHandler — no boxing) reduced GC Gen 0 collections by 70%.

Boxing allocates on the heap and creates GC pressure. Use generic collections (List not ArrayList), avoid assigning structs to interface variables in hot paths, and use .NET 6+ string interpolation. Watch for hidden boxing in params object[], non-generic APIs, and interface dispatch on structs.
⚠️ Common Mistake
// ❌ Hidden boxing in a hot loop — massive GC pressure for (int i = 0; i < 1_000_000; i++) { object boxed = i; // 1M box allocations Console.WriteLine("{0}", i); // another box per iteration IComparable comp = i; // yet another box! }
// ✅ Avoid boxing — generics + modern interpolation var list = new List<int>(1_000_000); // no boxing for (int i = 0; i < 1_000_000; i++) { list.Add(i); // stored as int, not object Console.WriteLine($"{i}"); // .NET 6+ = no boxing }
🔁 Follow-Up Question

Does boxing happen with enums? What about Nullable — is int? boxed differently?

49 Why does calling .Result or .Wait() on async code cause deadlocks? How do you fix it? advanced

This is the #1 async gotcha in .NET interviews. The deadlock happens in environments with a SynchronizationContext (UI apps, legacy ASP.NET — NOT ASP.NET Core):

  1. You call task.Result or task.Wait() — this blocks the current thread until the task completes.
  2. Inside the async method, await captures the SynchronizationContext and tries to resume on the same thread.
  3. But that thread is blocked waiting for the result → deadlock. Neither side can proceed.

Why ASP.NET Core doesn't deadlock: it has no SynchronizationContext. Continuations run on any thread pool thread. But .Result still blocks a thread — wasting resources.

Fixes:

  • async all the way — never mix sync and async. Use await instead of .Result.
  • ConfigureAwait(false) — tells the await to resume on any thread, not the original context. Use in library code.
  • Task.Run(() => AsyncMethod()).Result — escapes the context (last resort).
// ── THE DEADLOCK (in WPF/WinForms/legacy ASP.NET) ──
// Button click handler in a WPF app
private void Button_Click(object sender, EventArgs e)
{
    // ❌ DEADLOCK — blocks the UI thread
    var result = GetDataAsync().Result;
    // GetDataAsync's await tries to resume on UI thread
    // UI thread is blocked here → deadlock forever
}

private async Task<string> GetDataAsync()
{
    var data = await httpClient.GetStringAsync("https://api.example.com");
    // ↑ wants to resume on UI thread (captured SynchronizationContext)
    // but UI thread is blocked on .Result above → DEADLOCK
    return data;
}

// ── FIX 1: async all the way (BEST) ──
private async void Button_Click(object sender, EventArgs e)
{
    var result = await GetDataAsync(); // no blocking, no deadlock
    TextBox.Text = result;
}

// ── FIX 2: ConfigureAwait(false) in library code ──
public async Task<string> GetDataAsync()
{
    var data = await httpClient.GetStringAsync("https://api.example.com")
        .ConfigureAwait(false); // resume on any thread, not UI thread
    return data;
}

// ── FIX 3: Task.Run escape hatch (last resort) ──
private void Button_Click(object sender, EventArgs e)
{
    // Runs async method on thread pool — no SynchronizationContext
    var result = Task.Run(() => GetDataAsync()).Result;
}

// ── ASP.NET Core — no deadlock but still wasteful ──
// No SynchronizationContext, so .Result won't deadlock
// But it BLOCKS a thread pool thread — reduces throughput
[HttpGet]
public IActionResult Get()
{
    var data = GetDataAsync().Result; // works but wastes a thread
    return Ok(data);
}

// ✅ Always prefer async
[HttpGet]
public async Task<IActionResult> Get()
{
    var data = await GetDataAsync(); // non-blocking
    return Ok(data);
}

A legacy ASP.NET MVC 5 app migrated a service layer to async but the controllers still called .Result (sync-over-async). Under load, the app froze — every request deadlocked on the ASP.NET SynchronizationContext. The fix: make controllers async (return Task) and add ConfigureAwait(false) to all library methods. After migration to ASP.NET Core, the SynchronizationContext deadlock risk disappeared but sync-over-async still wasted threads.

Never call .Result or .Wait() on async code — it blocks the thread and can deadlock on SynchronizationContext. Use "async all the way" from controller to data layer. Use ConfigureAwait(false) in library code. Even in ASP.NET Core (no deadlock), .Result wastes thread pool threads.
⚠️ Common Mistake
// ❌ Sync-over-async — deadlock risk + thread waste public string GetData() { return GetDataAsync().Result; // blocks thread, deadlock in UI/legacy ASP.NET } public IActionResult Index() { var data = _service.GetData(); // chain of sync-over-async return View(data); }
// ✅ Async all the way — no blocking, no deadlock public async Task<string> GetDataAsync() { return await _http.GetStringAsync(url).ConfigureAwait(false); } public async Task<IActionResult> Index() { var data = await _service.GetDataAsync(); return View(data); }
🔁 Follow-Up Question

What is ConfigureAwait(false) exactly? Why should library authors always use it?

50 ValueTask<T> vs Task<T> — when should you use each and what is the double-await trap? advanced

Task<T> is a class (heap-allocated). ValueTask<T> is a struct (stack-allocated) that can wrap either a synchronous result or a Task.

When ValueTask wins:

  • Hot paths where the method often completes synchronously (e.g., cache hit returns immediately). ValueTask<T> avoids the Task heap allocation entirely.
  • High-throughput APIs — IAsyncEnumerable<T> uses ValueTask<bool> in MoveNextAsync() because most iterations are synchronous (data already buffered).

When Task is better:

  • The method always goes async (I/O bound) — no allocation savings, and Task is simpler.
  • You need to await the result multiple times or store it.
  • You need Task.WhenAll() / Task.WhenAny() (these don't accept ValueTask).

The double-await trap: a ValueTask must be awaited exactly once. Awaiting it twice, or reading .Result after awaiting, is undefined behavior — it may return wrong data, throw, or succeed unpredictably.

// ── Task<T> — always allocates on the heap ──
public async Task<int> GetFromDbAsync(string key)
{
    return await _db.QueryAsync<int>(key); // always async → Task is fine
}

// ── ValueTask<T> — zero allocation on cache hit ──
private readonly ConcurrentDictionary<string, int> _cache = new();

public ValueTask<int> GetValueAsync(string key)
{
    // Synchronous path — cache hit, no Task allocated
    if (_cache.TryGetValue(key, out var value))
        return new ValueTask<int>(value); // wraps int directly, no heap alloc

    // Async path — cache miss, falls back to Task
    return new ValueTask<int>(LoadFromDbAsync(key));
}

private async Task<int> LoadFromDbAsync(string key)
{
    var value = await _db.QueryAsync<int>(key);
    _cache[key] = value;
    return value;
}

// ── THE DOUBLE-AWAIT TRAP ──
var vt = GetValueAsync("key");

// ❌ WRONG: awaiting ValueTask twice — undefined behavior!
var result1 = await vt;
var result2 = await vt; // 💥 may throw, return wrong value, or seem to work

// ❌ WRONG: storing and awaiting later — may be recycled
ValueTask<int> stored = GetValueAsync("key");
// ... other work ...
var late = await stored; // 💥 underlying IValueTaskSource may be reused

// ✅ CORRECT: await immediately, once
var result = await GetValueAsync("key");

// ✅ If you need Task features, convert first
Task<int> task = GetValueAsync("key").AsTask();
await Task.WhenAll(task, otherTask); // now safe

// ── IAsyncEnumerable uses ValueTask internally ──
// MoveNextAsync() returns ValueTask<bool> — most iterations
// complete synchronously (buffered data), avoiding Task allocation

A Redis cache wrapper used Task for all lookups. Profiling showed 95% of calls were cache hits (synchronous return) but each allocated a Task object — 200K allocations/second. Switching to ValueTask eliminated those allocations entirely for cache hits, reducing Gen 0 GC collections by 60%. The team added code review rules to catch double-await of ValueTask.

Use ValueTask when a method frequently completes synchronously (cache lookups, buffered reads). Use Task for always-async operations and when you need WhenAll/WhenAny. Never await a ValueTask twice — convert to Task first with .AsTask() if you need to store or reuse it.
⚠️ Common Mistake
// ❌ Double-await a ValueTask — undefined behavior var vt = GetCachedValueAsync(key); if (await vt > 0) Console.WriteLine(await vt); // 💥 second await — may crash or return junk
// ✅ Await once, store the result var value = await GetCachedValueAsync(key); if (value > 0) Console.WriteLine(value); // safe — using the int, not the ValueTask
🔁 Follow-Up Question

What is IValueTaskSource and how does it enable pooling of ValueTask instances?

Frequently Asked Questions

Written and reviewed by the FreeBytes Editorial Team · Last updated: June 2026