🟣 .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.
.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.
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:
// ".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// .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 newWhat is the role of the CLR (Common Language Runtime) in .NET?
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.
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:
// "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.// 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, arraysWhat is boxing and unboxing? What are the performance implications?
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.
Candidates cannot explain when to choose struct over class. The rule of thumb is the "struct suitability" checklist:
// 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]// 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 valueCan a struct implement an interface? What happens performance-wise when it does?
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.
Candidates confuse protected internal (OR — broader access) with private protected (AND — narrower access):
// "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// 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 restrictiveWhat is the default access modifier for class members? For top-level classes?
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.
Candidates use public fields instead of properties. Fields cannot be used in interfaces, data binding, serialization attributes, or mocking frameworks:
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)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 laterWhat is the difference between a property and a field? Why should you prefer properties for public APIs?
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.
Candidates always use StringBuilder even when string interpolation is cleaner and equally fast for simple cases:
// 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// 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+ concatenationsWhat is string interning in .NET? How does it affect memory and equality checks?
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.
Using throw ex; instead of throw; destroys the stack trace — the #1 debugging killer in production:
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
}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
}What is the difference between throw and throw ex? When would you use exception filters?
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.
Candidates only know int? but are unaware of Nullable Reference Types — the most important null-safety feature in modern C#:
// 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
}#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 surprisesWhat are Nullable Reference Types and how do they differ from Nullable Value Types?
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.
Using dictionary[key] which throws KeyNotFoundException when the key doesn't exist:
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"; }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"];What is the difference between IEnumerable
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.
Candidates say "interfaces cannot have implementation" — outdated since C# 8 default interface methods:
// "Interfaces only have method signatures, nothing else"
public interface ILogger
{
void Log(string message);
// "Cannot have a method body here" — WRONG since C# 8!
}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
}What are default interface methods in C# 8? When would you use them vs abstract classes?
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.
Calling .ToList() too early defeats deferred execution and wastes 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// 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 liftingWhat is deferred execution in LINQ and why does it matter for performance?
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.
Not unsubscribing from events causes memory leaks — the publisher holds a reference to the subscriber, preventing garbage collection:
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 */ }
}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
}
}What is the difference between Func, Action, and Predicate? When would you use each?
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.
Using async void instead of async Task — exceptions crash the process silently, and the caller cannot await or catch errors:
// 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// 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);
}What is ConfigureAwait(false) and when should you use it?
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.
Injecting a scoped service into a singleton creates a captive dependency — the scoped service lives forever instead of per-request:
// 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
}
}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);What is the difference between transient, scoped, and singleton lifetimes? Give a real example of when each is appropriate.
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.
Not using AsNoTracking() for read-only queries adds unnecessary overhead — the change tracker monitors every entity for modifications:
// 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// 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;What is the N+1 query problem in EF Core and how do you solve it?
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.
Using object instead of generics — loses type safety, causes boxing for value types, and requires unsafe casting:
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!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 responsibilityWhat are covariance and contravariance in generics? Give a practical example.
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.
Calling .ToList() before filtering converts IQueryable to IEnumerable — all filtering happens in memory instead of SQL:
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
}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
}Should repositories return IQueryable
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%.
Putting UseAuthorization() before UseRouting() breaks authorization because there's no routing context:
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!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 expectedHow do you write custom middleware in ASP.NET Core? What is the difference between app.Use() and app.Map()?
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).
Overusing extension methods for core business logic that belongs in the class itself:
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
}// 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 logicCan extension methods access private members of the type they extend? Why or why not?
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.
Candidates only know basic is type-checking and miss the powerful patterns added in C# 8-11:
// 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
}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 casesWhat are list patterns in C# 11? How do they compare to traditional array indexing?
.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
What is the Pinned Object Heap (POH) introduced in .NET 5? When would you pin objects?
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 structthat 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
.Spanproperty.
Key benefits:
- Slicing without allocation —
span.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
What is the difference between stackalloc and ArrayPool? When would you choose one over the other?
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 ofFunc<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
IQueryableusesExpression<Func<...>>. - You can build expression trees dynamically at runtime using the
Expressionfactory 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.
How does EF Core translate expression trees into SQL? What happens when it encounters an expression it cannot translate?
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 inspection —
typeof(T),obj.GetType(),Assembly.GetTypes() - Member access —
GetProperties(),GetMethods(),GetCustomAttributes() - Dynamic invocation —
MethodInfo.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/MethodInfoobjects - Use compiled expressions or
DynamicMethodfor 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.
What are source generators? How do they provide compile-time reflection and eliminate runtime reflection overhead?
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 toIEnumerable<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 toAction<Dog>. The type parameter appears only in input positions (method parameters). - Invariance — the default.
List<Dog>cannot be assigned toList<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
Why are arrays covariant in C# even though they are mutable? What runtime exception can this cause?
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,TryRemovefor 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.
What is the Channel
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:
- Authorization filters — run first, short-circuit if unauthorized. Implement
IAuthorizationFilter. - Resource filters — run before model binding. Good for caching or short-circuiting. Implement
IResourceFilter/IAsyncResourceFilter. - Action filters — run before/after action method execution. Most common type for logging, validation, transformation. Implement
IActionFilter. - Exception filters — handle unhandled exceptions from action methods. Implement
IExceptionFilter. - 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.
What is the difference between TypeFilter and ServiceFilter? When would you use a filter factory?
EF Core provides powerful features beyond basic CRUD:
- Change Tracking — EF Core tracks entity state (
Added,Modified,Deleted,Unchanged,Detached). OnSaveChanges(), it generates SQL only for changed entities. UseAsNoTracking()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 SQL —
FromSqlRaw()/FromSqlInterpolated()for complex queries while still materialising entities.SqlQueryRaw<T>()in EF Core 8 for scalar/DTO projections. - Compiled Queries —
EF.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.
What are owned entities and value converters in EF Core? How do they support DDD value objects?
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.
When does CQRS become overkill? What are simpler alternatives for small applications?
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
HttpClientwithIHttpClientFactoryand Polly for resilience. - gRPC (HTTP/2 + Protobuf) — binary protocol, 5-10× faster than JSON. Strongly typed contracts via
.protofiles. 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 Checks —
Microsoft.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.
What is the Saga pattern? How does MassTransit implement sagas for distributed transactions?
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:
- Exception handling
- HTTPS redirection
- Static files
- Routing
- CORS
- Authentication
- Authorization
- Custom middleware
- Endpoint execution
Three ways to write middleware:
- Inline —
app.Use(async (ctx, next) => { ... }) - Convention-based class — constructor takes
RequestDelegate next, hasInvokeAsync(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.
What is the difference between app.Use(), app.Map(), and app.Run()? When would you branch the pipeline?
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+) —
IIncrementalGeneratorcaches 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.
How would you write your own source generator? What is the difference between ISourceGenerator and IIncrementalGenerator?
JWT (JSON Web Token) authentication in ASP.NET Core validates bearer tokens attached to HTTP requests. The flow:
- Client authenticates (e.g., login endpoint) → server issues a signed JWT containing claims.
- Client sends JWT in
Authorization: Bearer <token>header. - ASP.NET Core middleware validates the token (signature, expiry, issuer, audience).
HttpContext.Useris populated with the token's claims as aClaimsPrincipal.
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.
What is refresh token rotation? How do you implement token revocation without a database lookup on every request?
Hosted services are long-running background tasks managed by the .NET Generic Host. Two main approaches:
- IHostedService — interface with
StartAsyncandStopAsync. Full control over lifecycle. Good for startup/shutdown hooks. - BackgroundService — abstract class (implements IHostedService) with a single
ExecuteAsyncmethod. 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. ExecuteAsyncreceives aCancellationTokenthat 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
How does .NET 8's PeriodicTimer differ from Task.Delay for scheduled work? What about Quartz.NET or Hangfire for complex scheduling?
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 Checks — Microsoft.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 Tracing — OpenTelemetry 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.
What are .NET Meters and the new System.Diagnostics.Metrics API? How do custom metrics work with Prometheus?
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.
How do you add BenchmarkDotNet to a CI pipeline to catch performance regressions automatically?
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>.Sharedis a global pool. CallRent(size)to borrow an array (may be larger than requested) andReturn(array)when done. Essential for avoiding Large Object Heap allocations. - ObjectPool<T> (
Microsoft.Extensions.ObjectPool) — pools arbitrary objects. Configured with aPooledObjectPolicy<T>that definesCreate()andReturn()logic. Used internally by ASP.NET Core forStringBuilderinstances.
When to pool:
- Frequent allocation of large arrays (≥85KB → LOH)
- Expensive object creation (database connections, HTTP clients — though
IHttpClientFactoryhandles 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
What is RecyclableMemoryStream? Why is it preferred over MemoryStream in high-throughput scenarios?
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.
How does Native AOT compilation work with Minimal APIs? What limitations exist?
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.
What is cache stampede (thundering herd)? How does HybridCache in .NET 9 solve it?
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 returninasync 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
CancellationTokenvia[EnumeratorCancellation]attribute.
Channel<T> (System.Threading.Channels) is a high-performance async producer-consumer queue:
- Bounded —
Channel.CreateBounded<T>(capacity)applies backpressure when full. - Unbounded —
Channel.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.
How does System.IO.Pipelines compare to Channels? When would you use PipeReader/PipeWriter?
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
What is the internal growth strategy of List
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:
- GetHashCode() is called on the key → returns an
int. - The hash is mapped to a bucket index:
hash % buckets.Length. - The bucket stores the index into an entries array (
Entry[]) that holds{hashCode, next, key, value}. - Collision resolution: when two keys map to the same bucket, entries are chained via the
nextfield (linked list within the entries array — not separate heap objects). - 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.
What happens if you mutate a key object after adding it to a Dictionary? Why is this dangerous?
This is a classic trick question that trips up even experienced developers. The GetHashCode/Equals contract:
- If
a.Equals(b)is true, thena.GetHashCode() == b.GetHashCode()must be true. - If hash codes are equal,
Equals()may return false (collisions are allowed). - 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
What happens if GetHashCode() returns the same value for every object? What is the performance impact?
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.ProcessorCountstripes. 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
What is the FrozenDictionary in .NET 8? When would you use it over ConcurrentDictionary?
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).
| Operation | List<T> | HashSet<T> |
|---|---|---|
| Contains() | O(n) — linear scan | O(1) — hash lookup |
| Add() | O(1) amortised | O(1) amortised |
| Index access [i] | O(1) | ❌ Not supported |
| Maintains order | Yes | No |
| Allows duplicates | Yes | No |
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
What is FrozenSet
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.
What is the difference between String.Empty and ""? Are they the same interned instance?
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()— callsDispose(true)+GC.SuppressFinalize(this). - Add a finalizer (
~ClassName()) — callsDispose(false). Safety net if Dispose is not called. Dispose(bool disposing)— iftrue, clean up managed + unmanaged resources. Iffalse(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.
Why should you call GC.SuppressFinalize(this) in Dispose()? What happens to finalizable objects during GC?
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 toobject.string.Format("{0}", myInt)—params object[]boxes each int.ArrayList.Add(42)— non-generic collection storesobject.- Calling
ToString(),GetHashCode(), orEquals()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%.
Does boxing happen with enums? What about Nullable
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):
- You call
task.Resultortask.Wait()— this blocks the current thread until the task completes. - Inside the async method,
awaitcaptures the SynchronizationContext and tries to resume on the same thread. - 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
awaitinstead 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
What is ConfigureAwait(false) exactly? Why should library authors always use it?
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>usesValueTask<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
awaitthe 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
What is IValueTaskSource and how does it enable pooling of ValueTask instances?
Frequently Asked Questions
The most common .NET interview questions cover C# fundamentals (value vs reference types, async/await, LINQ), ASP.NET Core (middleware, DI, filters), Entity Framework Core (DbContext, migrations, change tracking), collections internals (Dictionary, HashSet, List vs LinkedList), and performance optimization. Our guide covers all of these with real code examples and follow-up questions.
We cover 50 .NET interview questions across 5 difficulty levels: Basic (10), Intermediate (15), Advanced (13), Experienced (7), and Performance & Optimization (5). Each question includes 6 answer sections — theory, code example, real-world scenario, key takeaway, common mistake, and follow-up question.
Questions are organized into 5 levels: Basic (0-1 year experience), Intermediate (1-3 years), Advanced (3-5 years), Experienced/Architect (5+ years), and Performance & Optimization (all levels). You can filter by level using the pills above the question list.
All code examples are real, working C# code — not pseudocode or placeholder variable names. Each example uses realistic scenarios from production environments including ASP.NET Core, EF Core, and modern C# features. You can copy and run them directly in any .NET 8+ project.
.NET Framework (2002) is Windows-only and in maintenance mode. .NET Core (2016, now just ".NET" from version 5 onwards) is cross-platform, open-source, modular, and high-performance. All new development should target .NET 6+. Our questions cover modern .NET with notes on Framework differences where relevant.
Focus on the middleware pipeline, dependency injection lifetimes (transient, scoped, singleton), authentication/authorization with JWT, Entity Framework Core querying patterns, and async/await best practices. Our guide covers these with production scenarios and common pitfalls that interviewers love to ask about.
Senior .NET interviews focus on Clean Architecture with CQRS/MediatR, microservices patterns (gRPC, message queues), source generators, advanced EF Core (global query filters, compiled queries), memory management (Span<T>, object pooling), and observability with OpenTelemetry. Our experienced and performance sections cover all of these.
All questions target .NET 8+ (the latest LTS release) with modern C# 12 features like primary constructors, collection expressions, and keyed DI services. Where older patterns differ significantly, we note the evolution to help you answer questions about legacy codebases as well.