Top 50 C# and .NET Interview Questions in 2026 (With Real Answers)
C# is not the language it was ten years ago, and .NET is not the Windows-only framework a lot of people still picture. Modern .NET is cross-platform, open source, fast, and on a predictable yearly release train. .NET 10 shipped in November 2025 as the current LTS release, with C# 14 alongside it, and that is the baseline most teams hiring in 2026 are working on or migrating toward.
That shift changes the interview. Nobody is asking you to recite the difference between ArrayList and List<T> anymore. They want to know whether you understand what await actually compiles to, why a scoped service inside a singleton is a bug, and why your EF Core endpoint fires 200 queries. The questions below are organized from language fundamentals up to the senior design questions that close out a loop.
If your role is backend-heavy, pair this with our top 50 database interview questions and top 50 microservices interview questions. Coming from the JVM? Our Java interview guide covers the same ground on that side.
Let's get into it.
Language Fundamentals (1-10)
These are the warm-ups. They sound basic, but follow-ups on any of them can go deep fast.
1. What's the difference between value types and reference types?
A value type variable holds the data itself. A reference type variable holds a reference to an object on the managed heap. Assigning a value type copies the data; assigning a reference type copies the reference, so both variables point at the same object.
struct Point { public int X; }
class Box { public int X; }
var p1 = new Point { X = 1 };
var p2 = p1;
p2.X = 99;
var b1 = new Box { X = 1 };
var b2 = b1;
b2.X = 99;
Value types: int, double, bool, char, decimal, enums, structs, and DateTime. Reference types: classes, string, arrays, delegates, interfaces (as a variable type), and records declared as record or record class.
The real interview answer: "Value types are copied, reference types are shared. And 'value types live on the stack' is only half true - a struct field inside a class lives on the heap with that class. Where it lives depends on where it's declared, not on what it is."
2. struct vs class vs record vs record struct - when do you use each?
- class - reference semantics, reference equality by default, supports inheritance. The default for entities and services.
- struct - value semantics, copied on assignment, no inheritance. Use for small, short-lived values where avoiding a heap allocation matters (a coordinate, a money amount).
- record (
record class, C# 9) - a reference type with compiler-generated value equality, a readable ToString(), and non-destructive mutation via with. Ideal for DTOs, messages, and immutable data.
- record struct (C# 10) - the same conveniences on a value type. Use
readonly record struct when you want it immutable.
public record Money(decimal Amount, string Currency);
var a = new Money(10m, "USD");
var b = a with { Amount = 20m };
Console.WriteLine(a == new Money(10m, "USD"));
One detail interviewers like: positional properties on a record class are init-only, but on a plain record struct they are mutable. Add readonly to get immutability.
3. What is boxing, and why should you care?
Boxing is converting a value type to object or to an interface it implements. The runtime allocates a heap object and copies the value into it. Unboxing copies it back out and requires the exact type.
int n = 42;
object o = n;
int m = (int)o;
long l = (long)o;
Why it matters: each box is an allocation that adds GC pressure. Classic sources are non-generic collections (ArrayList), calling interface methods on a struct through the interface type, and string.Format-style APIs taking object parameters. Generics are the main fix - List<int> stores ints without boxing.
4. What are nullable reference types, and what do they actually guarantee?
Nullable reference types (C# 8) let you declare intent: string means "should never be null," string? means "may be null." The compiler does flow analysis and warns when you dereference something that might be null or assign null to a non-nullable reference.
#nullable enable
string? FindName(int id) => id == 1 ? "Ada" : null;
var name = FindName(2);
Console.WriteLine(name.Length);
if (name is not null)
Console.WriteLine(name.Length);
The key point: it is compile-time only. There is no runtime check, and the null-forgiving operator (name!) just silences the warning. Values coming from deserialization, reflection, or un-annotated libraries can still be null. New project templates enable it by default, and many teams turn the warnings into errors.
5. How does pattern matching work in modern C#?
Pattern matching lets you test a value's shape and extract data in one expression. It has grown every release since C# 7: type patterns, property patterns, relational patterns (> 5), logical patterns (and, or, not), and list patterns (C# 11).
decimal Discount(Order order) => order switch
{
{ Customer.IsVip: true, Total: > 1000 } => 0.15m,
{ Total: > 1000 } => 0.10m,
{ Items: [] } => 0m,
{ Items: [_, _, ..] } => 0.05m,
_ => 0m
};
if (shape is Circle { Radius: > 0 } c)
Console.WriteLine(c.Radius);
The compiler checks switch expressions for exhaustiveness and warns if a case is unreachable or the input isn't fully covered. is not null is also now the idiomatic null check, since it can't be hijacked by an overloaded == operator.
6. What do init and required do?
init (C# 9) creates a property that can be set in an object initializer but not afterward. required (C# 11) forces callers to set the member during construction, or the code doesn't compile.
public class User
{
public required string Email { get; init; }
public string? DisplayName { get; init; }
}
var u = new User { Email = "ada@example.com" };
var bad = new User();
u.Email = "x";
Together they give you immutable objects with initializer syntax, without writing a constructor that takes ten parameters. If a constructor does set all required members, mark it with [SetsRequiredMembers] so callers using that constructor aren't forced to set them again.
7. What are primary constructors, and how are they different on classes vs records?
Primary constructors (C# 12 for classes and structs) let you declare constructor parameters on the type itself. On a record, those parameters become public properties. On a class, they don't - they are parameters in scope for the whole class body.
public class OrderService(IOrderRepository repo, ILogger<OrderService> logger)
{
public async Task<Order?> Get(int id)
{
logger.LogInformation("Fetching {Id}", id);
return await repo.FindAsync(id);
}
}
Gotchas worth mentioning: the captured parameters are mutable (you can reassign repo inside a method), and there's no readonly modifier for them. If you also copy a parameter into a property and use the parameter elsewhere, the compiler warns that the value is stored twice. Many teams use primary constructors for DI injection in services and assign to readonly fields when immutability matters.
8. How do generics, constraints, and variance work?
Generics let you write type-safe code once for many types. Unlike Java's type erasure, .NET generics are reified - the runtime knows List<int> and List<string> are different types, and value type instantiations get their own specialized code, which is why List<int> doesn't box.
Constraints restrict what T can be: where T : class, struct, new(), notnull, a base class, or an interface.
Variance applies only to generic interfaces and delegates, and only for reference types:
out T (covariance) - IEnumerable<string> can be used as IEnumerable<object>, because T only comes out.
in T (contravariance) - Action<object> can be used as Action<string>, because T only goes in.
IEnumerable<string> names = new List<string> { "a" };
IEnumerable<object> objs = names;
List<object> list = new List<string>();
Bonus point: arrays are covariant in an unsafe way. object[] arr = new string[1]; arr[0] = 1; compiles and throws ArrayTypeMismatchException at runtime.
9. Explain delegates, events, and lambdas.
A delegate is a type-safe reference to a method. Func<...> and Action<...> cover most needs, so custom delegate types are rare now. A lambda is concise syntax for creating a delegate (or an expression tree). An event is a delegate field with restricted access: outside code can only += and -=; only the declaring class can invoke it or reassign it.
public class Downloader
{
public event EventHandler<int>? ProgressChanged;
protected void OnProgress(int pct) => ProgressChanged?.Invoke(this, pct);
}
var d = new Downloader();
d.ProgressChanged += (_, pct) => Console.WriteLine($"{pct}%");
Follow-ups to expect: lambdas capture variables, not values (closures), so a lambda that captures a loop counter from a for loop sees the final value. And event subscriptions keep the subscriber alive - a long-lived publisher with short-lived subscribers that never unsubscribe is a classic .NET memory leak.
10. What are extension methods, and what changed in C# 14?
Extension methods are static methods in a static class whose first parameter is marked this. They let you call the method as if it were an instance method. LINQ is built on them.
public static class StringExtensions
{
public static bool IsBlank(this string? s) => string.IsNullOrWhiteSpace(s);
}
" ".IsBlank();
They are resolved at compile time (static dispatch), so they can't override real instance methods and can't access private members. C# 14 adds extension members: a new extension block syntax that also supports extension properties and static extension members, not just methods. Existing this-parameter extension methods keep working unchanged.
LINQ, Memory and the Runtime (11-20)
This is where interviewers find out whether you understand what your code costs.
11. What is deferred execution in LINQ?
Most LINQ operators (Where, Select, OrderBy, Take) don't run when you call them. They build a pipeline that executes when you enumerate - with foreach, ToList(), Count(), First(), and so on.
var numbers = new List<int> { 1, 2, 3 };
var evens = numbers.Where(n => n % 2 == 0);
numbers.Add(4);
Console.WriteLine(evens.Count());
Two consequences: the query reflects the source at enumeration time, and every enumeration re-runs the whole pipeline. If the source is expensive (a database query, a file read, a yield return method doing work), enumerating twice does the work twice. Materialize with ToList() or ToArray() when you need the results more than once.
12. IEnumerable vs IQueryable - what's the difference?
IEnumerable<T> runs LINQ operators in memory using delegates. IQueryable<T> captures operators as expression trees that a provider (like EF Core) translates into something else, usually SQL.
var active = db.Users.Where(u => u.IsActive).ToList();
IEnumerable<User> all = db.Users;
var activeSlow = all.Where(u => u.IsActive).ToList();
The trap is accidentally switching to IEnumerable too early - via a method that accepts IEnumerable<T>, or an AsEnumerable() call - and filtering a million rows in your app instead of in the database. The flip side: an IQueryable expression can only use what the provider can translate, so calling arbitrary C# methods inside a query either fails at runtime or forces client evaluation.
13. What are Span<T> and Memory<T>, and when would you use them?
Span<T> is a view over contiguous memory - an array, a slice of an array, stack memory from stackalloc, or native memory - without copying. Slicing a span is just adjusting a pointer and length, so it allocates nothing.
ReadOnlySpan<char> line = "2026-10-11,ORDER,42";
int comma = line.IndexOf(',');
ReadOnlySpan<char> date = line[..comma];
int id = int.Parse(line[(line.LastIndexOf(',') + 1)..]);
Span<T> is a ref struct, so it can only live on the stack: it can't be a field of a class, can't be boxed, and can't be held across an await. Memory<T> is the heap-friendly counterpart - storable in fields and usable in async code - and you call .Span on it when you actually process the data.
Use them in hot paths: parsers, serializers, protocol handling, and anywhere Substring and array copies show up in allocation profiles. Most application code never needs them directly, but the BCL uses them heavily, which is part of why modern .NET is fast.
14. What's the difference between ==, Equals, and GetHashCode?
For reference types, == defaults to reference equality unless the type overloads it (string does, records do). Equals is virtual and can be overridden to define value equality. GetHashCode must be consistent with Equals: equal objects must return equal hash codes, or Dictionary and HashSet break.
public sealed class Sku : IEquatable<Sku>
{
public string Code { get; }
public Sku(string code) => Code = code;
public bool Equals(Sku? other) => other is not null && Code == other.Code;
public override bool Equals(object? obj) => Equals(obj as Sku);
public override int GetHashCode() => Code.GetHashCode();
}
This is also a great moment to mention that records generate all of this for you. And never base a hash code on a mutable field of an object that sits in a hash set - change the field and the object is lost in the wrong bucket.
15. How does the .NET garbage collector work? What are generations?
.NET uses a tracing, generational, compacting GC. New objects go into Gen 0. Objects that survive a collection get promoted to Gen 1, then Gen 2. The bet (the generational hypothesis) is that most objects die young, so collecting Gen 0 often and cheaply reclaims most garbage, while Gen 2 collections are rarer and more expensive.
During a collection the GC finds live objects starting from roots (statics, stack references, GC handles), reclaims the rest, and compacts surviving objects to remove fragmentation.
Two modes to know: workstation GC (tuned for client apps) and server GC (a heap per core, higher throughput, the default for ASP.NET Core apps). Background GC lets Gen 2 collections run mostly concurrently with your code. Since .NET 9, server GC uses DATAS (dynamic adaptation to application sizes) by default, which adjusts heap count to the workload instead of always using one heap per core.
The real interview answer: "Allocation in .NET is cheap; survival is expensive. Short-lived garbage is fine. Objects that live just long enough to get promoted to Gen 2 and then die are what cause painful full collections."
16. What is the Large Object Heap?
Objects of 85,000 bytes or more (in practice, mostly large arrays and big strings) go on the Large Object Heap. The LOH is collected only with Gen 2, and by default it is not compacted, because copying large objects is expensive. That means repeatedly allocating and freeing large buffers can fragment it and grow memory.
Fixes: reuse buffers with ArrayPool<T>.Shared, stream data instead of loading it into giant arrays, and avoid building huge strings. You can request a one-time LOH compaction via GCSettings.LargeObjectHeapCompactionMode, but that's a band-aid. Since .NET 5 there's also a Pinned Object Heap for objects that need to stay pinned, which keeps them from fragmenting the regular heaps.
17. Explain IDisposable, using, and finalizers.
The GC manages memory, not other resources. File handles, sockets, database connections, and native memory need deterministic cleanup, and IDisposable.Dispose() is the contract for that.
using var conn = new SqlConnection(connString);
await conn.OpenAsync();
A finalizer (~MyType()) is a safety net the GC runs at some unknown point if Dispose wasn't called. Finalizable objects are more expensive: they survive at least one extra collection while they wait in the finalization queue. Call GC.SuppressFinalize(this) in Dispose to skip that.
In modern code you almost never write a finalizer. If you wrap a native handle, use SafeHandle, which already has the finalization logic. Your class just implements IDisposable and disposes the handle.
18. What is IAsyncDisposable?
IAsyncDisposable exposes DisposeAsync(), returning a ValueTask, for resources whose cleanup involves I/O - flushing a stream, closing a network connection, committing a transaction. You consume it with await using.
await using var writer = new StreamWriter(File.OpenWrite("out.txt"));
await writer.WriteLineAsync("hello");
If a type implements both interfaces, prefer await using in async code so cleanup doesn't block. The DI container also understands it: disposing a scope asynchronously calls DisposeAsync on services that implement it.
19. What's the relationship between C#, IL, the CLR, and the JIT?
The C# compiler (Roslyn) compiles source to IL (intermediate language) plus metadata in an assembly. At runtime the CLR loads the assembly and the JIT compiles IL to native machine code, method by method, the first time each method is called.
Modern .NET uses tiered compilation: methods are first compiled quickly with fewer optimizations (tier 0), and hot methods are recompiled with full optimizations (tier 1). Dynamic PGO (on by default since .NET 8) uses data gathered at runtime - which types actually flow through a virtual call, which branches are hot - to guide that recompilation. That's why long-running .NET services often get faster after warm-up.
ReadyToRun precompiles IL to native code to speed startup, while keeping the IL so the JIT can still re-optimize.
20. What is Native AOT, and what are the trade-offs?
Native AOT compiles your app ahead of time into a single native executable with no JIT and no need for an installed runtime. You get fast startup, lower memory use, and smaller deployments, which makes it attractive for serverless functions, containers that scale to zero, and CLI tools.
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
The cost is that everything must be knowable at build time. The app is trimmed, so reflection-heavy code can break, and you can't load assemblies dynamically or generate code at runtime. You lean on source generators instead - for example System.Text.Json source generation instead of reflection-based serialization. ASP.NET Core minimal APIs support Native AOT; MVC controllers do not. The build emits trim and AOT warnings, and the honest answer is "treat those warnings as errors, or you'll find out in production."
Async and Concurrency (21-30)
Async is where most C# interviews get serious. Expect follow-ups on every one of these.
21. What does async/await actually compile to?
The compiler rewrites an async method into a state machine. Each await is a possible suspension point. When you await something that hasn't completed, the method registers a continuation, returns an incomplete Task to its caller, and the thread is freed. When the awaited operation finishes, the state machine resumes at the right state.
Key points to say out loud:
async does not create a thread. Awaiting I/O frees the thread while the operation is in flight - that's where the scalability comes from.
- If the awaited task already completed, execution just continues synchronously with no suspension.
- Exceptions are captured into the returned
Task and rethrown at the await.
Task.Run is for offloading CPU-bound work, not for making I/O "async."
22. Task vs ValueTask - when should you use ValueTask?
Task is a reference type, so every async method that returns one may allocate. ValueTask<T> is a struct that can wrap either a result directly or an underlying task, so methods that usually complete synchronously (a cache hit, data already buffered) can avoid the allocation.
public ValueTask<Product?> GetAsync(int id)
{
if (_cache.TryGetValue(id, out var p))
return ValueTask.FromResult<Product?>(p);
return new ValueTask<Product?>(LoadAsync(id));
}
The rules that trip people up: await a ValueTask only once, don't await it concurrently, and don't call .Result unless it has completed. If you need to do any of those, call .AsTask(). Default to Task; use ValueTask in hot paths where measurements show the allocations matter.
By default, await captures the current SynchronizationContext (or task scheduler) and resumes the continuation on it. In UI apps that means "back on the UI thread." ConfigureAwait(false) says "resume on any thread pool thread."
ASP.NET Core has no SynchronizationContext, so in application code there it makes no practical difference. It still matters in library code that might be called from WPF, WinForms, MAUI, or legacy ASP.NET, where it avoids unnecessary thread hops and helps prevent the deadlock in the next question.
The real interview answer: "In app code on ASP.NET Core, I don't bother. In a reusable library, I use ConfigureAwait(false) on every await, because I don't know who's calling me."
24. Why can calling .Result or .Wait() on a task deadlock?
Classic scenario: UI thread (or legacy ASP.NET request thread with a synchronization context) calls GetDataAsync().Result. The thread blocks waiting for the task. Inside GetDataAsync, an await completes and tries to resume on the captured context - the same thread that is blocked. Neither can proceed.
var data = GetDataAsync().Result;
var data = await GetDataAsync();
In ASP.NET Core you won't get that exact deadlock, but sync-over-async still blocks thread pool threads, and under load that leads to thread pool starvation: requests queue up, latency spikes, and the thread pool injects new threads slowly. The fix is the same everywhere - async all the way down.
25. Why is async void dangerous?
An async void method can't be awaited, so the caller can't know when it finishes, and exceptions thrown inside it are raised directly on the synchronization context. In ASP.NET Core or a console app, an unhandled exception there can crash the process.
The only legitimate use is event handlers, where the signature is fixed by the delegate (void Button_Click(object sender, EventArgs e)). Everything else should return Task or ValueTask. A common accidental source is passing an async lambda to a parameter of type Action, which silently makes it async void.
26. How does cancellation work in .NET?
Cancellation is cooperative. A CancellationTokenSource owns the signal; you pass its CancellationToken down through your calls; code checks the token or passes it to APIs that honor it.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
await ProcessAsync(cts.Token);
}
catch (OperationCanceledException)
{
}
async Task ProcessAsync(CancellationToken ct)
{
foreach (var item in await LoadItemsAsync(ct))
{
ct.ThrowIfCancellationRequested();
await SaveAsync(item, ct);
}
}
In ASP.NET Core, add a CancellationToken parameter to an action or minimal API handler and the framework binds it to HttpContext.RequestAborted, so work stops when the client disconnects. Use CancellationTokenSource.CreateLinkedTokenSource to combine a request token with your own timeout.
27. What is IAsyncEnumerable<T>?
IAsyncEnumerable<T> (C# 8) is an async stream: a sequence where getting each next item may require awaiting. You produce it with yield return inside an async method and consume it with await foreach.
async IAsyncEnumerable<Order> StreamOrdersAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
int page = 0;
while (true)
{
var batch = await _api.GetPageAsync(page++, ct);
if (batch.Count == 0) yield break;
foreach (var o in batch) yield return o;
}
}
await foreach (var order in StreamOrdersAsync(ct))
Process(order);
It's the right fit for paged APIs, database results you want to process row by row, and streaming responses without buffering everything in memory. EF Core queries can be consumed with AsAsyncEnumerable(), and ASP.NET Core can stream an IAsyncEnumerable<T> return value as JSON.
28. How do you make code thread-safe in .NET?
Start by not sharing mutable state: immutable objects, records, and local variables are thread-safe by default. When you must share, pick the lightest tool that works:
Interlocked - atomic operations on a single value (Interlocked.Increment(ref count)).
- Concurrent collections -
ConcurrentDictionary, ConcurrentQueue. Note that ConcurrentDictionary.GetOrAdd with a factory can invoke the factory more than once under contention; only one value wins, but side effects in the factory may happen twice.
lock - for multi-step invariants that must hold together.
- Immutable collections (
ImmutableArray, ImmutableDictionary) - swap a whole reference atomically instead of mutating.
A classic follow-up: count++ is not atomic. It's a read, an add, and a write, and two threads can interleave and lose an update.
29. lock vs SemaphoreSlim - when do you use each?
lock is a fast, re-entrant mutual exclusion lock, but you cannot await inside a lock block - the compiler rejects it, because the continuation might run on a different thread than the one that holds the lock. SemaphoreSlim supports WaitAsync, so it's the standard async-compatible lock, and it can allow N concurrent holders instead of one.
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task RefreshAsync()
{
await _gate.WaitAsync();
try
{
await ReloadCacheAsync();
}
finally
{
_gate.Release();
}
}
SemaphoreSlim is not re-entrant, so the same flow acquiring it twice will deadlock itself. C# 13 and .NET 9 also added a dedicated System.Threading.Lock type; when you lock on an instance of it, the compiler uses its more efficient scoped API instead of Monitor.
30. What are Channels, and when would you use them?
System.Threading.Channels provides async producer/consumer queues. Producers write to a ChannelWriter<T>, consumers read from a ChannelReader<T>, and both sides are fully async.
var channel = Channel.CreateBounded<Job>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait
});
await channel.Writer.WriteAsync(job, ct);
await foreach (var job in channel.Reader.ReadAllAsync(ct))
await HandleAsync(job, ct);
A bounded channel gives you backpressure: when it's full, producers wait instead of growing memory without limit. The typical pattern in ASP.NET Core is an endpoint that writes work to a channel and a BackgroundService that drains it. It's simpler and more efficient than hand-rolling a BlockingCollection or a queue plus a lock.
ASP.NET Core (31-37)
Most C# backend roles are ASP.NET Core roles. These questions come up in almost every loop.
31. How does the middleware pipeline work?
Every request flows through an ordered chain of middleware. Each component can do work, call the next one, then do more work on the way back out - or short-circuit and write the response itself.
var app = builder.Build();
app.UseExceptionHandler("/error");
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Use(async (context, next) =>
{
var sw = Stopwatch.StartNew();
await next(context);
logger.LogInformation("{Path} took {Ms}ms", context.Request.Path, sw.ElapsedMilliseconds);
});
Order matters. The exception handler goes first so it can catch everything after it. Authentication must run before authorization, since you can't decide what a user may do before you know who they are. Run adds terminal middleware that never calls next; Map branches the pipeline by path.
32. Explain DI service lifetimes: transient, scoped, singleton.
- Transient - a new instance every time it's resolved. For lightweight, stateless services.
- Scoped - one instance per scope. In ASP.NET Core a scope is one HTTP request.
DbContext is registered as scoped by default.
- Singleton - one instance for the application's lifetime. Must be thread-safe.
builder.Services.AddTransient<IEmailFormatter, EmailFormatter>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IClock, SystemClock>();
The container disposes the instances it creates when their scope (or the root container) ends. Instances you construct yourself and register with AddSingleton(instance) are not disposed by the container.
33. What is a captive dependency?
A captive dependency is a service with a longer lifetime holding onto one with a shorter lifetime. The classic case: a singleton that takes a scoped DbContext in its constructor. The singleton is created once, so it holds the first DbContext forever - shared across requests and threads, which DbContext is not built for.
public class PriceCache(AppDbContext db) { ... }
builder.Services.AddSingleton<PriceCache>();
public class PriceCache(IServiceScopeFactory scopes)
{
public async Task RefreshAsync()
{
using var scope = scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
}
}
The default host enables scope validation in the Development environment, so resolving a scoped service from the root provider or injecting it into a singleton throws at startup there. That's a good reason to run locally in Development.
34. Minimal APIs vs controllers - which do you choose?
Minimal APIs map handlers directly to routes with very little ceremony. They support route groups (MapGroup), endpoint filters, TypedResults for strongly typed responses, and they're the path that supports Native AOT.
var orders = app.MapGroup("/orders").RequireAuthorization();
orders.MapGet("/{id:int}", async Task<Results<Ok<Order>, NotFound>> (int id, IOrderService svc) =>
await svc.GetAsync(id) is { } order ? TypedResults.Ok(order) : TypedResults.NotFound());
Controllers give you a convention-heavy structure: model binding, model validation via [ApiController], action filters, and a natural home for grouping many related actions. They're well understood by large teams.
A good answer is pragmatic: minimal APIs for new services, microservices, and anything targeting AOT; controllers are fine for large existing codebases or teams that rely on MVC filters and conventions. Both run on the same routing and middleware, and both perform well.
35. What is the options pattern?
The options pattern binds configuration sections to strongly typed classes and injects them.
public class SmtpOptions
{
public required string Host { get; init; }
public int Port { get; init; } = 587;
}
builder.Services.AddOptions<SmtpOptions>()
.Bind(builder.Configuration.GetSection("Smtp"))
.ValidateDataAnnotations()
.ValidateOnStart();
Three interfaces, three behaviors:
IOptions<T> - singleton, read once; doesn't see config changes.
IOptionsSnapshot<T> - scoped, recomputed per request; sees changes, but can't be injected into singletons.
IOptionsMonitor<T> - singleton, always returns the current value and offers OnChange notifications.
ValidateOnStart() is worth calling out: it makes the app fail at startup on bad config instead of on the first request that happens to read it.
36. How do authentication and authorization work in ASP.NET Core?
Authentication establishes who the user is and produces a ClaimsPrincipal. It's handled by schemes - JWT bearer for APIs, cookies for server-rendered apps, OpenID Connect for delegating login to an identity provider. Authorization decides what that user can do.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.Authority = "https://login.example.com";
o.Audience = "orders-api";
});
builder.Services.AddAuthorization(o =>
o.AddPolicy("CanRefund", p => p.RequireClaim("permission", "refund")));
app.MapPost("/refunds", IssueRefund).RequireAuthorization("CanRefund");
Prefer policy-based authorization over sprinkling role checks: a policy names a requirement, and you can implement complex rules with custom IAuthorizationHandlers, including resource-based checks ("is this user the owner of this order?"). A 401 means not authenticated; a 403 means authenticated but not allowed.
37. What are hosted services and BackgroundService?
IHostedService lets you run code that starts and stops with the application host. BackgroundService is the base class most people use: override ExecuteAsync and run a loop until the stopping token fires.
public class OutboxPublisher(IServiceScopeFactory scopes, ILogger<OutboxPublisher> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
using var scope = scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await PublishPendingAsync(db, stoppingToken);
}
}
}
builder.Services.AddHostedService<OutboxPublisher>();
Points interviewers look for: hosted services are singletons, so create a scope for scoped services like DbContext; honor the stopping token so shutdown is clean; catch and log exceptions inside the loop, because an unhandled exception in ExecuteAsync stops the host by default. Also know that in-process background work runs on every instance of your app - if you scale to five instances, you have five publishers, which matters for anything that must run once.
EF Core and Testing (38-44)
38. Tracking vs no-tracking queries - what's the difference?
By default, entities returned by EF Core queries are tracked: the DbContext keeps a snapshot, detects changes, and writes them on SaveChanges(). That costs memory and CPU. For read-only queries, AsNoTracking() skips it.
var products = await db.Products
.AsNoTracking()
.Where(p => p.IsActive)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync(ct);
Projecting into a DTO with Select is often even better: you only fetch the columns you need, and projections to non-entity types aren't tracked anyway. Use AsNoTrackingWithIdentityResolution() when you want no tracking but need the same row to map to the same object instance within one query.
For bulk changes, EF Core 7+ has ExecuteUpdateAsync and ExecuteDeleteAsync, which issue a single SQL statement without loading entities at all.
39. What is the N+1 query problem, and how do you fix it in EF Core?
N+1 is one query to load a list, then one more query per item to load related data. It usually comes from lazy loading or from querying inside a loop.
foreach (var order in await db.Orders.ToListAsync())
Console.WriteLine(order.Customer.Name);
var orders = await db.Orders.Include(o => o.Customer).ToListAsync();
var rows = await db.Orders
.Select(o => new { o.Id, CustomerName = o.Customer.Name })
.ToListAsync();
Follow-up: Include on several collection navigations can produce a "cartesian explosion" - one huge joined result set with duplicated rows. AsSplitQuery() loads each collection in a separate query instead. Turn on EF Core logging in development and look at the SQL; that's how you catch these before production does.
40. How do you handle EF Core migrations in production?
dotnet ef migrations add <Name> generates a migration from model changes; review the generated code, because renames can be generated as drop-and-add, which loses data.
For applying them, calling Database.Migrate() at app startup is convenient but risky: multiple instances can race, and the app account then needs schema-change permissions. Common production approaches:
- Idempotent SQL scripts -
dotnet ef migrations script --idempotent, reviewed and run by the pipeline.
- Migration bundles -
dotnet ef migrations bundle produces a self-contained executable that applies pending migrations, run as a deploy step.
Pair this with expand-and-contract changes for zero-downtime deploys: add the new column, deploy code that writes both, backfill, switch reads, then drop the old column in a later release.
41. What are compiled queries, and when do they help?
Every time EF Core runs a LINQ query, it has to turn the expression tree into SQL. EF Core caches that translation, but there's still per-call work to look up the cached query. Compiled queries do that work once up front and give you a delegate.
private static readonly Func<AppDbContext, int, Task<Product?>> GetById =
EF.CompileAsyncQuery((AppDbContext db, int id) =>
db.Products.FirstOrDefault(p => p.Id == id));
var product = await GetById(db, 42);
They help on very hot, simple queries where that overhead shows up in profiles. They're not a fix for slow SQL - for that you look at indexes and query shape. Don't confuse them with compiled models (dotnet ef dbcontext optimize), which speed up DbContext startup for large models.
42. How is a test suite structured in xUnit?
[Fact] marks a test with no parameters; [Theory] with [InlineData], [MemberData], or [ClassData] runs the same test with different inputs.
public class PriceCalculatorTests
{
[Theory]
[InlineData(100, 0.10, 90)]
[InlineData(0, 0.10, 0)]
public void ApplyDiscount_ReturnsDiscountedPrice(decimal price, decimal rate, decimal expected)
{
var calc = new PriceCalculator();
Assert.Equal(expected, calc.ApplyDiscount(price, rate));
}
}
Things that distinguish xUnit: it creates a new instance of the test class for every test, so the constructor is setup and Dispose is teardown - no [SetUp] attributes. Shared expensive setup goes in fixtures: IClassFixture<T> for one class, collection fixtures across classes. Tests in different classes run in parallel by default, which is a common source of flaky tests when they share state like a database.
43. How do you mock dependencies, and what shouldn't you mock?
Libraries like Moq and NSubstitute generate test doubles for interfaces (and virtual members).
var repo = Substitute.For<IOrderRepository>();
repo.FindAsync(42).Returns(new Order { Id = 42, Total = 100m });
var service = new OrderService(repo, NullLogger<OrderService>.Instance);
var order = await service.Get(42);
Assert.Equal(100m, order!.Total);
await repo.Received(1).FindAsync(42);
What not to mock: types you don't own with complicated behavior. Mocking DbContext or HttpClient internals produces tests that pass against fake behavior. Better options: run EF Core against a real database in a container (Testcontainers is popular for this), and test HTTP clients with a fake HttpMessageHandler. Also inject time via TimeProvider (.NET 8+) instead of calling DateTime.UtcNow directly, so time-dependent logic is testable.
44. How do you write integration tests for an ASP.NET Core API?
WebApplicationFactory<TEntryPoint> (from Microsoft.AspNetCore.Mvc.Testing) boots your real app in memory with a test server and gives you an HttpClient that talks to it - real routing, middleware, DI, and serialization, with no network port.
public class OrdersApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task GetOrder_Returns404_ForUnknownId()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/orders/999999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
You can override services with WithWebHostBuilder(b => b.ConfigureTestServices(...)) to swap in fakes for external systems. With top-level statements on older templates, you may need to add public partial class Program { } so the test project can reference the entry point type.
Modern .NET and Senior Design (45-50)
These come up for mid-level and senior roles, and they're where you show judgment rather than recall.
45. What's new in recent C# versions that you actually use?
Pick features you have used, and be ready to explain why they matter. A solid tour:
- C# 11 -
required members, raw string literals ("""..."""), list patterns, and generic math via static abstract interface members.
- C# 12 (.NET 8) - primary constructors for classes and structs, collection expressions (
int[] xs = [1, 2, 3];), and using aliases for any type.
- C# 13 (.NET 9) -
params collections (params ReadOnlySpan<T>, params IEnumerable<T>), the System.Threading.Lock type, and ref struct types allowed to implement interfaces.
- C# 14 (.NET 10) - extension members (extension properties and static extension members), the
field keyword for accessing a property's compiler-generated backing field, null-conditional assignment (customer?.Order = order;), and implicit conversions from arrays to Span<T> and ReadOnlySpan<T>.
public string Name
{
get;
set => field = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
}
Also know the release rhythm: a new major .NET version ships every November, even-numbered releases are LTS (three years of support), and odd-numbered releases are STS. .NET 10 is the current LTS.
46. What is .NET Aspire?
.NET Aspire is an opinionated stack for building and running distributed .NET apps, focused heavily on the local development experience. Its main pieces:
- AppHost - a C# project where you describe your app's resources (APIs, workers, Postgres, Redis, message brokers) and how they reference each other. Running it starts everything, wiring up connection strings and service discovery.
- Service defaults - a shared project that configures OpenTelemetry (logs, metrics, traces), health checks, and HTTP resilience consistently across services.
- Dashboard - a local UI showing every resource, its logs, traces, and metrics.
- Integrations - packages that configure common clients and containers with sensible defaults.
var builder = DistributedApplication.CreateBuilder(args);
var db = builder.AddPostgres("pg").AddDatabase("orders");
var cache = builder.AddRedis("cache");
builder.AddProject<Projects.OrdersApi>("orders-api")
.WithReference(db)
.WithReference(cache);
builder.Build().Run();
A good answer is honest about scope: it replaces a lot of docker-compose and hand-written observability setup, and it can produce deployment artifacts, but it's not a runtime or a hosting platform you're locked into.
47. How do you call external HTTP services correctly?
The classic mistake is new HttpClient() per request. Each instance can hold its own connections, and disposing them rapidly can exhaust sockets. A single static HttpClient fixes that but can hold onto stale DNS for long-lived connections.
The standard answer is IHttpClientFactory, typically with typed clients:
builder.Services.AddHttpClient<PaymentsClient>(c =>
{
c.BaseAddress = new Uri("https://payments.example.com");
c.Timeout = TimeSpan.FromSeconds(10);
})
.AddStandardResilienceHandler();
The factory pools and recycles message handlers, solving both the socket and DNS problems. The resilience handler (built on Polly) adds retries with backoff, circuit breaking, and timeouts. Senior follow-up: only retry idempotent operations, or use idempotency keys - retrying a non-idempotent POST /charges can charge a customer twice.
48. A production ASP.NET Core service has rising latency and memory. How do you investigate?
Walk through it methodically:
- Look at the metrics first. Is it CPU, memory, GC, thread pool, or a downstream dependency?
dotnet-counters shows live runtime counters: GC heap size, Gen 2 collection count, thread pool queue length, exception rate.
- Thread pool starvation shows up as a growing queue length and thread count climbing slowly while CPU stays low. The usual cause is sync-over-async (
.Result, .Wait()) somewhere. A dotnet-dump and its clrstack command will show many threads blocked in the same place.
- Memory growth - take a heap snapshot with
dotnet-gcdump (or a full dotnet-dump) at two points in time and compare which types grew. Usual suspects: unbounded caches, static collections, event handlers never unsubscribed, captive DbContexts.
- CPU hot paths -
dotnet-trace captures a trace you can open in PerfView, Visual Studio, or speedscope.
- Downstream latency - distributed traces (OpenTelemetry) tell you whether your service is slow or it's waiting on the database or another API.
The real interview answer: "I don't guess. I measure which resource is saturated, form a hypothesis, prove it with a dump or trace, fix it, and confirm the metric moved."
49. Design a service that processes millions of background jobs reliably.
There's no single right answer; interviewers want to see trade-offs. Key decisions to talk through:
- Durable queue, not memory. An in-process
Channel is great for buffering but loses work on a crash. Use a broker (SQS, Azure Service Bus, RabbitMQ, Kafka) or a database-backed queue.
- At-least-once delivery means idempotent handlers. Messages will be redelivered. Track processed message IDs or design operations so repeating them is harmless.
- Transactional outbox. When you update the database and publish a message, write the message to an outbox table in the same transaction and publish it from a background worker, so you never commit data without its event (or vice versa).
- Concurrency and backpressure. Use a bounded
Channel or Parallel.ForEachAsync with MaxDegreeOfParallelism to cap work per instance, and scale out horizontally.
- Failure handling. Retries with backoff, a dead-letter queue for poison messages, and alerts on dead-letter depth.
- Observability. Queue depth, processing latency, and failure rate as metrics; trace context propagated through message headers.
Mention .NET specifics where they fit - BackgroundService workers, CancellationToken-aware shutdown so in-flight jobs finish or get released, and scoped DbContext per message.
50. How would you structure a large ASP.NET Core solution?
Show that you can reason about trade-offs instead of reciting a template.
- Organize by feature, not just by layer. A
Features/Orders folder holding the endpoint, handler, validator, and DTOs is easier to change than spreading one feature across Controllers, Services, and Repositories projects.
- Keep the domain free of infrastructure. Business rules shouldn't depend on EF Core or HTTP types. That's the useful core of Clean or Onion Architecture.
- Don't add abstraction layers that do nothing. A generic repository that wraps
DbContext method-for-method adds code without adding value; DbContext already is a unit of work and DbSet<T> already is a repository. Add an abstraction when you have a reason (testing a boundary, swapping a provider).
- Modular monolith before microservices. Strong module boundaries inside one deployable get you most of the design benefit without distributed-systems costs. Split out a service when you have a real scaling, ownership, or deployment-cadence reason.
- Enforce boundaries mechanically - project references,
internal visibility, and architecture tests - so the structure survives the next deadline.
The real interview answer: "I start as simple as the problem allows, keep the domain clean, and add structure when pain shows up - not before. The worst codebases I've seen were over-architected on day one."
How to Prepare for a C# Interview
Know why, not just what. Anyone can say "use ValueTask for performance." Interviewers want the conditions: when it helps, what the rules are, and what breaks when you ignore them.
Have a production story ready. A deadlock you debugged, an N+1 you found in the logs, a captive dependency that leaked a DbContext. Real stories beat definitions every time.
Write code without the IDE. It's easy to lean on IntelliSense. Practice writing a BackgroundService, a middleware, or a LINQ query from memory, because whiteboard and shared-editor rounds don't autocomplete.
Practice explaining out loud. Reading these answers is not the same as delivering them under pressure. If you want to test yourself, gitGood has free C# practice questions covering the language, async, and .NET runtime material above, and you can run the follow-up questions in an AI mock interview.
Good luck out there. You've got this.