The 60 .NET questions interviewers actually ask, with direct answers, runnable C# code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
.NET is a free, open-source developer platform, first released by Microsoft in 2002 and rebuilt as cross-platform .NET Core in 2016, for building many kinds of applications with one runtime and one class library. Its main language is C#, and code compiles to an intermediate language (IL) that the Common Language Runtime (CLR) turns into machine code at run time with a just-in-time compiler. According to Microsoft's official documentation, modern .NET runs on Windows, Linux, and macOS, which is the split every candidate should know: modern .NET is cross-platform, while the legacy .NET Framework is Windows-only. In interviews, .NET questions probe how well you understand the runtime (CLR, JIT, garbage collection), the type system (value versus reference types, boxing), and asynchronous programming (async/await, Task), not memorized trivia. This page collects the 60 questions that come up most, each with a direct answer and runnable C#. If the round runs as a live AI coding interview, pair this bank with our AI interview preparation guides for the format side.
Watch: C# Tutorial - Full Course for Beginners
Video: C# Tutorial - Full Course for Beginners (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your .NET certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
'.NET is a free, open-source developer platform for building web, desktop, mobile, cloud, and game apps. Its main language is C#, and one runtime plus one class library serve every app type.
Its popularity comes from strong tooling (Visual Studio, the dotnet CLI), a large standard library, static typing that catches errors early, and modern .NET running the same code on Windows, Linux, and macOS. Teams get performance and safety without giving up productivity.
Key point: A one-line definition plus what C# and the runtime give you beats a memorized feature list. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: What is C#? | C# 101 [1 of 19] (dotnet, YouTube)
C# is a programming language. .NET is the platform it runs on: the runtime (CLR), the base class libraries, and the tooling. You write C#, it compiles to IL, and .NET runs that IL.
The distinction matters because .NET supports other languages too (F#, Visual Basic), and all of them compile to the same IL and share the same libraries. So C# is one way to target .NET, not the whole thing.
Key point: Candidates who blur these two sound less precise. Naming that C# compiles to IL that the CLR runs shows you understand the layers.
The Common Language Runtime is .NET's execution engine. It loads your compiled IL, uses a just-in-time compiler to turn it into native machine code, and manages memory, garbage collection, exceptions, and type safety while the program runs.
Because the CLR provides these services, C# code doesn't manage memory manually or worry about the CPU it runs on. That's what 'managed code' means: code whose execution the CLR manages.
C# source -> C# compiler -> IL (+ metadata) in a .dll/.exe
IL -> CLR loads it -> JIT compiles to native code -> runs
CLR also handles: garbage collection, type safety, exceptionsKey point: The trigger phrase the question needs is 'managed code'. Explaining IL then JIT then native is the clean three-step answer.
IL (Intermediate Language) is the CPU-independent code the C# compiler produces. It isn't machine code yet. JIT (just-in-time) compilation is when the CLR converts IL to native machine code at run time, method by method, the first time each method runs.
This two-stage model is why the same .dll runs on different processors: IL is portable, and the JIT targets whatever CPU is present. Ahead-of-time (AOT) compilation is an alternative that compiles to native code before run time for faster startup.
Key point: AOT as the alternative to JIT matters.NET versions.
A value type (int, double, bool, struct, enum) holds its data directly and is copied on assignment. A reference type (class, string, array, delegate) holds a reference to data on the heap, so assignment copies the reference, not the object.
This drives real behavior: passing a struct to a method copies it, but passing a class passes a reference to the same object. It's the root of many 'why did my change not stick' and 'why did they both change' bugs.
struct Point { public int X; }
class Box { public int X; }
var p1 = new Point { X = 1 };
var p2 = p1; // copy
p2.X = 99;
// p1.X is still 1 (value type copied)
var b1 = new Box { X = 1 };
var b2 = b1; // same object
b2.X = 99;
// b1.X is now 99 (reference type shared)| Value type | Reference type | |
|---|---|---|
| Examples | int, struct, enum | class, string, array |
| Stored | Stack or inline | Heap (reference on stack) |
| Assignment | Copies the value | Copies the reference |
| Default value | Zero-initialized | null |
Key point: This is the gateway to boxing, struct-vs-class, and pass-by-reference questions. Answer it crisply and expect a follow-up.
Boxing converts a value type to a reference type by wrapping it in an object on the heap. Unboxing extracts the value type back out, with a run-time type check. Both cost a heap allocation and a copy.
Boxing happens implicitly when a value type is assigned to object or an interface. In hot loops it quietly hurts performance, which is why generics (List<int> instead of ArrayList) exist: they avoid boxing entirely.
int n = 42;
object boxed = n; // boxing: heap allocation + copy
int back = (int)boxed; // unboxing: type check + copy
// generics avoid it
var list = new List<int> { 1, 2, 3 }; // no boxing per elementKey point: The senior-signaling add-on is 'generics avoid boxing'. Naming List<int> vs ArrayList shows you know why generics arrived.
Use a struct for small, immutable, value-like data that you copy cheaply: a point, a color, a money amount. Use a class for most things: entities with identity, larger objects, and anything you want to share by reference.
The guidance is size and semantics. Structs avoid heap allocation but get copied on every pass, so a large struct copied often is slower than a class. Microsoft's rule of thumb is roughly 16 bytes or smaller, immutable, and short-lived.
Key point: The right answer includes a downside: large structs are expensive to copy. Only naming the upside sounds like a memorized rule.
A string in .NET can't be changed after creation. Any operation that looks like it modifies a string actually allocates a new one. Immutability makes strings safe to share, hashable, and interned, but it means building a string with += in a loop allocates on every step.
Use StringBuilder when you're concatenating in a loop or assembling many pieces. It writes into a growable buffer and produces the final string once, turning O(n squared) work into O(n).
var sb = new StringBuilder();
foreach (var row in rows)
sb.Append(Render(row));
string html = sb.ToString(); // one allocation, the idiomatic wayBuilding a big string: += in a loop vs StringBuilder
Work to assemble a string of N pieces, N = 10000 (log scale). String is immutable, so += allocates a new string each step.
Key point: The follow-up is 'why is that faster?'. Answering 'string is immutable so += copies each time' before they ask shows you understand the cause.
const is a compile-time constant: its value is baked into the callers at compile time and must be known then. readonly is a run-time constant: it can be set in a constructor, so its value can depend on run-time input, and it's evaluated when the object is built.
The practical trap: because const values are inlined into other assemblies at compile time, changing a public const in a library means dependents must recompile to see the new value. readonly avoids that.
public const double Pi = 3.14159; // compile-time, inlined
public readonly DateTime Created; // set in constructor
public Order() { Created = DateTime.UtcNow; } // run-time valuestatic means a member belongs to the type itself, not to any instance. A static field is shared across all instances; a static method is called on the type (Math.Sqrt) without creating an object. A static class can't be instantiated and holds only static members.
Use static for utilities and shared constants. The caution: static mutable state is global state, which makes code hard to test and unsafe under concurrency unless you guard it.
public (visible everywhere), private (only within the same type), protected (the type and its subclasses), internal (anywhere in the same assembly), and protected internal and private protected, which combine the two.
The default for class members is private, and for top-level types it's internal. Choosing the tightest modifier that still works is the encapsulation habit interviewers look for.
| Modifier | Visible to |
|---|---|
| public | Everywhere |
| private | Same type only (default for members) |
| protected | Same type and derived types |
| internal | Same assembly (default for top-level types) |
| protected internal | Same assembly OR derived types |
An abstract class can hold fields, constructors, and fully implemented methods alongside abstract ones, but a class can inherit only one. An interface defines a contract of members that implementers must provide, and a class can implement many.
Use an abstract class for an 'is-a' hierarchy with shared state and behavior. Use an interface for a capability that unrelated types can share (IComparable, IDisposable). Since C# 8, interfaces can carry default method implementations, which narrows the gap.
abstract class Shape {
public string Name = "shape"; // state allowed
public abstract double Area(); // must override
public void Describe() => Console.WriteLine(Name);
}
interface IPrintable {
void Print(); // contract only
}
class Circle : Shape, IPrintable { // one class, many interfaces
public double R;
public override double Area() => 3.14159 * R * R;
public void Print() => Console.WriteLine(Area());
}Key point: The modern add-on is 'interfaces can have default implementations since C# 8'. it matters.
Encapsulation (bundling data with the methods that guard it, exposing only what's needed), inheritance (a class deriving from a base to reuse and extend it), polymorphism (one interface, many implementations, via virtual/override), and abstraction (modeling with abstract classes and interfaces to hide detail).
The signal isn't reciting the four words. It's giving a one-line C# example of each: a private field with a public property, a base class, an overridden virtual method, and an interface.
Key point: Interviewers hear the four words from everyone. Attaching a concrete C# mechanism to each is what separates candidates.
Overloading is defining multiple methods with the same name but different parameter lists in the same type; the compiler picks one by arguments at compile time. Overriding is a derived class replacing a base class's virtual method; the correct version is chosen at run time by the object's actual type.
So overloading is compile-time polymorphism (by signature), overriding is run-time polymorphism (by type). Overriding needs virtual on the base and override on the derived method.
class Printer {
public void Print(int n) { } // overload
public void Print(string s) { } // overload
public virtual void Log() { }
}
class FilePrinter : Printer {
public override void Log() { } // override
}virtual marks a base method as replaceable. override replaces it in a derived class, and calls dispatch to the derived version even through a base-typed reference. new hides the base method instead of overriding it, so which version runs depends on the reference type, not the object.
The gotcha is new: it looks like override but breaks polymorphism. A base-typed variable calls the base method even when the object is the derived type. Interviewers use this to test whether you understand dispatch.
class A { public virtual void Go() => Console.Write("A"); }
class B : A { public override void Go() => Console.Write("B"); }
class C : A { public new void Go() => Console.Write("C"); }
A b = new B(); b.Go(); // "B" (override: run-time type)
A c = new C(); c.Go(); // "A" (new hides, base-typed reference)Key point: If you can predict the output of the base-typed 'new' call, you've shown you understand virtual dispatch, which is the whole point.
A nullable value type (int?) lets a value type also hold null, useful for 'no value yet' cases like an unset database column. Nullable reference types (a compiler feature you enable) flag possible nulls in reference types at compile time so you handle them before they crash.
With the feature on, string means 'never null' and string? means 'might be null', and the compiler warns when you dereference something possibly null. It turns a class of NullReferenceException bugs into compile-time warnings.
int? age = null; // nullable value type
if (age.HasValue) { } // check before use
string? name = GetName(); // nullable reference type
int len = name?.Length ?? 0; // null-conditional + null-coalescingA field is a variable stored on an object. A property is an accessor with get and set methods that looks like a field to callers but can compute, validate, or restrict access behind the scenes. Auto-properties (public int Age { get; set; }) generate the backing field for you.
The reason to expose properties, not public fields, is evolution: you can add validation or make a property read-only later without breaking any caller. Public fields lock you in.
class Person {
public string Name { get; set; } // auto-property
private int _age;
public int Age {
get => _age;
set => _age = value < 0 ? 0 : value; // validation
}
}params lets a method accept a variable number of arguments as a single array parameter. Callers can pass a comma-separated list or an actual array, and the method sees an array either way.
It has to be the last parameter, and there can be only one. String.Format and Console.WriteLine use it, which is why you can pass any number of format arguments.
int Sum(params int[] numbers) {
int total = 0;
foreach (var n in numbers) total += n;
return total;
}
Sum(1, 2, 3); // 6
Sum(new[] { 4, 5 }); // 9ref passes a variable by reference and requires it to be initialized first; the method can read and write it. out also passes by reference but the caller need not initialize it, and the method must assign it before returning. in passes by reference read-only, to avoid copying a large struct without allowing changes.
out is the classic for methods returning more than one value, like int.TryParse. Since C# 7 you can declare the out variable inline at the call site.
if (int.TryParse("42", out int value))
Console.WriteLine(value); // 42
void Swap(ref int a, ref int b) {
(a, b) = (b, a);
}try holds code that may fail, catch handles specific exception types, and finally always runs for cleanup whether or not an exception was thrown. You throw with throw, and you should catch specific types (FileNotFoundException) rather than the base Exception.
Catching Exception too broadly hides bugs, and swallowing an exception with an empty catch turns a crash into silent corruption. the key signal is that awareness and for using finally (or using) for cleanup.
try {
var text = File.ReadAllText(path);
}
catch (FileNotFoundException ex) {
Console.WriteLine($"Missing: {ex.FileName}");
}
finally {
Console.WriteLine("always runs");
}IDisposable is an interface with one method, Dispose(), that releases unmanaged resources like file handles, sockets, or database connections. The using statement calls Dispose() automatically when the block exits, even if an exception is thrown.
This matters because the garbage collector frees managed memory but doesn't promptly release unmanaged resources. using gives you deterministic cleanup. C# 8 added the using declaration (using var f = ...) that disposes at the end of the enclosing scope.
using (var reader = new StreamReader("data.txt")) {
var line = reader.ReadLine();
} // reader.Dispose() called here, even on exception
// C# 8 using declaration
using var conn = new SqlConnection(cs);Key point: The key phrase is 'deterministic cleanup of unmanaged resources'. Contrasting it with the non-deterministic GC shows you understand why using exists.
List<T> (a dynamic array, the default sequence), Dictionary<TKey,TValue> (O(1) average key lookup), HashSet<T> (unique elements, fast membership), Queue<T> and Stack<T> (FIFO and LIFO), and arrays for fixed-size data.
Reaching for the right one signals fluency: Dictionary for lookups, HashSet for de-duplication and O(1) Contains, Queue for order-of-arrival processing. Picking List and scanning it with Contains when you needed a HashSet is a common performance mistake.
| Type | Best at | Lookup cost |
|---|---|---|
| List<T> | Ordered, indexable sequence | O(n) by value, O(1) by index |
| Dictionary<K,V> | Key to value lookup | O(1) average |
| HashSet<T> | Unique items, membership tests | O(1) average |
| Queue<T> / Stack<T> | FIFO / LIFO processing | O(1) enqueue/pop |
For candidates with working experience: language mechanics, the runtime, LINQ, async, and the questions that separate users from understanders.
The GC automatically frees managed objects no longer reachable from the program. It's generational: new objects start in Gen 0, and those that survive a collection are promoted to Gen 1, then Gen 2. Because most objects die young, collecting Gen 0 frequently is cheap and full Gen 2 collections are rare.
The GC also compacts the heap to reduce fragmentation, and large objects (over 85 KB) go on a separate Large Object Heap. You almost never call it manually; understanding generations is what lets you reason about allocation-heavy code.
| Generation | Holds | Collected |
|---|---|---|
| Gen 0 | Newly allocated objects | Very frequently, cheaply |
| Gen 1 | Gen 0 survivors (buffer) | Less often |
| Gen 2 | Long-lived objects | Rarely, most expensive |
| LOH | Objects over ~85 KB | With Gen 2 |
Key point: The trap is saying 'the GC frees memory' and stopping. Explaining generations, and why they exist (most objects die young), is the intermediate-level answer.
Dispose() is called deterministically by you (or a using block) to release unmanaged resources right away. A finalizer (~ClassName) runs non-deterministically when the GC collects the object, as a safety net for resources nobody disposed. Objects with finalizers cost extra: they survive an extra GC pass.
The standard pattern implements IDisposable, calls GC.SuppressFinalize(this) in Dispose so a disposed object skips finalization, and keeps the finalizer only as a backstop. Most modern code uses SafeHandle and doesn't write finalizers at all.
public class Resource : IDisposable {
private bool _disposed;
public void Dispose() {
if (_disposed) return;
// release unmanaged resources here
_disposed = true;
GC.SuppressFinalize(this); // skip the finalizer
}
~Resource() { /* backstop only */ }
}An async method can use await to pause at an asynchronous operation and return control to the caller until that operation finishes, without blocking the thread. When the awaited work completes, execution resumes right after the await. Nothing runs in parallel by default; it's about not tying up a thread while waiting.
The compiler rewrites an async method into a state machine that tracks where to resume. This is what keeps a web server handling other requests while one waits on a database, and keeps a UI responsive during I/O.
async Task<string> FetchAsync(HttpClient client, string url) {
HttpResponseMessage resp = await client.GetAsync(url);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadAsStringAsync();
}Key point: The one-line answer the question needs: await frees the thread while waiting, it doesn't create a new thread. Getting that straight is the whole question.
Watch a deeper explanation
Video: C# Async / Await - Make your app more responsive and faster with asynchronous programming (IAmTimCorey, YouTube)
A Thread is an OS-level thread, a heavyweight resource you manage directly. A Task is a higher-level promise of future work that usually runs on a pooled thread managed by the runtime, so you don't create or destroy OS threads yourself. Task is what async/await builds on.
Use Task and the thread pool for almost everything; the runtime reuses threads and scales them for you. Reach for a raw Thread only for rare cases like a long-running dedicated background loop where you want full control.
| Thread | Task | |
|---|---|---|
| Level | OS thread, low-level | Unit of work, high-level |
| Backing | One dedicated thread | Usually a pooled thread |
| Cost | Heavier to create | Cheap, reused |
| Works with async/await | No | Yes |
Calling .Result or .Wait() on a Task from a context that has a single-threaded synchronization context (classic UI or old ASP.NET) can deadlock: the caller blocks the one thread the continuation needs to resume on, so neither proceeds. It's the most common async bug.
The fix is to go async all the way: await instead of blocking, never mix blocking calls with async in a captured context, and use ConfigureAwait(false) in library code to not capture the context. Modern ASP.NET Core has no such context, which removes the classic trap, but the rule still holds.
// risky: blocking on async in a captured context can deadlock
var data = FetchAsync().Result;
// correct: await all the way up
var data = await FetchAsync();
// in library code, don't capture the context
var r = await FetchAsync().ConfigureAwait(false);Key point: Naming 'don't block on async with .Result' plus ConfigureAwait(false) for libraries is exactly the production-scar answer the technical value is.
A delegate is a type-safe reference to a method, so you can pass methods around, store them, and invoke them later. Action and Func are built-in generic delegates. An event is a delegate wrapped so that outside code can only subscribe (+=) and unsubscribe (-=), not raise it or overwrite the whole list.
Delegates power callbacks, LINQ, and the observer pattern. Events add encapsulation on top: the publisher controls when it fires, subscribers only get to listen. This is the foundation of the pub/sub model in .NET.
public class Button {
public event Action? Clicked; // event
public void Press() => Clicked?.Invoke(); // publisher raises it
}
var b = new Button();
b.Clicked += () => Console.WriteLine("clicked"); // subscribe
b.Press();LINQ (Language Integrated Query) is a uniform query syntax over collections, databases, XML, and more, using methods like Where, Select, OrderBy, and GroupBy. You can write it in method syntax or the SQL-like query syntax; both compile to the same calls.
Most LINQ queries use deferred execution: building the query doesn't run it. It runs only when you enumerate the results (foreach, ToList, Count, First). That's efficient but has a gotcha: a deferred query re-runs every time you enumerate it, and captures variables by reference.
var nums = new[] { 1, 2, 3, 4, 5 };
var evens = nums.Where(n => n % 2 == 0); // not run yet
foreach (var n in evens) // runs here
Console.WriteLine(n); // 2, 4
var list = evens.ToList(); // forces execution nowKey point: The follow-up is usually 'what's the downside of deferred execution?'. Naming re-execution on each enumeration (and ToList to materialize) is the answer.
IEnumerable<T> represents an in-memory sequence: LINQ operators run as compiled C# over objects already loaded. IQueryable<T> represents a query that a provider (like Entity Framework) translates into another language, usually SQL, and runs at the data source.
The practical difference is where filtering happens. A Where on an IQueryable becomes a SQL WHERE, so the database returns only matching rows. The same Where after you've pulled data into IEnumerable filters in memory, after loading everything. Filtering on IQueryable before materializing is a common performance fix.
| IEnumerable<T> | IQueryable<T> | |
|---|---|---|
| Runs where | In memory (client) | At the data source (e.g. SQL) |
| Filtering | After loading data | Translated into the query |
| Typical use | Objects, LINQ to Objects | Entity Framework, databases |
| Risk | Loads more than needed | Not all C# translates to SQL |
Key point: The gotcha to mention: not every C# expression translates to SQL on IQueryable, so a method call the provider can't translate throws or silently pulls everything into memory.
An extension method adds a method to an existing type without modifying it or subclassing it. You write a static method in a static class with this before the first parameter, and the compiler lets you call it as if it were an instance method of that type.
All of LINQ is extension methods on IEnumerable<T>. They're useful for adding readable helpers to types you don't own, like string or a third-party class. The caveat: they can't access private members, so they're syntactic sugar, not real member injection.
public static class StringExtensions {
public static bool IsNullOrBlank(this string? s)
=> string.IsNullOrWhiteSpace(s);
}
" ".IsNullOrBlank(); // true, called like an instance methodGenerics let you write a class or method that works over a type parameter T, keeping type safety without duplicating code or boxing. List<T>, Dictionary<K,V>, and Task<T> are generic. Constraints (where T : ...) restrict what T can be, so the method can rely on those capabilities.
Common constraints: where T : class, where T : struct, where T : IComparable<T>, and where T : new() for a parameterless constructor. Constraints are what let a generic method call methods on T, since without them T is just object.
T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
Max(3, 7); // 7
Max("a", "b"); // "b"Dependency injection is a pattern where a class receives its dependencies from outside instead of creating them itself, usually through its constructor. That inverts control: a container builds the object graph and hands each class what it needs, which makes code testable and loosely coupled.
ASP.NET Core has a built-in DI container. You register services at startup and the framework injects them into controllers, pages, and other services. DI is core to the framework, not optional; middleware, logging, and configuration all flow through it.
// registration
builder.Services.AddScoped<IOrderService, OrderService>();
// injection via constructor
public class OrdersController : ControllerBase {
private readonly IOrderService _orders;
public OrdersController(IOrderService orders) => _orders = orders;
}Key point: the question needs 'constructor injection' plus 'testable and loosely coupled' plus the three lifetimes. Naming the built-in container shows real ASP.NET Core experience.
Watch a deeper explanation
Video: ASP.NET Core Full Course For Beginners (.NET 8) (Julio Casal, YouTube)
Transient: a new instance every time the service is requested, for lightweight stateless services. Scoped: one instance per request (per HTTP request in a web app), the common choice for things like a database context. Singleton: one instance for the whole application lifetime, for stateless shared services and caches.
The classic bug is a captive dependency: injecting a scoped service into a singleton keeps that one scoped instance alive forever, which is wrong and can corrupt per-request state. Matching lifetimes correctly is what this question screens for.
| Lifetime | Instances | Typical use |
|---|---|---|
| Transient | New every request | Lightweight, stateless services |
| Scoped | One per HTTP request | DbContext, per-request state |
| Singleton | One per app | Caches, stateless shared services |
Key point: The captive-dependency trap (scoped inside singleton) is the follow-up. Volunteering it marks you as someone who has debugged real DI issues.
Middleware is a chain of components that each get to inspect and modify an HTTP request on the way in and the response on the way out. Each component can handle the request or call the next one, so the pipeline is ordered and each piece wraps the rest.
Order matters: authentication runs before authorization, exception handling wraps everything so it catches downstream errors, and static files short-circuit early. Built-in middleware covers routing, CORS, and HTTPS redirection; you add custom middleware with app.Use.
app.UseExceptionHandler("/error"); // outermost, catches downstream
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers(); // endpoint execution
// custom middleware
app.Use(async (context, next) => {
// before next component
await next();
// after next component
});Configuration is layered from multiple sources: appsettings.json, environment-specific files like appsettings.Production.json, environment variables, command-line arguments, and user secrets in development. Later sources override earlier ones, so environment variables can override the JSON file.
You read strongly typed settings with the options pattern: bind a config section to a class and inject IOptions<T>. That keeps configuration typed and testable instead of reading magic string keys throughout the code.
// bind a section to a typed class
builder.Services.Configure<SmtpOptions>(
builder.Configuration.GetSection("Smtp"));
// inject it
public class Mailer {
public Mailer(IOptions<SmtpOptions> opts) {
var host = opts.Value.Host;
}
}EF Core is .NET's object-relational mapper. It maps C# classes to database tables so you query with LINQ instead of raw SQL, and it tracks changes to loaded entities so SaveChanges generates the right INSERT, UPDATE, or DELETE. Migrations version your schema alongside your model.
The performance topics interviewers probe: the N+1 query problem (loading related data in a loop instead of with Include), change tracking overhead (AsNoTracking for read-only queries), and the difference between eager, lazy, and explicit loading of related data.
// eager load related data in one query, avoiding N+1
var orders = await db.Orders
.Include(o => o.Customer)
.Where(o => o.Total > 100)
.AsNoTracking() // read-only, skips change tracking
.ToListAsync();Key point: Naming the N+1 problem and Include, plus AsNoTracking for reads, is the signal that you've used EF Core on something real.
Override both together. Equals defines value equality by comparing the fields that make two objects the same. GetHashCode must return the same hash for any two objects that are equal, so hash the same fields you compare. Break that contract and objects go missing in dictionaries and sets.
The modern shortcut is a record, which generates value-based Equals, GetHashCode, and ToString for you. Write them by hand only when a record doesn't fit; otherwise records remove a whole class of subtle equality bugs.
// records generate correct value equality for you
public record Money(decimal Amount, string Currency);
var a = new Money(10, "USD");
var b = new Money(10, "USD");
Console.WriteLine(a == b); // True, value equalityA record is a reference type (or, as record struct, a value type) built for data: the compiler generates value-based equality, a readable ToString, and a with-expression for making a copy with a few changes. They're typically immutable via init-only properties.
Use records for DTOs, domain values, and anything defined by its data rather than identity. Use a regular class when the object has mutable state and behavior and identity matters. Records make immutability and value equality the easy default.
public record Person(string Name, int Age);
var p1 = new Person("Asha", 30);
var p2 = p1 with { Age = 31 }; // non-destructive copy
// p1 unchanged, p2 is a new recordPattern matching tests a value's shape and extracts data in one step, using is, switch expressions, and patterns for types, properties, tuples, and ranges. It replaces chains of type checks and casts with declarative branches.
Modern C# added switch expressions (a value-returning switch), property patterns, relational patterns (< 18), and list patterns. They make branching logic shorter and let the compiler check you've covered the cases.
string Classify(object shape) => shape switch {
Circle c when c.Radius > 10 => "big circle",
Circle => "circle",
Rectangle { Width: var w, Height: var h } => $"rect {w}x{h}",
null => "nothing",
_ => "unknown"
};The standard generic collections (List<T>, Dictionary<K,V>) are not thread-safe for concurrent writes; mutating one from multiple threads corrupts it. Options are to lock around access, or use the System.Collections.Concurrent types (ConcurrentDictionary, ConcurrentQueue, ConcurrentBag) built for concurrent access.
ConcurrentDictionary is the common answer for shared caches, with atomic methods like GetOrAdd and AddOrUpdate. For read-heavy, rarely-changing data, an immutable collection swapped atomically also works. The wrong answer is assuming the default collections are safe.
var cache = new ConcurrentDictionary<string, int>();
// atomic, thread-safe
int value = cache.GetOrAdd("key", k => Compute(k));
cache.AddOrUpdate("hits", 1, (k, old) => old + 1);A CancellationToken is how you cooperatively cancel async or long-running work. A CancellationTokenSource issues the token; when it's cancelled, code that checks the token (or passes it to async APIs) stops promptly instead of running to completion. Cancellation is cooperative: the work has to observe the token.
It matters for responsiveness and resource use: a web request that the client abandoned should stop its database call, and a timeout should actually abort work. Threading CancellationToken through async methods is expected in production .NET code.
async Task ProcessAsync(CancellationToken ct) {
foreach (var item in items) {
ct.ThrowIfCancellationRequested(); // honor cancellation
await HandleAsync(item, ct); // pass it down
}
}advanced rounds probe internals, design judgment, and production scars. Expect every answer here to draw a follow-up.
The C# compiler produces IL plus metadata in an assembly. At run time the CLR loads the assembly, verifies types, and the JIT compiles each method to native code the first time it's called, caching the result for later calls. The GC manages the heap while it runs, and the runtime handles exceptions and security.
Senior-level extras: tiered compilation JITs quickly first then re-JITs hot methods with more optimization; ReadyToRun images ship precompiled code for faster startup; and Native AOT compiles the whole app ahead of time with no JIT at all, trading some flexibility for startup and size.
Key point: Mentioning tiered compilation, ReadyToRun, and Native AOT distinguishes someone who's tuned startup from someone who's only read the happy-path story.
The compiler rewrites an async method into a state machine struct. Each await splits the method into segments; the state machine records which segment to resume, hooks a continuation onto the awaited Task via its awaiter, and returns control. When the awaited work completes, the continuation runs the next segment, capturing the synchronization context unless ConfigureAwait(false) said otherwise.
The consequences that matter in production: an async method that never awaits runs synchronously and allocates needlessly; ValueTask avoids an allocation for the common already-completed case; and capturing the context on every await has a cost that ConfigureAwait(false) removes in library code.
// ValueTask avoids an allocation when the result is often ready
public ValueTask<int> GetAsync(string key) {
if (_cache.TryGetValue(key, out var v))
return new ValueTask<int>(v); // no Task allocated
return new ValueTask<int>(LoadAsync(key));
}Key point: 'What does await compile to?' The answer 'a state machine that hooks a continuation onto the awaiter' is the mark of someone who's read past the syntax.
Watch a deeper explanation
Video: Deep .NET: Writing async/await from scratch in C# with Stephen Toub and Scott Hanselman (dotnet, YouTube)
Span<T> is a stack-only view over contiguous memory: an array, a stack buffer, or unmanaged memory. It lets you slice and process data without copying or allocating, which cuts garbage in hot paths. Because it's a ref struct, it can't live on the heap or cross an await. Memory<T> is the heap-friendly counterpart that can, at a small cost.
They exist for high-performance code that used to allocate substrings and arrays: parsing, serialization, and buffer processing. string.AsSpan().Slice() parses without allocating substrings. This is core to why modern .NET got faster.
ReadOnlySpan<char> text = "2026-07-04";
ReadOnlySpan<char> year = text.Slice(0, 4); // no substring allocated
int y = int.Parse(year); // parses the span directlylock (a Monitor) for simple mutual exclusion around a short critical section. SemaphoreSlim, including its async WaitAsync, to limit concurrency or guard async code where lock can't be held across await. Interlocked for lock-free atomic counters and swaps. ReaderWriterLockSlim when reads vastly outnumber writes.
The judgment is picking the cheapest correct tool and holding it briefly. lock is fine for most cases; you cannot await inside a lock, so async coordination uses SemaphoreSlim; and a single counter should use Interlocked.Increment rather than a lock.
// atomic without a lock
Interlocked.Increment(ref _counter);
// guard async code (can't use lock across await)
await _gate.WaitAsync();
try { await DoWorkAsync(); }
finally { _gate.Release(); }Key point: The trap is 'lock everything'. Explaining why you can't await inside a lock, and reaching for SemaphoreSlim, shows real concurrency experience.
Yes. The GC frees unreachable objects, but a managed leak keeps objects reachable forever: static collections that only grow, event handlers that pin subscribers (the classic one, since the publisher holds a reference to every subscriber), undisposed unmanaged resources, and captured variables in long-lived closures or timers.
Diagnosis: watch process memory and Gen 2 size over time, take heap snapshots with dotnet-gcdump or a profiler and diff them to find the growing type, and trace the GC root that keeps it alive. The fix is usually unsubscribing events, bounding caches, or disposing correctly.
Key point: The event-handler leak (publisher pins subscribers) is the answer they hope to hear. Pairing it with 'unsubscribe or use weak events' seals it.
Workstation GC is tuned for responsiveness on client apps: it uses fewer resources and, in its concurrent mode, keeps pauses short. Server GC uses a dedicated heap and GC thread per core, so it collects in parallel and maximizes throughput for multi-core servers handling many requests, at the cost of higher memory use.
ASP.NET Core defaults to server GC. The tuning conversation: server GC for throughput-heavy backends, workstation for memory-tight or latency-sensitive single-user apps, and you configure it in the project or runtime config. Knowing this is a real ops lever separates infra-aware candidates.
Single responsibility (a class has one reason to change), open/closed (extend without modifying, often via interfaces and DI), Liskov substitution (a subtype must honor the base contract), interface segregation (small focused interfaces over fat ones), and dependency inversion (depend on abstractions, which DI operationalizes).
In .NET these aren't abstract: interface segregation is why IReadOnlyList exists next to IList, dependency inversion is the entire ASP.NET Core DI container, and open/closed is how middleware and filters extend the pipeline. Tying each principle to a framework feature is The production-ready answer.
A repository abstracts data access behind a collection-like interface; a unit of work groups changes into one atomic commit. They isolate the rest of the app from the persistence detail and make swapping or mocking the data layer easier.
The nuance the question needs: EF Core's DbContext already is a unit of work, and DbSet already is a repository, so wrapping them in more layers is often redundant abstraction that hides useful features. The judgment call is whether the extra seam earns its cost, and many teams skip a generic repository over EF Core.
Key point: The mature answer questions the pattern: DbContext is already a unit of work. Blindly adding a repository over EF Core indicates cargo-culting.
Minimal APIs map endpoints directly to handler methods with less ceremony, good for small services, microservices, and focused endpoints where controller scaffolding is overhead. Controllers give you a structured convention: attribute routing, filters, model binding, and grouping, which pays off in larger apps with many endpoints and cross-cutting concerns.
The trade-off is structure versus lightness. Minimal APIs have grown filters and route groups, closing the gap, so the honest answer is team preference and app size rather than a hard rule. Naming that they share the same pipeline underneath shows depth.
// minimal API endpoint
app.MapGet("/orders/{id}", async (int id, IOrderService svc) => {
var order = await svc.GetAsync(id);
return order is null ? Results.NotFound() : Results.Ok(order);
});Throw specific exception types at the point of failure, catch only where you can act, and let everything else bubble to a central handler. In ASP.NET Core that's exception-handling middleware that maps exceptions to appropriate status codes and a consistent error body, with the full detail logged and only safe detail returned.
The principles: don't use exceptions for normal control flow, don't swallow them, preserve stack traces (throw; not throw ex;), chain inner exceptions for the root cause, and distinguish expected failures (validation returns a 400) from unexpected ones (a 500 that pages someone).
try {
await ProcessAsync();
}
catch (ValidationException ex) {
_log.LogWarning(ex, "bad input");
throw; // preserves the stack trace, not 'throw ex;'
}in-memory caching (IMemoryCache) for hot, read-heavy data on a single instance, then move to a distributed cache (IDistributedCache backed by Redis) when you run multiple instances that must share state comes first. Response caching and output caching handle whole HTTP responses at the edge of the pipeline.
The hard part isn't the API, it's invalidation and correctness: pick sensible expirations, guard against cache stampede (many callers recomputing at once) with a lock or single-flight, and never cache per-user data in a shared key. Saying 'the two hard problems are naming and invalidation' with a concrete invalidation plan is the production signal.
| Cache | Scope | Best at |
|---|---|---|
| IMemoryCache | One process | Hot data on a single instance |
| IDistributedCache (Redis) | Shared across instances | Multi-instance, scaled-out apps |
| Output/Response caching | HTTP response | Cacheable GET endpoints |
For in-process work, implement IHostedService or subclass BackgroundService for a long-running loop the host starts and stops cleanly, honoring the stopping token for graceful shutdown. For deferred work triggered by a request, a background task queue plus a hosted consumer keeps the request fast while work happens after the response.
The scaling caveat: in-process background work dies with the process and doesn't survive restarts or scale across instances. Once durability or retries matter, move to a real queue and worker (a message broker, or a library like Hangfire), which is the answer that shows you've hit the limits of the simple approach.
public class Worker : BackgroundService {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
while (!stoppingToken.IsCancellationRequested) {
await DoWorkAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}Unit tests with xUnit (or NUnit) for logic in isolation, mocking dependencies with Moq or NSubstitute against interfaces, which is another reason to depend on abstractions. Integration tests with WebApplicationFactory spin up the app in memory and hit real endpoints, often against a containerized database via Testcontainers.
The judgment is the pyramid: many fast unit tests, fewer integration tests that catch wiring and query bugs, a thin layer of end-to-end. Testing behavior over implementation, and mocking only true external boundaries (network, clock, database), is what keeps the suite from becoming brittle.
[Fact]
public void ApplyDiscount_ReducesTotal() {
var svc = new PricingService();
var result = svc.ApplyDiscount(100m, 10);
Assert.Equal(90m, result);
}A request enters through the server (Kestrel), then flows through the middleware pipeline in order: exception handling, HTTPS redirection, routing, authentication, authorization, and finally endpoint execution. The endpoint (a controller action or minimal API handler) runs with its dependencies injected per request, produces a result, and the response flows back out through the same middleware in reverse.
Understanding this order explains real bugs: authentication before authorization, exception middleware outermost so it catches downstream, and static-file middleware early so it short-circuits before hitting your endpoints.
An HTTP request through ASP.NET Core
Order is the exam: auth before authorization, exception handling outermost, static files early to short-circuit.
Key point: Getting the ordering right (and why each piece sits where it does) is the whole point. Reciting the names without the ordering rationale works poorly.
Authentication establishes who the caller is: validating a cookie, a JWT bearer token, or an external identity provider, and building a ClaimsPrincipal. Authorization decides what that identity is allowed to do: role checks, policy-based rules, or resource-based checks. Authentication runs first, authorization second.
ASP.NET Core wires this through middleware and DI: authentication handlers per scheme, and an authorization system of policies and requirements you apply with attributes or minimal-API extensions. JWT bearer tokens are the common choice for APIs; cookies for server-rendered apps.
// policy-based authorization
builder.Services.AddAuthorization(o =>
o.AddPolicy("AdminOnly", p => p.RequireRole("Admin")));
[Authorize(Policy = "AdminOnly")]
public IActionResult Delete(int id) => Ok();Use ILogger<T> with structured logging so log entries carry named properties, not just interpolated strings, which lets you query them later. Configure providers (console, a file or aggregator, Application Insights) and set levels per category. For distributed systems, add tracing and metrics via OpenTelemetry, which .NET supports natively.
The senior points: log structured events with correlation IDs so you can follow one request across services, pick log levels deliberately (Information for the happy path, Warning and Error for problems), and treat metrics and traces as first-class, not an afterthought bolted on when something breaks.
// structured logging: OrderId is a queryable property, not just text
_logger.LogInformation("Order {OrderId} shipped to {Region}", id, region);Clarify the requirement: clients retry on timeouts, so the endpoint must be idempotent, charging once even if called twice. The standard approach is an idempotency key sent by the client (a header). On the first request you record the key with the result; on a retry with the same key you return the stored result instead of charging again.
Then cover the edges: store the key and outcome atomically with the charge (same transaction or an atomic store) so a crash mid-request can't double-charge, expire keys after a window, and return a clear conflict if the same key arrives with a different payload. Structuring it clarify, mechanism, storage, edges is what the question scores.
Handling a request with an idempotency key
The atomic write of key-plus-outcome is what prevents a crash mid-request from double-charging.
Key point: wearing a .NET costume. the technical evaluation checks the idempotency-key reasoning and the atomicity edge; the language is secondary.
Measure first: dotnet-trace or dotnet-counters on the live process for CPU, allocations, and GC pauses; a profiler or BenchmarkDotNet for a specific hot path; and real workload, because intuition about bottlenecks is usually wrong. Then fix in impact order: better algorithms, batching and reducing I/O and database round-trips, caching, cutting allocations (Span, pooling, avoiding boxing), and only then parallelism.
The interviewer checks for the discipline (profile, don't guess) and the escalation ladder. Common .NET wins are fixing N+1 queries, removing sync-over-async blocking, and reducing GC pressure in hot loops, with a measurement before and after to prove the fix.
Key point: Leading with 'measure first' and naming a concrete tool (dotnet-trace, BenchmarkDotNet) beats jumping straight to a guess about the cause.
.NET wins when you want a statically typed, high-performance runtime with first-class tooling that runs the same code on Windows, Linux, and macOS. It trades the freewheeling flexibility of dynamic platforms for compile-time safety and predictable performance. The comparison interviewers care about most is internal: modern .NET (the cross-platform successor to .NET Core) versus the legacy .NET Framework. Knowing that split, and how .NET stacks up against Java and Node.js, shows you pick a stack on merits rather than habit.
| Platform | Cross-platform | Typing | Best at | Watch out for |
|---|---|---|---|---|
| Modern .NET | Yes (Win, Linux, macOS) | Static (C#) | New web, cloud, and service work | Older Windows-only APIs missing |
| .NET Framework | No (Windows only) | Static (C#) | Legacy Windows and WebForms apps | In maintenance mode, no new features |
| Java (JVM) | Yes | Static | Large enterprise and Android | More verbose, slower startup than .NET |
| Node.js | Yes | Dynamic (JS/TS) | I/O-heavy services, full-stack JS | Single-threaded model, weaker for CPU work |
Prepare in layers, and practice out loud. Most .NET rounds move from concept questions to live coding to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical .NET interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These .NET questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works