C# and .NET interview questions for backend developers: language features, OOP, LINQ, async and threading, memory and the garbage collector, ASP.NET Core and Entity Framework Core, with clear answers and code.
- 52 questions
- 16 easy
- 30 medium
- 6 hard
- Free, no sign-up
- Link to this question
.NET is a free, open-source, cross-platform platform for building apps (web, desktop, mobile, cloud, games). It has three main parts:
Part What it is Languages C# (most popular), F# and Visual Basic Runtime (CLR) the Common Language Runtime: loads code, compiles it to machine code, manages memory (garbage collection), handles exceptions and type safety Base class library (BCL) built-in types and APIs: collections, files, networking, JSON, threading, LINQ and more How C# code runs:
Diagram - The C# compiler turns source code into Intermediate Language (IL), stored in an assembly (
.dll). - At run time the CLR's JIT (just-in-time) compiler turns IL into machine code for the current CPU, method by method.
- Because every .NET language compiles to IL, they can all use each other's libraries.
Follow-up: ".NET Framework vs .NET?" .NET Framework (up to 4.8) is the older, Windows-only version. Modern .NET (.NET Core, then .NET 5, 6, 7, 8 and later) is cross-platform and is where all new features go.
- The C# compiler turns source code into Intermediate Language (IL), stored in an assembly (
- Link to this question
Value types Reference types Examples int,double,bool,DateTime,struct,enumclass,string, arrays,delegate,record(class)Variable holds the data itself a reference (pointer) to an object on the heap Assignment copies the whole value only the reference, so both variables point to the same object Default value zero / falsenullstruct PointS { public int X; } class PointC { public int X; } var a = new PointS { X = 1 }; var b = a; // copies the value b.X = 99; Console.WriteLine(a.X); // 1 var c = new PointC { X = 1 }; var d = c; // copies the reference d.X = 99; Console.WriteLine(c.X); // 99, same objectA common myth is "value types live on the stack, reference types on the heap". It's only partly true: a value type that is a field of a class, or an element of an array, lives inside that object on the heap. The real difference is copy semantics, not where the memory is.
- Link to this question
Use a struct when the type is a small, simple value, such as a point, a money amount or a colour, and:
- it logically represents one value (like
intorDateTime); - it's small (a common guideline is 16 bytes or less), because it's copied on every assignment and method call;
- it's ideally immutable (
readonly struct); - it won't be boxed often (stored as
objector an interface).
Use a class for everything else: objects with identity, larger data, inheritance, or shared mutable state.
public readonly struct Money { public decimal Amount { get; } public string Currency { get; } public Money(decimal amount, string currency) => (Amount, Currency) = (amount, currency); }Why it matters:
Struct benefit Struct cost No separate heap allocation, so less garbage collection Copied on every assignment, which is slow for large structs Good memory locality in arrays Mutable structs cause bugs: you change a copy, not the original No inheritance (but can implement interfaces) Gotcha:
list[0].X = 5doesn't compile for aListof a mutable struct, because the indexer returns a copy. That's one reason to keep structs immutable. - it logically represents one value (like
- Link to this question
Boxing wraps a value type in an object on the heap so it can be treated as
object(or an interface). Unboxing copies the value back out.int n = 42; object o = n; // boxing: allocates a new object on the heap int m = (int)o; // unboxing: type check + copyWhy avoid it in hot code:
- every box is a heap allocation, which adds work for the garbage collector;
- unboxing needs a type check (casting to the wrong type throws
InvalidCastException); - changes to the boxed copy don't affect the original.
Where it hides:
Hidden boxing Fix Old non-generic collections like ArrayListandHashtableuse generic List<int>,Dictionary<K, V>Calling an interface method on a struct through the interface type use generics with constraints, e.g. void M<T>(T x) where T : IComparable<T>string.Format/ string concatenation with value types in older codestring interpolation, which modern C# compiles without boxing Struct without Equals/GetHashCodeoverrides used as a dictionary keyimplement IEquatable<T>(or use arecord struct)Generics are the main reason boxing is rare in modern C#:
List<int>stores realints, not boxed objects. - Link to this question
By default, C# passes arguments by value (for reference types, the reference is copied). These modifiers pass the variable itself by reference:
Modifier Must be set before the call? Can the method change it? Typical use refyes yes method updates the caller's variable outno must assign it before returning returning extra values ( TryParse)inyes no (read-only) passing large structs without copying void Double(ref int x) => x *= 2; int n = 5; Double(ref n); // n is now 10 if (int.TryParse("123", out int value)) Console.WriteLine(value); // 123 double Length(in Vector3 v) => Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);Interview follow-ups:
- Reference types with
ref: withoutref, the method can change the object's fields but can't make the caller's variable point to a new object. Withref, it can. - Prefer return values or tuples over many
outparameters:(int min, int max) GetRange()is easier to read. ref,outandincan't be used inasyncmethods or iterators.
- Reference types with
- Link to this question
A
stringcan never change after it's created. Methods likeReplace,ToUpperor+return a new string.Why immutable:
- Safe to share: many variables (and threads) can point to the same string without copying or locking.
- Safe as dictionary keys: the hash code never changes.
- Interning: identical string literals can share one object.
- Security: a string checked once (like a file path) can't be changed afterwards.
The cost: building a string in a loop creates a new string every time, copying all the text so far. That's O(n²) work.
// Slow: creates 10,000 intermediate strings var s = ""; for (int i = 0; i < 10_000; i++) s += i + ","; // Fast: one growable buffer var sb = new StringBuilder(); for (int i = 0; i < 10_000; i++) sb.Append(i).Append(','); string result = sb.ToString();Rules of thumb:
- A few concatenations, or string interpolation on one line: just use
+or$"...". - Building text in a loop or from many pieces:
StringBuilder. - Joining a collection:
string.Join(",", items).
- Link to this question
Compares Can be customised? ReferenceEquals(a, b)whether both are the same object no a.Equals(b)value equality if the type overrides it, otherwise reference equality yes, override Equals(andGetHashCode)a == bfor value types: value; for reference types: reference, unless the operator is overloaded yes, overload operator ==string a = "hello"; string b = new string("hello".ToCharArray()); Console.WriteLine(a == b); // True (string overloads ==) Console.WriteLine(a.Equals(b)); // True Console.WriteLine(ReferenceEquals(a, b)); // False (two objects) object x = a, y = b; Console.WriteLine(x == y); // False! == on object compares referencesThat last line is the classic trap:
==is chosen at compile time from the variable's declared type, whileEqualsis virtual and uses the real type at run time.When you override equality:
- override
GetHashCodetoo, or the object breaks inDictionaryandHashSet; - implement
IEquatable<T>to avoid boxing for structs; - or use a
record, which generates all of this for you.
- override
- Link to this question
There are two separate features with similar syntax:
1. Nullable value types (
int?): a value type that can also benull.int?is shorthand forNullable<int>, a struct withHasValueandValue.int? age = null; int years = age ?? 0; // null-coalescing: 0 if null if (age is int a) Console.WriteLine(a);2. Nullable reference types (C# 8+, on by default in new projects): a compile-time feature. The compiler tracks which references can be null and warns you before a
NullReferenceExceptioncan happen.string name = null; // warning: null into a non-nullable string string? nickname = null; // fine: declared as maybe-null int len = nickname.Length; // warning: may be null int len2 = nickname?.Length ?? 0; // safeOperators to know:
Operator Meaning ?.call only if not null, otherwise the result is null ??use the right side if the left is null ??=assign only if currently null !"null-forgiving": tells the compiler "trust me, not null" (use sparingly) Key point: nullable reference types change nothing at run time. They're warnings, so treat warnings as errors in serious projects.
- Link to this question
A record (C# 9+) is a type built for holding data. The compiler generates:
- value-based equality (
Equals,GetHashCode,==) comparing all properties; - a readable
ToString(), likePerson { Name = Ana, Age = 30 }; withexpressions for non-destructive copies;- deconstruction for positional records.
public record Person(string Name, int Age); var a = new Person("Ana", 30); var b = new Person("Ana", 30); Console.WriteLine(a == b); // True: same values var older = a with { Age = 31 }; // copy with one change var (name, age) = older; // deconstructclassrecord(class)record structEquality by reference by value by value Kind reference type reference type value type Immutable by default no yes (positional properties are init)no (use readonly record struct)Good uses: DTOs, API request and response models, events and messages, value objects in domain-driven design, and any data you want to compare by content.
Avoid for: entities whose identity matters more than their values (like EF Core entities that change over time).
- value-based equality (
- Link to this question
constreadonlystatic readonlySet when compile time in the declaration or constructor in the declaration or static constructor Per type (implicitly static) instance type Allowed types primitives, string, enumsany type any type Value baked into calling code yes no no public class Config { public const int MaxRetries = 3; public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); public readonly Guid InstanceId; public Config() => InstanceId = Guid.NewGuid(); }The versioning trap: a
constvalue is copied into every assembly that uses it at compile time. If library A haspublic const int Version = 1and you change it to 2, app B still sees 1 until B is recompiled. Useconstonly for values that truly never change (likeMath.PI), andstatic readonlyfor public values that might.Note:
readonlymakes the field read-only, not the object. Areadonly List<int>can't be reassigned, but you can stillAddto it. - Link to this question
Modifier Visible to publiceveryone privatethe same class only (the default for class members) protectedthe same class and classes that inherit from it internalanything in the same assembly (project) (the default for top-level types) protected internalsame assembly or derived classes private protectedderived classes in the same assembly only file(C# 11)the same source file only (for types) Good practice:
- Start with the most restrictive access and open up only when needed. Less public surface means fewer things that can break callers.
- Use
internalfor helpers that other projects shouldn't depend on.InternalsVisibleTolets a test project see them. - Prefer properties over public fields, so you can add validation later without breaking callers.
- Link to this question
Key practices:
- Catch only what you can handle. Catch specific exceptions (
FileNotFoundException), notException, except at the top level (middleware, a global handler) where you log and return an error. - Rethrow with
throw;, notthrow ex;.throw ex;resets the stack trace, hiding where the error really happened. - Clean up with
finallyorusing, so resources are released even when an exception is thrown. - Don't use exceptions for normal control flow. They're slow. Use
TryParse,TryGetValueor result types for expected failures. - Wrap with context: throw a new exception and pass the original as
innerException.
try { var text = await File.ReadAllTextAsync(path); return Parse(text); } catch (FileNotFoundException) { return Defaults(); // expected and handled } catch (JsonException ex) when (ex.LineNumber > 0) // exception filter { throw new ConfigException($"Bad config at line {ex.LineNumber}", ex); } catch (Exception ex) { logger.LogError(ex, "Failed to load {Path}", path); throw; // keeps the original stack trace }Exception filters (
when) decide whether to catch without unwinding the stack, which keeps debugging information intact.Never swallow exceptions silently with an empty
catch { }. - Catch only what you can handle. Catch specific exceptions (
- Link to this question
- A delegate is a type-safe reference to a method: a variable that holds a function.
- A lambda is a short, inline way to write a function:
x => x * 2. - An event is a delegate wrapped so that outside code can only subscribe (
+=) and unsubscribe (-=), not invoke it or replace the subscribers.
Func<int, int> square = x => x * x; // takes int, returns int Action<string> log = msg => Console.WriteLine(msg); // returns nothing Predicate<int> isEven = n => n % 2 == 0; Console.WriteLine(square(4)); // 16 public class Order { public event EventHandler<decimal>? Paid; public void Pay(decimal amount) { // ... take payment Paid?.Invoke(this, amount); // notify subscribers } } var order = new Order(); order.Paid += (sender, amount) => Console.WriteLine($"Paid {amount}");Built-in delegate types:
Func<...>(returns a value),Action<...>(returns void),Predicate<T>(returns bool).Why events instead of a public delegate field? With a public field, any code could write
order.Paid = null(removing all subscribers) or callorder.Paid(...)directly. Theeventkeyword prevents both.Watch out: an object subscribed to a long-lived publisher's event stays in memory until it unsubscribes, which is a common memory leak.
- Link to this question
Extension methods let you add methods to an existing type without changing it or inheriting from it. They're static methods whose first parameter has
thisin front of it:public static class StringExtensions { public static bool IsBlank(this string? s) => string.IsNullOrWhiteSpace(s); public static string Truncate(this string s, int max) => s.Length <= max ? s : s[..max] + "…"; } // Called as if they were instance methods: if (title.IsBlank()) { ... } var shortTitle = title.Truncate(20);Rules:
- defined in a static class, as a static method;
- the namespace must be imported (
using) for them to show up; - they can't access private members of the type;
- if the type has an instance method with the same signature, the instance method wins.
LINQ is built on extension methods:
Where,SelectandOrderByare extension methods onIEnumerable<T>.Use them for small, generally useful helpers. Don't use them to hide important logic where readers won't expect it.
- Link to this question
Pattern matching tests a value's shape (type, properties, ranges) and extracts data from it in one step. It works with
isandswitchexpressions.// Type pattern with a variable if (shape is Circle c) Console.WriteLine(c.Radius); // Switch expression with property, relational and logical patterns decimal Shipping(Order o) => o switch { { Total: >= 100 } => 0m, { Country: "US", Weight: < 1 } => 5m, { Country: "US" } => 10m, { Express: true } => 40m, _ => 25m, // discard: everything else }; // Null and negation patterns if (customer is not null) { ... } // List patterns (C# 11) string Describe(int[] xs) => xs switch { [] => "empty", [var only] => $"one item: {only}", [1, ..] => "starts with 1", _ => "something else", };Why it's useful:
- replaces long
if/elsechains and casts with readable, declarative rules; - the compiler warns when a switch expression doesn't cover every case;
- works well with records for handling different message or result types.
- replaces long
- Link to this question
Interface Abstract class Inheritance a class can implement many a class can inherit from one State (fields) no instance fields yes Constructors no yes Method bodies optional default implementations (C# 8+) yes, plus abstractmethods without bodiesAccess modifiers on members public by default any Meaning "can do" (a capability, a contract) "is a" (a shared base with common code) public interface IPaymentProvider { Task<bool> ChargeAsync(decimal amount); } public abstract class Report { public string Title { get; } protected Report(string title) => Title = title; public string Render() => $"# {Title}\n{Body()}"; // shared logic protected abstract string Body(); // each report fills this in }When to choose:
- Interface: you want a contract many unrelated types can fulfil, for dependency injection and mocking in tests. This is the default choice in most modern C# code.
- Abstract class: related types share real code and state, and you want to enforce a template (like
Renderabove).
It's common to use both: an interface for the contract and an abstract base class as an optional helper.
- Link to this question
virtual+override: polymorphism. The method called depends on the object's real type at run time.new: hiding. The derived method just hides the base one; which runs depends on the variable's declared type at compile time.
class Animal { public virtual string Speak() => "..."; public string Name() => "animal"; } class Dog : Animal { public override string Speak() => "Woof"; public new string Name() => "dog"; } Animal a = new Dog(); Console.WriteLine(a.Speak()); // Woof (override: real type wins) Console.WriteLine(a.Name()); // animal (new: declared type wins) Dog d = new Dog(); Console.WriteLine(d.Name()); // dogRelated keywords:
Keyword Meaning abstractno body; derived classes must override sealed overrideoverrides, but stops further overriding sealed classcan't be inherited (also lets the JIT optimise calls) base.Speak()call the base class version from an override newis rarely what you want. It mostly exists to silence the warning when a base class later adds a member with the same name. Its behaviour surprises readers, so prefer renaming. - Link to this question
Generics let you write a class or method once and use it with any type, while keeping full type safety and no boxing.
List<T>works forList<int>,List<string>and so on.public class Cache<TKey, TValue> where TKey : notnull { private readonly Dictionary<TKey, TValue> _items = new(); public void Set(TKey key, TValue value) => _items[key] = value; public bool TryGet(TKey key, out TValue? value) => _items.TryGetValue(key, out value); }Constraints (
where) limit which types can be used, so the code can rely on what those types can do:Constraint Means T must where T : classbe a reference type where T : structbe a non-nullable value type where T : notnullnot be nullable where T : new()have a public parameterless constructor where T : BaseClassinherit from BaseClasswhere T : IComparable<T>implement an interface where T : unmanagedbe a simple value type with no references (for low-level code) T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b;Why not just use
object? You'd lose compile-time type checking, need casts everywhere, and box value types. .NET generics are reified:List<int>is a real type at run time (unlike Java's type erasure), so value types get specialised, fast code. - Link to this question
They describe when you can substitute a generic type with a more or less specific type argument.
- Covariance (
out T): you can use a more derived type than declared. Allowed whenTonly comes out (is returned). - Contravariance (
in T): you can use a less derived type. Allowed whenTonly goes in (is a parameter).
// Covariance: IEnumerable<out T> IEnumerable<string> strings = new List<string> { "a" }; IEnumerable<object> objects = strings; // OK: every string is an object // Contravariance: Action<in T> Action<object> printAny = o => Console.WriteLine(o); Action<string> printString = printAny; // OK: anything that handles object handles string // Not allowed: List<T> is invariant (T goes both in and out) // List<object> list = new List<string>(); // compile errorWhy
List<T>can't be covariant: ifList<string>could be treated asList<object>, you could callAdd(42)and put anintinto a list of strings.Common variant types:
Covariant ( out)Contravariant ( in)IEnumerable<T>,IReadOnlyList<T>,Func<..., TResult>,Task<T>-like resultsAction<T>,IComparer<T>,IEqualityComparer<T>,Func<T, ...>parametersVariance only works on interfaces and delegates, and only for reference types (
IEnumerable<int>can't becomeIEnumerable<object>).Memory trick: "out" = output = can return something more specific; "in" = input = can accept something more general.
- Covariance (
- Link to this question
Letter Principle In short S Single Responsibility a class should have one reason to change O Open/Closed open for extension, closed for modification L Liskov Substitution subtypes must work anywhere the base type is expected I Interface Segregation many small interfaces beat one big one D Dependency Inversion depend on abstractions, not concrete classes Example, applying S, O and D together:
// Before: one class does everything, and it's hard to add a new channel or test it public class OrderService { public void Place(Order o) { // save to SQL ... // build an email and send it with SmtpClient ... } } // After: small pieces behind interfaces public interface IOrderRepository { Task SaveAsync(Order o); } public interface INotifier { Task NotifyAsync(Order o); } public class OrderService(IOrderRepository repo, IEnumerable<INotifier> notifiers) { public async Task PlaceAsync(Order o) { await repo.SaveAsync(o); foreach (var n in notifiers) await n.NotifyAsync(o); } } // Adding SMS = a new SmsNotifier class; OrderService doesn't change (Open/Closed). // Tests pass fake repositories and notifiers (Dependency Inversion).Liskov classic violation: a
Squarethat inherits fromRectangleand changes both sides when you set the width. Code expecting a rectangle breaks.Good answers also mention balance: applying every principle everywhere can create too many tiny abstractions. Use them where change is likely.
- Link to this question
staticmeans the member belongs to the type itself, not to any instance.Meaning static field one value shared by all instances (and all threads) static method called on the type ( Math.Max(1, 2)); has nothis, so it can't use instance membersstatic class can't be instantiated or inherited; only static members ( Math,File, extension method classes)static constructor runs once, automatically, before the type is first used public class Counter { private static int _created; // shared across all Counters public int Id { get; } public Counter() => Id = Interlocked.Increment(ref _created); public static int TotalCreated => _created; }Watch out for:
- Thread safety: static mutable state is shared by every request in a web app, so it needs locking or should be avoided.
- Testability: static dependencies (like
DateTime.Nowor a static database helper) can't be swapped in tests. Prefer injected services, e.g.TimeProvider. - Memory: objects referenced from static fields live for the whole life of the app.
Good uses: pure helper functions, constants, extension methods and factory methods.
- Link to this question
- A field is a variable stored directly in the object.
- A property looks like a field to callers but is really a pair of methods (
getandset), so it can add logic.
public class Product { private decimal _price; // field (private) public string Name { get; set; } = ""; // auto-property: compiler creates the field public Guid Id { get; init; } // can only be set during object creation public decimal Price // property with validation { get => _price; set => _price = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(value)); } public bool IsFree => Price == 0; // computed, read-only }Why expose properties instead of public fields:
- you can add validation, lazy loading or change notifications later without breaking callers;
- different access for reading and writing (
public get; private set;); - data binding, serializers and many frameworks work with properties, not fields;
- interfaces can contain properties, but not fields.
Fields are fine for
privatestate inside a class. - Link to this question
Pick based on how you'll access the data:
Collection Good for Lookup by key/value Notes T[](array)fixed size, fastest index access O(n) search size can't change List<T>ordered items, add to the end, index access O(n) Containsthe default choice Dictionary<K, V>look up a value by key O(1) keys must be unique HashSet<T>unique items, fast "is it in there?" O(1) Containsset operations: union, intersect Queue<T>/Stack<T>first-in-first-out / last-in-first-out — work queues, undo stacks SortedDictionary<K, V>keys kept in order O(log n) LinkedList<T>many inserts/removes in the middle O(n) rarely the best choice in practice ConcurrentDictionary<K, V>shared between threads O(1) thread-safe ImmutableList<T>etc.data that must never change after creation safe to share var seen = new HashSet<string>(); foreach (var email in emails) if (!seen.Add(email)) // Add returns false if already present Console.WriteLine($"Duplicate: {email}");Classic performance bug: calling
list.Contains(x)inside a loop is O(n²). Converting the list to aHashSetfirst makes it O(n).For method parameters and return types, prefer the least specific interface you need:
IEnumerable<T>orIReadOnlyList<T>, notList<T>. - Link to this question
IEnumerable<T>IQueryable<T>Where the query runs in memory, in your app translated and run by a provider, e.g. as SQL in the database LINQ methods take delegates ( Func<T, bool>)expression trees ( Expression<Func<T, bool>>) that can be translatedTypical source lists, arrays, generated sequences EF Core DbSet, other query providers// IQueryable: the filter becomes SQL ... WHERE Total > 100. Only matching rows are loaded. var big = db.Orders.Where(o => o.Total > 100).ToList(); // IEnumerable: ALL orders are loaded first, then filtered in memory IEnumerable<Order> all = db.Orders; // treated as IEnumerable from here var big2 = all.Where(o => o.Total > 100).ToList();The second version gives the same result but can pull millions of rows over the network. It's a common performance bug when a repository returns
IEnumerableand callers keep adding filters.Guidelines:
- Keep queries as
IQueryablewhile you add filters, sorting and paging, then materialise once (ToListAsync). - Not every C# method can be translated to SQL. EF Core throws if it can't translate a
Where, rather than silently running it in memory. - Returning
IQueryablefrom a repository gives callers great flexibility but leaks database concerns, so many teams return materialised results instead.
- Keep queries as
- Link to this question
Most LINQ operators (
Where,Select,OrderBy,Take...) don't run when you write them. They build a query that runs only when you enumerate it: withforeach,ToList(),Count(),First()and so on.var numbers = new List<int> { 1, 2, 3 }; var evens = numbers.Where(n => n % 2 == 0); // nothing runs yet numbers.Add(4); Console.WriteLine(string.Join(",", evens)); // 2,4 (sees the new item)Consequences:
- Results reflect the source at enumeration time, not when the query was written.
- Multiple enumeration runs the query again, which can be expensive or even give different results (a database query or a random generator runs twice).
var expensive = orders.Where(o => IsSuspicious(o)); // slow check if (expensive.Any()) // runs the query foreach (var o in expensive) Flag(o); // runs it AGAIN var list = orders.Where(o => IsSuspicious(o)).ToList(); // run once, reuse- Exceptions happen late, at enumeration, which can make stack traces confusing.
Deferred Immediate Where,Select,SelectMany,OrderBy,GroupBy,Skip,Take,DistinctToList,ToArray,ToDictionary,Count,Sum,First,Any,MaxRider and Visual Studio warn about "possible multiple enumeration" for exactly this reason.
- Link to this question
yield returnlets you write an iterator: a method that produces a sequence one item at a time, on demand, instead of building a whole list first. The compiler turns the method into a state machine that pauses after eachyield returnand resumes when the next item is requested.IEnumerable<int> Fibonacci() { int a = 0, b = 1; while (true) // infinite, but lazy { yield return a; (a, b) = (b, a + b); } } foreach (var n in Fibonacci().Take(10)) Console.Write($"{n} "); // 0 1 1 2 3 5 8 13 21 34 IEnumerable<string> ReadLines(string path) { using var reader = new StreamReader(path); string? line; while ((line = reader.ReadLine()) != null) yield return line; // never holds the whole file in memory }Benefits: low memory use for large or infinite sequences, and work happens only for the items actually used.
Things to know:
yield breakends the sequence early.- Code in the method doesn't run until enumeration starts, so argument validation is delayed too. Validate in a wrapper method if you need errors immediately.
IAsyncEnumerable<T>withawait foreachis the async version (great for streaming database rows or API pages).
- Link to this question
Method No match One match More than one match First()throws returns it returns the first FirstOrDefault()returns default(null, 0)returns it returns the first Single()throws returns it throws SingleOrDefault()returns defaultreturns it throws var user = users.SingleOrDefault(u => u.Email == email); // emails must be unique var latest = orders.OrderByDescending(o => o.Date).First();How to choose:
- Use
Singlewhen there must be exactly one (lookup by a unique key). If there are duplicates, you want an exception, because your data is wrong. - Use
Firstwhen several may match and you want the first (usually afterOrderBy). - Use the
OrDefaultversions when "not found" is a normal case, and check fornull.
Performance note:
Singlehas to keep looking after the first match to make sure there isn't a second, so on a database it generatesTOP 2instead ofTOP 1. That's usually negligible.Gotcha: for value types,
FirstOrDefaultreturns0when nothing matches, which may be indistinguishable from a real 0. .NET 6+ lets you pass your own default:FirstOrDefault(x => x > 10, -1). - Use
- Link to this question
Selectmaps each item to one result. A list of 3 orders gives 3 results.SelectManymaps each item to a sequence, then flattens all those sequences into one.
var orders = new[] { new Order(1, new[] { "pen", "book" }), new Order(2, new[] { "lamp" }), }; var nested = orders.Select(o => o.Items); // [ ["pen", "book"], ["lamp"] ] -> a sequence of arrays var flat = orders.SelectMany(o => o.Items); // [ "pen", "book", "lamp" ] -> one flat sequence // Keep the parent too: var lines = orders.SelectMany(o => o.Items, (o, item) => $"{o.Id}: {item}"); // [ "1: pen", "1: book", "2: lamp" ] record Order(int Id, string[] Items);In query syntax, two
fromclauses compile toSelectMany:var pairs = from o in orders from item in o.Items select new { o.Id, item };In EF Core,
SelectManyover a navigation collection becomes a SQL join. - Link to this question
async/awaitlets you write code that waits for slow operations (network, disk, database) without blocking a thread, while still reading top to bottom like normal code.public async Task<string> GetUserNameAsync(int id) { var response = await http.GetAsync($"/users/{id}"); // thread is released here response.EnsureSuccessStatusCode(); var user = await response.Content.ReadFromJsonAsync<User>(); return user!.Name; }What happens:
Diagram - The compiler rewrites the method into a state machine.
- At
await, if the operation hasn't finished, the method returns an unfinishedTaskto the caller and the thread goes back to do other work. - When the operation completes, the rest of the method (the continuation) runs, then the
Taskcompletes with the result.
Key points:
asyncdoes not create a new thread. Async I/O uses no thread while waiting. That's why an ASP.NET Core server can handle thousands of concurrent requests with few threads.- For CPU-bound work, async alone doesn't help; use
Task.Runto move it to another thread (but not inside ASP.NET request handlers, where it only adds overhead). - Exceptions are stored in the
Taskand rethrown atawait. - "Async all the way": once something is async, its callers should
awaitit too.
- Link to this question
ThreadTaskWhat it is an operating-system thread a promise of a result (work that will complete later) Cost heavy: about 1 MB of stack, slow to create light: an object; often runs on a thread pool thread, or on no thread at all while waiting for I/O Returns a value no (you'd use shared variables) yes, Task<T>Exceptions crash the process if unhandled stored in the task, rethrown on awaitComposition manual ( Join)await,Task.WhenAll,Task.WhenAny, continuationsCancellation manual built-in with CancellationToken// Old style var t = new Thread(() => DoWork()); t.Start(); t.Join(); // Modern await Task.Run(() => DoWork()); // CPU work on the thread pool var page = await http.GetStringAsync(url); // I/O: no thread used while waitingA
Taskis a higher-level abstraction. A task that waits on I/O isn't running on any thread while it waits.Use a dedicated
Threadonly for rare cases: long-running loops that need their own thread for the whole app's life, or when you need to set thread properties like priority. Even then,Task.Factory.StartNew(..., TaskCreationOptions.LongRunning)or aBackgroundServiceis usually better. - Link to this question
Blocking on async code (
.Result,.Wait(),.GetAwaiter().GetResult()) can deadlock in apps that have a synchronization context that allows only one thread at a time: WinForms, WPF, MAUI and old ASP.NET (.NET Framework).// WPF button click private void Button_Click(object sender, RoutedEventArgs e) { var text = LoadAsync().Result; // DEADLOCK Label.Content = text; } private async Task<string> LoadAsync() { var s = await http.GetStringAsync(url); // wants to resume on the UI thread return s.ToUpper(); }Diagram - The UI thread calls
.Resultand blocks, waiting for the task. - The HTTP call finishes. By default,
awaitresumes on the captured context, the UI thread. - The UI thread is blocked waiting for the task, and the task is waiting for the UI thread. Neither can move.
Fixes:
- Best:
awaitall the way. Make the handlerasyncand useawait LoadAsync(). - In library code, use
ConfigureAwait(false)so continuations don't need the original context.
ASP.NET Core has no synchronization context, so this classic deadlock doesn't happen there. Blocking is still bad, though: it ties up thread pool threads and can cause thread pool starvation under load.
- The UI thread calls
- Link to this question
By default,
awaitcaptures the current synchronization context (like the UI thread in WPF) and resumes the method there afterwards.ConfigureAwait(false)says: "I don't need to come back to the original context; continue on any thread pool thread."public async Task<Config> LoadConfigAsync(string path) { var json = await File.ReadAllTextAsync(path).ConfigureAwait(false); return JsonSerializer.Deserialize<Config>(json)!; }Where Use ConfigureAwait(false)?General-purpose library code (NuGet packages) yes: avoids deadlocks if a caller blocks, and is slightly faster UI apps (WPF, WinForms, MAUI), code that touches UI controls after the await no: you need to be back on the UI thread ASP.NET Core app code not needed: there's no synchronization context, so it makes no difference Gotchas:
- It only affects the
awaitit's attached to. Eachawaitin a library needs it. - If the task has already completed, execution simply continues on the same thread.
- It's not a fix for blocking on async code; the real fix is to
awaitall the way.
- It only affects the
- Link to this question
Task<T>is a class, so every async method that returns one allocates an object, even when the result is already available.ValueTask<T>is a struct that can hold either the result directly (no allocation) or a real task.It helps when a method usually completes synchronously, for example when reading from a cache:
private readonly Dictionary<int, Product> _cache = new(); public ValueTask<Product> GetProductAsync(int id) { if (_cache.TryGetValue(id, out var p)) return ValueTask.FromResult(p); // no allocation on a cache hit return new ValueTask<Product>(LoadAndCacheAsync(id)); // slow path: real Task }Rules for consuming a
ValueTask:- Await it only once. Don't await it twice, don't await it concurrently.
- Don't call
.Resultbefore it has completed. - If you need to do any of those, convert it with
.AsTask()first.
When to use which:
Use TaskUse ValueTaskthe default for almost all code hot paths that often complete synchronously, measured by profiling the result is awaited many times or stored low-level libraries and interfaces like IAsyncEnumerator.MoveNextAsyncInterviewers usually want to hear: default to
Task;ValueTaskis an optimisation with sharp edges. - Link to this question
An
async voidmethod returns nothing the caller can await, which causes three problems:- Exceptions can crash the process. With
async Task, an exception is stored in the task. Withasync void, there is no task, so the exception is thrown on the synchronization context or thread pool, where it usually takes down the app. - The caller can't know when it finished. It can't await it, so code after the call runs while the work is still going.
- Hard to test. A test can't await it to check the result.
// Bad public async void SaveAsync(Order o) => await repo.SaveAsync(o); // Good public async Task SaveAsync(Order o) => await repo.SaveAsync(o);The one valid use is event handlers, because their signature must return
void:private async void SaveButton_Click(object sender, EventArgs e) { try { await SaveAsync(currentOrder); } catch (Exception ex) { ShowError(ex.Message); // always catch inside async void handlers } }Also watch for async lambdas passed to parameters of type
Action. They silently becomeasync void, for example inlist.ForEach(async x => await ...). Use aforeachloop withawaitinstead. - Exceptions can crash the process. With
- Link to this question
.NET uses cooperative cancellation: a
CancellationTokenSourcecreates aCancellationToken, you pass the token down through your calls, and the code doing the work checks it and stops.using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); // auto-cancel after 10 s try { var report = await BuildReportAsync(cts.Token); } catch (OperationCanceledException) { Console.WriteLine("Cancelled or timed out"); } async Task<Report> BuildReportAsync(CancellationToken ct) { var rows = await db.Orders.ToListAsync(ct); // pass it to every async API foreach (var row in rows) { ct.ThrowIfCancellationRequested(); // check in CPU loops Process(row); } return new Report(rows); }In ASP.NET Core, add a
CancellationTokenparameter to an endpoint and it's cancelled automatically when the client disconnects:app.MapGet("/report", async (ReportService svc, CancellationToken ct) => await svc.BuildAsync(ct));Good practices:
- Accept a
CancellationToken(usually last, with= default) in every public async method. - Pass it through to every async call you make.
- Combine sources with
CancellationTokenSource.CreateLinkedTokenSource(a, b)(for example: a user cancel plus a timeout). - Cancellation throws
OperationCanceledException, which is normal, so don't log it as an error.
- Accept a
- Link to this question
Code is thread-safe when it behaves correctly while several threads use it at the same time. Problems come from shared mutable state.
private int _count; public void Increment() => _count++; // NOT thread-safe: read, add, write can interleaveTools, from simplest to most specialised:
Tool Use for Avoid sharing immutable data, local variables, one object per request lockprotecting a few lines that touch shared state Interlockedsingle atomic operations on numbers: Interlocked.Increment(ref _count)Concurrent collections ConcurrentDictionary,ConcurrentQueue,Channel<T>SemaphoreSlimlimiting concurrency, and locking across await(lockcan't containawait)ReaderWriterLockSlimmany readers, few writers Lazy<T>thread-safe one-time initialisation private readonly object _gate = new(); private readonly List<string> _log = new(); public void Add(string msg) { lock (_gate) { _log.Add(msg); } } private readonly SemaphoreSlim _mutex = new(1, 1); public async Task RefreshAsync() { await _mutex.WaitAsync(); try { await ReloadFromApiAsync(); } finally { _mutex.Release(); } }Tips:
- Lock on a private object, never on
thisor a type. - Keep locks short, and don't call unknown code while holding one.
- Always take multiple locks in the same order to avoid deadlocks.
- In ASP.NET Core, singleton services are shared by all requests, so they must be thread-safe.
- Lock on a private object, never on
- Link to this question
Start the tasks first, then await them together with
Task.WhenAll:// Sequential: total time = sum of all calls var user = await GetUserAsync(id); var orders = await GetOrdersAsync(id); // Concurrent: total time ≈ the slowest call var userTask = GetUserAsync(id); var ordersTask = GetOrdersAsync(id); await Task.WhenAll(userTask, ordersTask); var (user2, orders2) = (userTask.Result, ordersTask.Result); // safe: already completedFor many items, limit how many run at once so you don't overload the server or the API:
await Parallel.ForEachAsync(urls, new ParallelOptions { MaxDegreeOfParallelism = 8 }, async (url, ct) => { var html = await http.GetStringAsync(url, ct); await SaveAsync(url, html, ct); });API Use for Task.WhenAllwait for all; throws if any failed (the awaited exception is the first; all are in task.Exception)Task.WhenAnyfirst one wins: timeouts, racing replicas Parallel.ForEachAsyncmany async items with a concurrency limit Parallel.For/ PLINQCPU-bound work across cores Gotchas:
- EF Core's
DbContextis not thread-safe: don't run two queries on the same context at once. - Starting thousands of HTTP calls at once can exhaust sockets or get you rate-limited.
- EF Core's
- Link to this question
The garbage collector (GC) frees memory for objects your code can no longer reach, so you don't free memory by hand.
How it decides: starting from roots (local variables, static fields, CPU registers), the GC marks every object it can reach. Everything else is garbage. It then reclaims that memory and compacts the survivors to avoid fragmentation.
Generations make this fast, based on the observation that most objects die young:
Diagram Gen 0 Gen 1 Gen 2 Contains new objects survivors of one GC long-lived (caches, singletons) Collected very often, very cheap often rarely, expensive (a "full GC") Other points interviewers like:
- Large Object Heap (LOH): objects of 85,000 bytes or more (big arrays, big strings) go here. It isn't compacted by default, so fragmentation can grow. Use
ArrayPool<T>for large temporary buffers. - Workstation vs Server GC: server GC (the default for ASP.NET Core) uses a heap per CPU core for throughput.
- Background GC does most Gen 2 work while your app keeps running.
- The GC manages memory only. Files, sockets and database connections must be released with
Dispose. - Reduce GC pressure by allocating less in hot paths: avoid boxing, use
Span<T>, pool objects, and avoid needless LINQ in tight loops.
- Large Object Heap (LOH): objects of 85,000 bytes or more (big arrays, big strings) go here. It isn't compacted by default, so fragmentation can grow. Use
- Link to this question
The GC frees memory, but not unmanaged resources like file handles, sockets, database connections or locks.
IDisposablegives a type aDispose()method to release them right away, instead of whenever the GC happens to run.usingguaranteesDispose()is called, even if an exception is thrown:// using declaration (C# 8+): disposed at the end of the enclosing scope using var conn = new SqlConnection(connectionString); await conn.OpenAsync(); // using statement: disposed at the end of the block using (var file = File.OpenWrite("out.txt")) { file.Write(data); } // async disposal await using var stream = new FileStream(path, FileMode.Open);The compiler turns
usingintotry { ... } finally { x?.Dispose(); }.Implementing it in your own class, when you own disposable fields:
public sealed class ReportWriter : IDisposable { private readonly StreamWriter _writer; private bool _disposed; public ReportWriter(string path) => _writer = new StreamWriter(path); public void Dispose() { if (_disposed) return; // safe to call twice _writer.Dispose(); _disposed = true; } }Follow-ups:
- Finalizers (
~ClassName()) are a last-resort safety net for classes that hold raw unmanaged handles. They delay collection and run on a separate thread. Most code should never need one; wrap handles inSafeHandleinstead. - Don't dispose what you don't own, e.g. services injected by the DI container. The container disposes them.
HttpClientis the famous exception: don't create and dispose one per request (you can run out of sockets). UseIHttpClientFactory.
- Finalizers (
- Link to this question
Span<T>is a view over a contiguous block of memory: part of an array, a string, stack memory or native memory. It lets you slice without copying or allocating.string line = "2026-10-26,ORDER-42,199.90"; // Old way: Split allocates an array and three new strings var parts = line.Split(','); // Span way: no allocations, just views into the original string ReadOnlySpan<char> span = line; int first = span.IndexOf(','); ReadOnlySpan<char> date = span[..first]; decimal amount = decimal.Parse(span[(span.LastIndexOf(',') + 1)..]); // Stack memory for a small temporary buffer Span<byte> buffer = stackalloc byte[256];Span<T>Memory<T>Kind ref struct: can only live on the stacknormal struct: can live on the heap Can be a class field, used across await, captured in a lambdano yes Speed fastest slightly slower; call .Spanto work with itWhy it matters: parsing, serialization and networking code does lots of slicing. Avoiding a new string or array for every piece greatly reduces GC pressure. Much of .NET's own speed-up in recent versions (Kestrel,
System.Text.Json) comes from spans.Rules of thumb:
- Accept
ReadOnlySpan<char>in synchronous parsing helpers. - Use
Memory<T>when the data must survive anawaitor be stored. - Combine with
ArrayPool<T>.Sharedto rent large buffers instead of allocating new ones.
- Accept
- Link to this question
C# compiles to IL, which must become machine code before it runs. There are two ways:
JIT (just-in-time) Native AOT (ahead-of-time) When at run time, the first time each method is called at build time, producing a native executable Startup slower (compiling on the fly) very fast Peak speed very good: can optimise using real run-time information good, but no run-time re-optimisation Memory and size needs the runtime installed (or bundled) small, self-contained binary; lower memory Limits none no dynamic code loading; reflection-heavy code must be trimming-safe Tiered compilation (the JIT's default): methods are first compiled quickly with few optimisations (tier 0). Hot methods are recompiled with full optimisations (tier 1). With Dynamic PGO (on by default since .NET 8), the JIT uses data gathered while the app runs, such as which types actually show up at a virtual call, to optimise better.
ReadyToRun is a middle ground: assemblies include precompiled code to start faster, and the JIT can still re-optimise hot paths.
When to choose Native AOT:
- serverless functions and containers where cold-start time and memory matter;
- command-line tools;
- environments where JIT isn't allowed (iOS).
Watch out: libraries that rely on reflection or runtime code generation (some serializers, older DI or ORM features) may not work. Use source generators instead, e.g.
System.Text.Jsonsource generation. - Link to this question
The GC frees only objects that are unreachable. A "leak" in .NET is usually objects that are still referenced by something long-lived, even though the app no longer needs them.
Common causes:
Cause Example Fix Event handlers a short-lived view subscribes to a long-lived service's event and never unsubscribes -=inDispose, or weak eventsStatic collections and caches a static Dictionarythat only growssize limits and expiry ( MemoryCachewithSizeLimit)Captured variables a lambda stored in a long-lived place captures a large object capture only what's needed Not disposing timers, streams, CancellationTokenSourcewith timersusing/DisposeWrong DI lifetime a scoped DbContextheld by a singleton, tracking more and more entitiesfix lifetimes (see the DI lifetimes question) Unmanaged memory native libraries, unreleased handles dispose, SafeHandleLarge Object Heap fragmentation many large temporary arrays ArrayPool<T>How to investigate:
- Watch memory over time with
dotnet-counters(GC heap size, Gen 2 size). - Take memory dumps with
dotnet-gcdumpordotnet-dumpat two points in time. - Compare them in Visual Studio, PerfView or JetBrains dotMemory: which types keep growing, and what is keeping them alive (the path to the GC root).
- Watch memory over time with
- Link to this question
Middleware components form a pipeline that every HTTP request passes through. Each one can do work, call the next one, then do more work as the response comes back, or short-circuit and respond immediately.
Diagram var app = builder.Build(); app.UseExceptionHandler("/error"); // first: catches errors from everything after it app.UseHttpsRedirection(); app.UseStaticFiles(); // short-circuits for files; later middleware doesn't run app.UseRouting(); app.UseAuthentication(); // who are you? app.UseAuthorization(); // are you allowed? app.MapControllers(); // Custom middleware app.Use(async (context, next) => { var sw = Stopwatch.StartNew(); await next(context); // run the rest of the pipeline logger.LogInformation("{Path} took {Ms} ms", context.Request.Path, sw.ElapsedMilliseconds); });Order matters because each middleware only sees what runs after it:
- the exception handler must come first to catch errors from the rest;
- authentication must come before authorization, or the user is always anonymous;
- CORS must run before the endpoints it protects;
- static files early, so file requests skip the rest.
Middleware vs filters: middleware runs for every request; MVC filters run only for controller actions and can see model binding and action results.
- Link to this question
ASP.NET Core has a built-in dependency injection (DI) container. You register services with a lifetime that controls how often a new instance is created:
Lifetime New instance Good for Transient every time it's requested lightweight, stateless services Scoped once per request (scope) DbContext, unit of work, per-request user infoSingleton once for the whole app caches, configuration, HttpClientfactories; must be thread-safebuilder.Services.AddTransient<IEmailFormatter, EmailFormatter>(); builder.Services.AddScoped<IOrderRepository, OrderRepository>(); builder.Services.AddSingleton<IClock, SystemClock>(); builder.Services.AddDbContext<ShopDb>(o => o.UseSqlServer(cs)); // scoped by default public class OrdersController(IOrderRepository repo) : ControllerBase // injected { [HttpGet("{id}")] public async Task<IActionResult> Get(int id) => Ok(await repo.FindAsync(id)); }The classic bug: a captive dependency. A singleton that depends on a scoped service keeps that one scoped instance forever:
public class PriceCache(ShopDb db) { ... } // ShopDb is scoped builder.Services.AddSingleton<PriceCache>(); // captures one DbContext for the app's lifeThat
DbContextis then shared across threads (it isn't thread-safe) and tracks ever more entities. In development, ASP.NET Core's scope validation throws an error for this. The fix: injectIServiceScopeFactoryinto the singleton and create a scope when needed.Rule: a service should only depend on services with the same or longer lifetime.
- Link to this question
Minimal APIs (.NET 6+) define HTTP endpoints directly with lambdas, with very little ceremony:
var app = builder.Build(); app.MapGet("/products/{id:int}", async (int id, ShopDb db) => await db.Products.FindAsync(id) is Product p ? Results.Ok(p) : Results.NotFound()); app.MapPost("/products", async (Product p, ShopDb db) => { db.Products.Add(p); await db.SaveChangesAsync(); return Results.Created($"/products/{p.Id}", p); }); app.Run();Controllers group actions in classes, with attributes for routing and filters:
[ApiController, Route("products")] public class ProductsController(ShopDb db) : ControllerBase { [HttpGet("{id:int}")] public async Task<ActionResult<Product>> Get(int id) => await db.Products.FindAsync(id) is Product p ? p : NotFound(); }Minimal APIs Controllers Boilerplate very little more Performance slightly faster, works with Native AOT good Organisation route groups ( MapGroup), extension methodsclasses and conventions Filters, model validation endpoint filters; validation needs a bit more setup rich MVC filters, automatic [ApiController]validationBest for microservices, small and medium APIs large APIs, teams used to MVC Both use the same routing, DI, middleware, authentication and OpenAPI support, and can be mixed in one app. Microsoft now recommends minimal APIs for new projects, but either is a valid answer if you can explain the trade-offs.
- Link to this question
Configuration is read from several providers, and later ones override earlier ones:
appsettings.jsonappsettings.{Environment}.json(for exampleappsettings.Production.json)- User secrets (development only, kept outside the project folder)
- Environment variables (
Email__SmtpHostmaps toEmail:SmtpHost) - Command-line arguments
Cloud providers like Azure Key Vault can be added too, for production secrets.
{ "Email": { "SmtpHost": "smtp.example.com", "Port": 587, "From": "shop@example.com" } }The options pattern binds a section to a typed class:
public class EmailOptions { public required string SmtpHost { get; init; } public int Port { get; init; } = 25; public required string From { get; init; } } builder.Services.AddOptions<EmailOptions>() .Bind(builder.Configuration.GetSection("Email")) .ValidateDataAnnotations() .ValidateOnStart(); // fail at startup, not at the first email public class EmailSender(IOptions<EmailOptions> options) { private readonly EmailOptions _opt = options.Value; }Interface Lifetime Picks up changes to the file? IOptions<T>singleton no IOptionsSnapshot<T>scoped (per request) yes IOptionsMonitor<T>singleton yes, with change notifications Never put secrets in
appsettings.jsonin source control. Use user secrets locally and environment variables or a vault in production. - Link to this question
- Authentication: who are you? Reads a cookie or token and builds a
ClaimsPrincipal(HttpContext.User). - Authorization: what are you allowed to do? Checks that user against rules.
A typical API uses JWT bearer tokens issued by an identity provider (Microsoft Entra ID, Auth0, Keycloak, Duende IdentityServer):
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(o => { o.Authority = "https://login.example.com/"; o.Audience = "shop-api"; }); builder.Services.AddAuthorization(o => { o.AddPolicy("CanRefund", p => p.RequireRole("Support").RequireClaim("refunds", "true")); }); app.UseAuthentication(); app.UseAuthorization(); app.MapPost("/orders/{id}/refund", RefundOrder).RequireAuthorization("CanRefund");Approach Example Any signed-in user [Authorize]or.RequireAuthorization()Role-based [Authorize(Roles = "Admin")]Policy/claims-based [Authorize(Policy = "CanRefund")]Resource-based IAuthorizationService.AuthorizeAsync(User, order, "OwnsOrder")checks against a specific object, like "only the owner can edit this order"Common choices:
- Cookies for server-rendered web apps in the browser.
- JWT bearer tokens for APIs used by SPAs, mobile apps and other services.
- ASP.NET Core Identity when the app stores its own users and passwords.
Follow-up: the API must still check that a user can access this specific record (resource-based authorization). Checking only "is logged in" leads to insecure direct object reference bugs.
- Authentication: who are you? Reads a cookie or token and builds a
- Link to this question
Implement a hosted service, usually by inheriting from
BackgroundService. The host starts it with the app and stops it gracefully on shutdown.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)) { try { using var scope = scopes.CreateScope(); // hosted services are singletons var db = scope.ServiceProvider.GetRequiredService<ShopDb>(); await PublishPendingAsync(db, stoppingToken); } catch (Exception ex) when (ex is not OperationCanceledException) { log.LogError(ex, "Outbox publish failed"); // keep the loop alive } } } } builder.Services.AddHostedService<OutboxPublisher>();Key points:
- Hosted services are singletons, so create a scope to use scoped services like
DbContext. - Catch exceptions inside the loop. Since .NET 6, an unhandled exception in a
BackgroundServicestops the whole host by default. - Honour the
stoppingTokenso shutdown is fast. - For an in-process work queue (requests hand work to the background), use
Channel<T>.
Limits: work in memory is lost if the app restarts, and with several instances each one runs the job. For durable or scheduled jobs, use a queue (Azure Service Bus, RabbitMQ, Kafka) or a library like Hangfire or Quartz.NET. A separate Worker Service project uses the same model without a web server.
- Hosted services are singletons, so create a scope to use scoped services like
- Link to this question
A
DbContexttracks the entities it loads: it remembers their original values, so when you callSaveChangesit can work out whichINSERT,UPDATEandDELETEstatements to send.var order = await db.Orders.FirstAsync(o => o.Id == id); // tracked order.Status = OrderStatus.Shipped; await db.SaveChangesAsync(); // UPDATE Orders SET Status = ... WHERE Id = ...Entity states:
Added,Unchanged,Modified,Deleted,Detached.Tracking costs memory and CPU. For read-only queries, turn it off:
var list = await db.Products .AsNoTracking() .Where(p => p.IsActive) .ToListAsync();Use tracking Use AsNoTrackingyou'll modify and save the entities read-only pages, reports, API GET endpoints large result sets Even better for reads: project into a DTO with
Select. EF fetches only the needed columns, and projections aren't tracked.var rows = await db.Products .Where(p => p.IsActive) .Select(p => new ProductRow(p.Id, p.Name, p.Price)) .ToListAsync();Related points:
- A
DbContextis a unit of work: short-lived (one per request) and not thread-safe. ExecuteUpdateAsync/ExecuteDeleteAsync(EF Core 7+) run bulk updates directly in SQL without loading or tracking entities.
- A
- Link to this question
N+1 happens when you run one query to load a list, then one more query for each item to load related data. For 100 orders, that's 101 round trips to the database.
// With lazy loading on: 1 query for orders + 1 query per order for its customer var orders = await db.Orders.ToListAsync(); foreach (var o in orders) Console.WriteLine(o.Customer.Name); // each access triggers a queryFixes:
// 1. Eager loading: one query with a JOIN var orders = await db.Orders .Include(o => o.Customer) .Include(o => o.Lines).ThenInclude(l => l.Product) .ToListAsync(); // 2. Projection: load exactly the columns you need (often the best option) var rows = await db.Orders .Select(o => new { o.Id, CustomerName = o.Customer.Name, LineCount = o.Lines.Count }) .ToListAsync(); // 3. Split queries: avoid one huge JOIN when including several collections var orders2 = await db.Orders .Include(o => o.Lines) .Include(o => o.Payments) .AsSplitQuery() .ToListAsync();Cartesian explosion: including two collections in one query multiplies rows (10 lines × 10 payments = 100 rows per order).
AsSplitQueryruns one query per collection instead.How to spot N+1: turn on EF Core logging (
LogTo(Console.WriteLine)) or use a profiler (Application Insights, MiniProfiler) and look for the same query repeated many times per request. Many teams leave lazy loading off for exactly this reason. - Link to this question
Migrations keep the database schema in sync with your entity classes. Each migration is a C# file with
Up(apply) andDown(undo) methods, created by comparing your model with the last snapshot.dotnet ef migrations add AddOrderStatus # generates a migration from model changes dotnet ef database update # applies pending migrations to the local database dotnet ef migrations script --idempotent -o migrate.sql # SQL script for productionpublic partial class AddOrderStatus : Migration { protected override void Up(MigrationBuilder mb) => mb.AddColumn<int>("Status", "Orders", nullable: false, defaultValue: 0); protected override void Down(MigrationBuilder mb) => mb.DropColumn("Status", "Orders"); }Applying them safely in production:
Approach Notes SQL scripts (idempotent) reviewed and run by the pipeline or a DBA most control; recommended for important databases Migration bundles ( dotnet ef migrations bundle)a single executable the deployment pipeline runs db.Database.Migrate()at app startupsimple, but risky with several instances starting at once, and the app needs schema-change permissions Good practices:
- Review the generated migration: renames can come out as drop + add, which loses data.
- Keep changes backward compatible during rolling deploys: add the new column, deploy code that uses it, then remove the old column in a later release.
- Commit migrations to source control along with the code that needs them.
- Link to this question
EF Core Dapper What it is full ORM: LINQ queries, change tracking, migrations, relationships "micro-ORM": you write SQL, it maps rows to objects Productivity high for CRUD and domain models more SQL to write Control over SQL generated (you can inspect or override it) complete Performance good, especially with AsNoTracking, projections and compiled queriesvery fast, very little overhead Schema changes migrations built in use another tool (DbUp, Flyway, SQL scripts) // Dapper using var conn = new SqlConnection(cs); var top = await conn.QueryAsync<CustomerTotal>(""" SELECT TOP (10) c.Name, SUM(o.Total) AS Total FROM Customers c JOIN Orders o ON o.CustomerId = c.Id WHERE o.CreatedAt >= @since GROUP BY c.Name ORDER BY Total DESC """, new { since }); // EF Core can also run raw SQL when needed var rows = await db.Database .SqlQuery<CustomerTotal>($"SELECT Name, Total FROM dbo.TopCustomers({since})") .ToListAsync();Interpolated values in
SqlQueryand Dapper's@sinceparameters are sent as parameters, not pasted into the SQL text, so they're safe from SQL injection.A common, pragmatic answer: use both. EF Core for writes and everyday queries, where change tracking and migrations save time. Dapper or raw SQL for complex reports, hand-tuned hot queries, or databases you don't own.