This page is for anyone facing a C# round, from a first developer job to a senior role. Most C# interviews open with value and reference types, boxing and the choice between class, struct and record, then move to interfaces, delegates and events. The middle of the round is usually LINQ and async/await, where deferred execution and deadlocks trip people up. Stronger rounds add IDisposable, garbage collection, generics and nullable reference types, plus a production story and a judgement call. Each question shows what the interviewer is really checking, the shape of a good answer and a short answer to say out loud. Practise them, then swap in your own stories.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Value types: structs, enums and the built-in numbers and bool; the variable holds the data itself.
Reference types: classes, strings, arrays, delegates; the variable holds a reference to an object on the heap.
Assignment: copying a value type copies the data; copying a reference type copies the reference, so both point at one object.
"A value type, like int, bool, a struct or an enum, holds its data directly in the variable. A reference type, like a class, an array or a string, holds a reference to an object that lives on the managed heap. The difference shows up on assignment. If I copy a struct into another variable and change the copy, the original stays the same, because I got a full copy of the data. If I copy a class reference and change a property through the copy, the original sees the change, because both variables point at the same object. The same rule applies when I pass them to a method. One thing I'd correct is the idea that value types always live on the stack. A local int usually does, but an int field inside a class lives on the heap with that object. Where it lives depends on where it's declared, not just on its type."
struct Point { public int X; }
class Box { public int X; }
var p1 = new Point { X = 1 };
var p2 = p1; // full copy of the data
p2.X = 99; // p1.X is still 1
var b1 = new Box { X = 1 };
var b2 = b1; // copy of the reference
b2.X = 99; // b1.X is now 99 too
Saying value types always live on the stack and reference types on the heap, with no mention that a struct field inside a class lives on the heap.
Boxing: a value type is converted to object or an interface; the runtime allocates a heap object and copies the value in.
Unboxing: an explicit cast back that needs the exact type, or it throws InvalidCastException.
Hidden cases: non-generic collections like ArrayList, storing a struct in an interface variable, APIs that take object.
"Boxing is what happens when a value type gets treated as an object or as an interface it implements. The runtime allocates a small object on the heap and copies the value into it. Unboxing is the explicit cast back, and it's strict: if I boxed an int, I have to unbox it as an int. Casting straight to long throws an InvalidCastException, even though int to long is normally fine. The cost is an allocation each time, plus garbage collection work later, and the boxed copy is separate from the original, so changing one doesn't change the other. It sneaks in through old non-generic collections like ArrayList or Hashtable, through methods that take a parameter of type object, and when I store a struct in an interface-typed variable. The main fix is generics: a List of int stores the ints directly with no boxing at all."
int n = 42;
object boxed = n; // box: new heap object holding 42
int back = (int)boxed; // unbox: exact type needed
// long bad = (long)boxed; // throws InvalidCastException
long ok = (long)(int)boxed; // unbox first, then convert
var old = new System.Collections.ArrayList();
old.Add(n); // boxes on every Add
var list = new List<int> { n }; // no boxing
Thinking unboxing can convert to any compatible numeric type, or not knowing that boxing allocates.
Class: reference type, identity equality by default, inheritance; the default for objects with behaviour and a lifetime.
Struct: value type copied on assignment, no inheritance; for small, short-lived values, ideally immutable.
Record: compiler-generated value-based equality, ToString and with-expressions; a record class by default, a record struct if asked.
"My default is a class. It's a reference type, it supports inheritance, and two instances are equal only if they're the same object, which suits things like services or entities with an identity. I'd reach for a struct when the type is small, represents a single value, like a coordinate or a money amount, and is created in large numbers where avoiding heap allocations helps. I'd keep it immutable, because a mutable struct gets copied silently and people end up changing a copy by mistake. A record is for data where two instances with the same values should count as equal, like a DTO or a message. The compiler writes Equals, GetHashCode and ToString for me, and I get with-expressions to make a changed copy. A plain record is still a reference type. If I want value semantics and value equality together, I can write record struct."
public record Money(decimal Amount, string Currency);
var a = new Money(10m, "EUR");
var b = new Money(10m, "EUR");
bool same = a == b; // true: value-based equality
bool sameObject = ReferenceEquals(a, b); // false: two objects
var c = a with { Amount = 20m }; // copy with one change
Saying structs are always faster, or that records are value types, without mentioning copying cost or that a plain record is a class.
Interface: a contract; a class can implement many; no instance fields or constructors, but default and static members are allowed.
Abstract class: single inheritance, but can hold state, constructors and protected members, and share real implementation.
How I choose: a capability many unrelated types can have goes in an interface; a family sharing state and setup code gets a base class.
"An interface describes what a type can do, and a class can implement as many as it likes. Since C# 8 an interface can carry a default method body, which mostly helps library authors add a method without breaking every existing implementer. But an interface still can't have instance fields or a constructor, so it can't hold shared state. An abstract class can. It can have fields, a constructor that enforces setup, protected helpers for subclasses, and a mix of abstract and concrete members. The catch is that a class can only inherit from one base class. So I use an interface for a capability that unrelated types can share, like IComparable or a repository contract that I want to mock in tests. I use an abstract class when I have a real family of types that share state and setup logic, like a base payment processor that handles logging and retries and lets each subclass implement one step."
Saying interfaces can't contain any implementation in modern C#, or choosing an abstract class just to share one helper method.
override: replaces a virtual or abstract method; the call is decided at runtime by the actual object.
new: hides a base method with an unrelated one; the call is decided at compile time by the variable's type.
Advice: new is rarely what you want; the compiler warns when you hide a method by accident.
"With override, the base method has to be marked virtual or abstract, and my derived method replaces it. The call is dispatched at runtime, so if I hold a Dog in a variable of type Animal and call Speak, I get the Dog's version. With new, I'm not replacing anything. I'm declaring a separate method that happens to have the same name, and it hides the base one. Which one runs then depends on the variable's declared type. Call it through an Animal variable and you get the Animal version, even though the object is a Dog. That's why new is almost never what you want. It shows up when a base class I don't control adds a method with the same name as one of mine. If I forget both keywords, the compiler treats it as hiding and gives me a warning, which I'd treat as a bug to fix. I can also mark an override as sealed to stop further overriding."
class Animal
{
public virtual string Speak() => "...";
public string Kind() => "Animal";
}
class Dog : Animal
{
public override string Speak() => "Woof";
public new string Kind() => "Dog";
}
Animal a = new Dog();
Console.WriteLine(a.Speak()); // Woof (runtime type decides)
Console.WriteLine(a.Kind()); // Animal (variable type decides)
Console.WriteLine(((Dog)a).Kind()); // Dog
Saying new and override do the same thing, or not being able to say which version runs through a base-type variable.
Shape: a static method in a static, non-generic class, with this before the first parameter.
Resolution: the compiler rewrites the call to a static call; a real instance method with the same signature always wins.
Limits: no access to private or protected members, no new state, and the namespace must be in scope.
"An extension method lets me call a static method as if it were an instance method on a type I don't own. I write it as a static method inside a static class, and put the keyword this before the first parameter. So if I write IsBlank on string, I can call name.IsBlank(). Under the hood it's just syntax. The compiler rewrites that call to StringExtensions.IsBlank(name). That explains the limits. It can't reach private or protected members, it can't add fields or state, and if the type already has an instance method with the same signature, the instance method wins. The namespace also has to be imported, or the method won't show up. Because it's really a static call, it can even be called on a null reference without an exception on the call itself. LINQ is the biggest example: Where and Select are extension methods on IEnumerable."
public static class StringExtensions
{
public static bool IsBlank(this string? value) =>
string.IsNullOrWhiteSpace(value);
}
// usage
string? name = null;
bool blank = name.IsBlank(); // true, no exception
Thinking an extension method actually changes the original type or can reach its private members.
Delegate: a type that describes a method signature; an instance holds a reference to a matching method.
Func and Action: built-in generic delegates; Func returns a value (last type argument), Action returns void.
Multicast: delegates can combine several methods with +=; all run in order, and a return value comes from the last one.
"A delegate is a type-safe reference to a method. I declare a delegate type with a signature, and any method with a matching signature can be stored in a variable of that type and called later. That's how I pass behaviour around: a callback, a rule to apply, a sort comparison. In practice I rarely declare my own delegate types now, because the framework gives me generic ones. Action is for methods that return void, with up to sixteen parameters. Func is for methods that return something, and the last type argument is the return type, so a Func of int and bool takes an int and returns a bool. Lambdas are just a short way to create a delegate instance. Delegates are also multicast: I can combine several methods with plus-equals, and invoking it calls each in order. If they return values, I only get the last one, which is why multicast is really used for void methods like event handlers."
Func<int, bool> isEven = n => n % 2 == 0;
Action<string> log = msg => Console.WriteLine(msg);
log += msg => File.AppendAllText("app.log", msg + "\n");
log("started"); // runs both, in order
bool r = isEven(4); // true
Describing a delegate as a function pointer with no type safety, or mixing up which of Func and Action returns a value.
Encapsulation: outside code can only subscribe and unsubscribe; only the owning class can raise or reset the event.
Raising safely: use the null-conditional invoke so a missing subscriber list isn't a crash.
Leaks: the publisher holds a reference to each subscriber; if the publisher lives longer, subscribers never get collected until they unsubscribe.
"An event is a delegate with guard rails. If I expose a public delegate field, any caller can invoke it, or assign it with a single equals and wipe out everyone else's handlers. With the event keyword, outside code can only use plus-equals and minus-equals. Only the class that declares the event can raise it or clear it. To raise it, I use the question-mark dot Invoke pattern, which reads the delegate once, so it won't crash if nobody subscribed. The leak comes from the direction of the reference. When a subscriber adds a handler, the publisher's delegate now holds a reference to the subscriber. If the publisher is long-lived, say a static class or a singleton, and the subscriber is something short-lived like a window or a per-request object, the subscriber can never be collected while it's still subscribed. The fix is to unsubscribe, usually in Dispose, or to design the lifetimes so the publisher doesn't outlive its subscribers."
public class PriceFeed
{
public event EventHandler<decimal>? PriceChanged;
public void Publish(decimal price) =>
PriceChanged?.Invoke(this, price); // only this class can raise it
}
// outside code:
feed.PriceChanged += OnPrice; // allowed
feed.PriceChanged -= OnPrice; // allowed
// feed.PriceChanged = null; // compile error
// feed.PriceChanged(this, 1m); // compile error
Not knowing the event keyword restricts outside callers, or saying the subscriber holds the reference that causes the leak.
Deferred: Where, Select and OrderBy build a query; nothing runs until something enumerates it.
Immediate: ToList, ToArray, Count, First, Sum and similar run the query right away.
Consequences: later changes to the source show up, and each enumeration runs the whole query again.
"Most LINQ operators like Where and Select don't do any work when you call them. They return an object that describes the query, and the real work happens when something enumerates it, like a foreach, or an operator such as ToList, Count or First. So in this example I build a query for numbers greater than one, then add another number to the list, then call Count. I get three, not two, because the filter runs at Count time and sees the new item. The other side effect is repeated work. If I enumerate the same query twice, it runs twice. With an in-memory list that's just wasted CPU, but if the source is a database query or a method with side effects, it means two round trips or doing the side effect twice. So when I need a stable snapshot, or I'll use the result more than once, I call ToList once and reuse that."
var numbers = new List<int> { 1, 2, 3 };
var big = numbers.Where(n => n > 1); // nothing runs yet
numbers.Add(4);
Console.WriteLine(big.Count()); // 3 -> runs now, sees the 4
var snapshot = numbers.Where(n => n > 1).ToList(); // runs once
numbers.Add(5);
Console.WriteLine(snapshot.Count); // still 3
Thinking the filter runs on the line where Where is called, or calling ToList everywhere without knowing why.
IEnumerable: LINQ to Objects; lambdas are compiled delegates that run in memory on items already loaded.
IQueryable: lambdas become expression trees that a provider translates, for example into SQL, so filtering runs in the database.
Trap: once a query is treated as IEnumerable, every later operator runs in memory after all rows are fetched.
"Both let me write the same Where and Select, but they run in very different places. With IEnumerable, the lambdas are compiled delegates, and the filtering happens in my process on objects that are already loaded. With IQueryable, the lambdas are kept as expression trees, and the provider, such as an ORM, translates the whole chain into SQL, so the database does the filtering, sorting and paging. The bug I've seen is a repository method that returns IEnumerable, or a call to AsEnumerable in the middle of a chain. Everything before that point goes to the database, but the Where added afterwards runs in memory, after every row has come back. It still gives the right answer, which is why nobody notices until the table gets big. The other catch with IQueryable is that the provider can only translate what it understands, so a call to my own C# method inside the lambda may fail to translate."
Saying they're interchangeable, or not realising an IEnumerable filter on a database query runs after all rows are loaded.
Group: GroupBy on the customer; each group has a Key and its orders.
Aggregate: project each group to the name, the sum of amounts and maybe a count.
Rank: OrderByDescending on the total, Take three, then materialise with ToList.
"I'd group the orders by customer, so each group has the customer name as its key and all their orders inside. Then I project each group into a small result with the key, the sum of the amounts and the order count. After that I sort those results by total, highest first, take three, and call ToList so the query runs once and I have a fixed result. I'd store the amount as decimal, not double, because it's money and I don't want rounding errors in binary floating point. If the orders came from a database through an ORM, the same query would usually translate into a GROUP BY with an ORDER BY and a row limit. One edge case I'd mention is ties: if two customers have the same total at third place, Take just cuts at three, so if the business cares, I'd add a second sort key like the name to make the result stable."
public record Order(string Customer, decimal Amount);
var topThree = orders
.GroupBy(o => o.Customer)
.Select(g => new
{
Customer = g.Key,
Total = g.Sum(o => o.Amount),
Orders = g.Count()
})
.OrderByDescending(x => x.Total)
.ThenBy(x => x.Customer)
.Take(3)
.ToList();
Sorting the orders before grouping, or looping and building a dictionary by hand when the interviewer asked for LINQ.
Iterator: a method with yield return is compiled into a state machine that produces one item per MoveNext.
Lazy: no code in the body runs until enumeration starts, and it pauses after each item.
Surprise: argument checks in the body run late; split into a normal method that checks and a local iterator function.
"When a method uses yield return, the compiler turns it into a state machine that implements IEnumerable. Calling the method doesn't run the body. It just returns the enumerator. Each time the caller asks for the next item, the body runs until the next yield return, hands back that value and pauses there. That's great for large or endless sequences, because I never build the whole list in memory, and it's how LINQ operators themselves are written. The surprise is that argument validation is lazy too. If I check for a null argument at the top of an iterator method, the exception doesn't come when the method is called. It comes later, when someone first enumerates, which could be far away in the code and confusing to debug. The fix is to have a normal public method that validates and then returns a call to a private or local iterator function that does the yielding."
public static IEnumerable<string> ReadLines(string path)
{
ArgumentNullException.ThrowIfNull(path); // runs on the call
return Iterate();
IEnumerable<string> Iterate()
{
using var reader = new StreamReader(path);
string? line;
while ((line = reader.ReadLine()) != null)
yield return line; // pauses here until the next item is asked for
}
}
Thinking the method body runs fully when called, or that yield return builds a list behind the scenes.
Before the await: the method runs synchronously on the caller's thread.
At an incomplete await: the method returns an unfinished Task and the thread goes back to do other work.
Resume: when the awaited work completes, the rest of the method runs as a continuation, on the captured context or a thread-pool thread.
"An async method starts running synchronously on the calling thread, just like a normal method. When it reaches an await on a task that isn't finished yet, say a database call or an HTTP request, it doesn't block. It registers the rest of the method as a continuation and returns an unfinished Task to its caller, so the thread is free to do other work. When the I/O completes, the continuation is scheduled. In a UI app it resumes on the UI thread, because the synchronization context was captured. In an ASP.NET Core app or a console app, it resumes on a thread-pool thread, often a different one. So async by itself doesn't create a thread. For I/O there's usually no thread waiting at all during the wait. The compiler makes this work by rewriting the method into a state machine. If I actually want CPU-heavy work off the current thread, that's Task.Run, which is a separate decision."
Saying async runs the method on a background thread, or that await blocks the thread until the result arrives.
Setup: a thread with a synchronization context, like a UI thread, blocks on a task with .Result.
Deadlock: the async method's continuation needs that same thread to resume, but the thread is blocked waiting for it.
Fixes: async all the way up; ConfigureAwait(false) in library code; avoid blocking even where no context exists, to prevent thread-pool starvation.
"The classic case is a desktop app. A button handler on the UI thread calls an async method and then blocks on .Result. Inside, the method awaits something, and by default it captures the UI synchronization context, so when the await finishes it wants to continue on the UI thread. But the UI thread is stuck in .Result waiting for the task to finish. Each is waiting on the other, so the app freezes. The real fix is to go async all the way: make the handler async and await the call. In library code I'd also use ConfigureAwait(false), so continuations don't need to come back to the caller's context. ASP.NET Core doesn't have a synchronization context, so this exact deadlock doesn't happen there, but blocking is still bad. Each blocked request holds a thread-pool thread, and under load the pool runs out, which shows up as the whole service slowing to a crawl."
// WinForms: this freezes the window forever
private void LoadButton_Click(object sender, EventArgs e)
{
string text = LoadAsync().Result; // UI thread blocks here
label1.Text = text;
}
private async Task<string> LoadAsync()
{
await Task.Delay(500); // wants to resume on the UI thread
return "done";
}
// Fix: async all the way
private async void LoadButtonFixed_Click(object sender, EventArgs e)
{
label1.Text = await LoadAsync();
}
Saying .Result is fine because the task will finish eventually, or wrapping everything in Task.Run without understanding the cause.
No Task: the caller gets nothing to await, so it can't know when the method finished.
Exceptions: an exception can't be caught by the caller; it's raised on the synchronization context and can crash the process.
Allowed: event handlers, because their delegate signature returns void; everywhere else return Task.
"An async void method gives the caller nothing to hold on to. With async Task, the caller gets a Task it can await, so it knows when the work finished and any exception comes back through that Task. With async void, the caller fires it and moves on. It can't wait for it, and if the method throws, the exception can't be caught with a try-catch around the call. It gets raised on the synchronization context, or on the thread pool if there isn't one, and in many apps that crashes the process. It also makes testing harder, because a test can't await it. The one place it's acceptable is an event handler, like a button click, because the event's delegate type returns void and I can't change it. Even then I put a try-catch inside the handler so nothing escapes. Everywhere else, an async method returns Task or Task of T."
Saying async void is fine for fire-and-forget work, or not knowing that its exceptions bypass the caller.
Concurrency: start the calls as tasks and await them together with Task.WhenAll.
Limit: a SemaphoreSlim with 10 slots; take a slot before each call, release it in finally.
Failures: WhenAll throws the first exception when awaited; decide whether one failure fails all or gets retried and logged.
"Since this is I/O, I don't want threads, I want lots of calls in flight with a cap. I'd use a SemaphoreSlim set to 10. For each ID I start an async lambda that waits for a slot with WaitAsync, makes the call, and releases the slot in a finally block, so a failed call never leaks a slot. Then I await Task.WhenAll on all the tasks, which gives me the results in the same order as the input. On errors: when you await WhenAll and something failed, you get the first exception thrown, and the task's Exception property holds all of them. If one failure shouldn't sink the batch, I'd catch inside the lambda and return a result object that says success or failure, and add a retry with backoff for transient errors. On newer .NET, Parallel.ForEachAsync with MaxDegreeOfParallelism set to 10 gives the same cap with less code, though I collect the results myself."
async Task<string[]> FetchAllAsync(HttpClient http, IEnumerable<int> ids,
CancellationToken ct)
{
using var gate = new SemaphoreSlim(10);
var tasks = ids.Select(async id =>
{
await gate.WaitAsync(ct);
try
{
return await http.GetStringAsync("items/" + id, ct);
}
finally
{
gate.Release();
}
});
return await Task.WhenAll(tasks);
}
Starting all 500 calls at once with no limit, or using .Result inside a Parallel.ForEach and blocking threads.
Purpose: release resources like file handles, connections and sockets as soon as you're done, not whenever the GC runs.
using: calls Dispose in a finally block, so cleanup happens even if an exception is thrown.
Forms: the block form, the using declaration that disposes at the end of the scope, and await using for IAsyncDisposable.
"The garbage collector frees memory, but it runs when it decides to, and it doesn't call Dispose for me. Some objects hold things that are scarce, like a file handle, a database connection or a socket, and I want those released as soon as I'm done. IDisposable gives those types a Dispose method that does the cleanup. A using statement guarantees Dispose is called. The compiler turns it into a try-finally, so even if an exception is thrown inside the block, Dispose still runs. Since C# 8 I can also write a using declaration, just using var reader equals something, and it gets disposed at the end of the enclosing scope, which saves a level of nesting. For types that need to clean up asynchronously, like some streams, there's IAsyncDisposable and await using. The rule I follow is simple: if I create an object that implements IDisposable and I own it, I dispose it."
using (var reader = new StreamReader("data.csv"))
{
Console.WriteLine(reader.ReadLine());
} // Dispose runs here, even after an exception
// C# 8 using declaration: disposed at the end of the method
using var writer = new StreamWriter("out.txt");
writer.WriteLine("hello");
Saying the garbage collector calls Dispose automatically, or that using is only a scoping convenience.
Reachability: the GC marks everything reachable from roots such as locals, statics and GC handles; the rest is garbage.
Generations: new objects start in gen 0, survivors move to gen 1 then gen 2; young collections are frequent and cheap, gen 2 ones are expensive.
Large object heap: big objects go straight to a separate heap collected with gen 2 and not compacted by default, so churn there hurts.
"The .NET GC is a tracing collector. It starts from roots, like local variables in active methods, static fields and handles, marks everything reachable, and reclaims the rest, cycles included. It splits the managed heap into generations because most objects die young. New objects go into gen 0, which is collected often and quickly because it's mostly garbage. Anything that survives moves to gen 1, and then to gen 2, which holds long-lived objects and is collected much less often but at a higher cost. The large object heap is for big allocations, roughly 85 thousand bytes and up, typically large arrays. Those are logically part of gen 2, only cleaned up in a full collection, and by default they aren't compacted, so allocating and dropping lots of large buffers causes fragmentation and expensive gen 2 collections. In hot paths I'd reuse big buffers, for example with ArrayPool, rather than allocating fresh ones each time."
Saying .NET uses reference counting, or that objects are freed the moment they go out of scope.
Finalizer: a safety net that runs on the finalizer thread at some unknown time if Dispose was never called; it delays collection.
Dispose(bool): true from Dispose frees managed and unmanaged resources; false from the finalizer frees only unmanaged ones.
SuppressFinalize: once disposed, the finalizer isn't needed; today, wrap raw handles in a SafeHandle instead of writing one.
"A finalizer is a safety net for unmanaged resources, like a raw native handle, in case someone forgets to call Dispose. It has a real cost. An object with a finalizer can't be freed on its first collection. It goes onto a queue, the finalizer thread runs it later, and only then can the memory be reclaimed, so it survives longer and gets promoted. The pattern has a public Dispose that calls a protected Dispose with true and then GC.SuppressFinalize, so the GC knows the finalizer is no longer needed. The finalizer calls Dispose with false. The flag matters because during finalization, other managed objects I reference may already have been finalized, so on that path I only release the unmanaged resource. I'd add that in modern code I almost never write a finalizer. I wrap the native handle in a SafeHandle subclass, which already has reliable finalization, and my class just implements plain IDisposable."
public class NativeBuffer : IDisposable
{
private IntPtr _ptr = Marshal.AllocHGlobal(1024);
private bool _disposed;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // no need to finalize any more
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// dispose other managed IDisposable fields here
}
Marshal.FreeHGlobal(_ptr); // unmanaged: freed on both paths
_disposed = true;
}
~NativeBuffer() => Dispose(false);
}
Adding a finalizer to every disposable class, or saying the finalizer runs as soon as the object goes out of scope.
Cause: each disposed client closes its connections, which linger in a waiting state, so busy traffic runs out of sockets.
Fix: reuse long-lived clients so connections are pooled, with a pooled-connection lifetime so DNS changes are picked up.
Verify: reproduce with a load test, watch connection counts, then confirm the errors stop.
"This is a known trap. The code looks right, because HttpClient is IDisposable and we're disposing it. But each client has its own connection pool, and disposing it closes those connections. Closed TCP connections stay in a waiting state for a while at the operating system level, so under heavy traffic the machine runs out of available sockets and new calls fail. HttpClient is designed to be created once and reused, and it's safe to use from many threads at the same time. So I'd change it to a long-lived client, either a shared instance or one handed out by the framework's client factory, if the app already uses it. With a single long-lived instance there's one catch: it can hold on to old DNS results, so I'd set a pooled-connection lifetime on the handler so connections are refreshed now and then. Then I'd run a load test and compare socket counts before and after."
Saying every IDisposable must be disposed right after each use no matter what, or blaming the remote API without checking our side.
Why a constraint: without one, T is only known to be an object, so you can't compare two T values.
The constraint: where T : IComparable of T lets you call CompareTo without boxing value types.
Others: class, struct, new(), a base class, notnull, unmanaged; generics keep their type at runtime.
"Inside a generic method, the compiler only knows T is some object, so I can't compare two T values with greater-than. I add the constraint where T implements IComparable of T, and then I can call CompareTo. I take an enumerator, throw if the sequence is empty, then walk it and keep the best so far. Using the generic IComparable of T rather than the old non-generic one matters for value types: an int compares directly, without boxing. Other constraints I use are class and struct to require a reference or value type, new() when I need to create a T with a parameterless constructor, a base class or interface, and notnull or unmanaged for more specialised cases. One thing that's different from Java: C# generics aren't erased. The type argument exists at runtime, so typeof(T) works, and the runtime generates specialised code for each value type, which is why a List of int doesn't box."
public static T Largest<T>(IEnumerable<T> items) where T : IComparable<T>
{
using var e = items.GetEnumerator();
if (!e.MoveNext())
throw new InvalidOperationException("Sequence is empty");
T best = e.Current;
while (e.MoveNext())
{
if (e.Current.CompareTo(best) > 0)
best = e.Current;
}
return best;
}
int top = Largest(new[] { 3, 9, 4 }); // 9
Comparing T values with greater-than and not seeing why it won't compile, or saying C# erases generic types like Java.
Covariance (out): a type that only hands T out, like IEnumerable, can be treated as a more general type.
Contravariance (in): a type that only takes T in, like Action or IComparer, can accept a more specific type.
Invariance: List both reads and writes T, so allowing the conversion would let you add the wrong type; arrays show the danger.
"It comes down to generic variance. IEnumerable is declared with out T, which means it only ever gives T values out. If I can read strings from something, it's safe to treat it as a source of objects, so the conversion is allowed. That's covariance. Contravariance is the reverse, with the in keyword. An Action of object can be used where an Action of string is expected, because anything that can handle any object can handle a string. List of T is invariant because it both reads and writes. If I could treat a List of string as a List of object, I could then add an integer to it, and the list would be broken. Arrays actually allow that conversion for historical reasons, and it's only caught at runtime with an ArrayTypeMismatchException. Two more rules: variance only works on interfaces and delegates, and only for reference types, so IEnumerable of int doesn't convert to IEnumerable of object."
IEnumerable<string> names = new List<string> { "a" };
IEnumerable<object> objs = names; // OK: covariant (out T)
Action<object> printAny = o => Console.WriteLine(o);
Action<string> printText = printAny; // OK: contravariant (in T)
// List<object> bad = new List<string>(); // compile error: invariant
object[] arr = new string[1]; // allowed for arrays...
arr[0] = 42; // ...ArrayTypeMismatchException
Saying the List conversion is blocked by an arbitrary compiler rule, with no example of what would go wrong.
What it is: with the feature enabled, string means 'should not be null' and string? means 'may be null'; the compiler warns on misuse.
Flow analysis: after a null check the compiler knows a value is safe; the ! operator silences a warning.
Limits: warnings only, no runtime change; data from outside, reflection or old libraries can still be null.
"Before C# 8, every reference type could be null and the compiler never said a word. With nullable reference types enabled, usually with the Nullable setting in the project file, a plain string means I intend it to never be null, and string with a question mark means null is allowed. The compiler then tracks the flow. If I dereference a string question mark without checking, I get a warning. If I assign null to a plain string, I get a warning. After an if-not-null check, it knows the value is safe. The exclamation mark, the null-forgiving operator, tells the compiler to trust me, and I'd use it sparingly. What it doesn't do is change anything at runtime. It's annotations and warnings only. JSON deserialisation, reflection or a library without annotations can still hand me a null, so public methods still need guard clauses like ArgumentNullException.ThrowIfNull. It's also separate from Nullable of T for value types, which is a real runtime struct."
#nullable enable
public class Greeter
{
public string Greet(string? name)
{
// return "Hi " + name.Length; // warning: name may be null
if (name is null) return "Hi there";
return "Hi " + name; // compiler knows it is not null here
}
public void Save(string path)
{
ArgumentNullException.ThrowIfNull(path); // runtime guard still needed
}
}
Saying nullable reference types stop null reference exceptions at runtime, or mixing them up with Nullable of T.
Scope: enable it per project or per file with the nullable directive, starting with new code and leaf libraries.
Order: annotate shared models and public APIs first, because fixes there remove warnings everywhere downstream.
Guard rails: no new warnings in touched files, treat nullable warnings as errors once a project is clean, avoid blanket use of !.
"I wouldn't flip it on for the whole solution and ask people to fix thousands of warnings in one go. The PR would be huge, and people would silence warnings with the exclamation mark just to get it merged, which defeats the point. I'd do it gradually. First, turn it on for all new files and for small leaf projects that nothing else depends on, so everyone gets used to it. Then work on the shared layers, like the domain models and public service interfaces, because once those say clearly what can be null, a lot of warnings in the code that uses them either vanish or become real findings. For the rest, the rule would be: when you touch a file, enable nullable in it and fix its warnings. Once a project is clean, set nullable warnings as errors there so it stays clean. I'd track the warning count on each build so progress is visible, and I'd expect it to surface some real null bugs along the way."
Turning it on everywhere and silencing warnings with the null-forgiving operator, or deciding it isn't worth it without trying.
Bare throw: rethrows the same exception and keeps the original stack trace.
throw ex: rethrows it but resets the stack trace to the catch block, hiding where it started.
Better options: wrap in a new exception with the original as InnerException; use exception filters with when to catch only what you handle.
"If I catch an exception, log it, and want it to keep going up, I write throw with nothing after it. That rethrows the same exception and keeps the original stack trace, so whoever reads the logs sees the exact line where it started. If I write throw ex, the stack trace is reset to my catch block, and the original location is gone, which makes production bugs much harder to trace. When I want to add context, like which order ID failed, I throw a new, more meaningful exception and pass the caught one as the inner exception, so nothing is lost. I also like exception filters, catch with a when clause. The filter runs before the stack is unwound, so if the condition is false, the exception passes through untouched, which beats catching and rethrowing. And I only catch what I can actually handle."
try
{
ProcessOrder(orderId);
}
catch (HttpRequestException ex) when (IsTransient(ex))
{
logger.LogWarning(ex, "Retrying order {OrderId}", orderId);
throw; // keeps the original stack trace
}
catch (FormatException ex)
{
throw new OrderImportException("Bad data in order " + orderId, ex);
}
Saying throw ex; and throw; are the same, or wrapping exceptions without keeping the original as the inner exception.
List: Contains scans every item, so checking n IDs is O(n squared) overall.
HashSet: Add and Contains are O(1) on average; Add returns false if the item was already there.
Dictionary: when you need a value per key, like a count; keys need stable Equals and GetHashCode.
"I'd use a HashSet. With a List, Contains walks through the items one by one, so each check is linear, and across 100,000 IDs the whole thing becomes quadratic. A HashSet hashes the value and goes almost straight to it, so Add and Contains are constant time on average. It also has a handy detail: Add returns false if the item was already in the set, so I can check and insert in one call. If I needed to store something per ID, like how many times I'd seen it or the last timestamp, I'd use a Dictionary instead, with TryGetValue so I only look up once. Both depend on the key's Equals and GetHashCode. For ints or strings that's already right, but for my own class as a key, I'd override both together or use a record, and never change a key after putting it in the set."
var seen = new HashSet<long>();
foreach (long id in incomingIds)
{
if (!seen.Add(id)) // false means it was already there
Console.WriteLine("Duplicate: " + id);
}
Choosing a List with Contains for large lookups, or not knowing hash collections rely on Equals and GetHashCode.
The risk: the program keeps going in a broken state; callers think it worked; bugs surface far from the cause.
What good looks like: catch specific exceptions you can handle, let the rest bubble up to one top-level handler.
How to say it: a concrete example from their code, a suggested change, and a conversation rather than a blocking comment.
"I'd raise it, but with a concrete example from their code rather than a general rule. Catching Exception everywhere and carrying on means that if saving an order fails, the method still returns as if it worked. The caller moves on, the data is now inconsistent, and the only trace is a log line nobody reads. It also catches things we can't recover from, like a null reference that's really a bug. I'd suggest catching only the specific exceptions we can handle at that point, like a timeout we'll retry, and letting everything else bubble up to one top-level handler that logs it properly and returns a clean error. I'd point out that the tests pass because they only cover the happy path, and offer to add one test where a dependency throws. I'd leave it as a comment and a quick chat, not a lecture, because they probably added the catches after a crash and meant well."
Approving it because the tests pass, or rejecting it with 'bad practice' and no explanation of what could go wrong.
Symptom: what you saw, like memory climbing until a restart, and why it wasn't obvious.
Evidence: counters, memory dumps and comparing heap snapshots to see which objects kept growing and what held them.
Fix and guard: the root cause, the fix and what you changed so it doesn't come back.
"At my last company a background service's memory climbed slowly for days until it got restarted, and gen 2 collections kept getting longer. Since .NET has a garbage collector, I knew it had to be something still holding references. I took two memory dumps a few hours apart and compared them. One type of handler object had grown from a few hundred to tens of thousands. Following the path back to a root showed every instance was referenced by a static event on a settings class. Each job created a handler that subscribed to a settings-changed event but never unsubscribed, so the static event kept every one of them alive. The fix was to make the handler IDisposable, unsubscribe in Dispose, and wrap each job in a using. Memory went flat after that. I also added a memory graph to our dashboard and an alert on steady growth, so we'd catch the next one early."
A story that ends with raising memory limits or scheduling restarts, without ever finding what held the references.
Why: the problem that made it worth doing, such as threads blocked on I/O under load.
How: start from the lowest I/O calls and work upward, keeping signatures async all the way to the entry point.
Result: how you measured it and what you watched for, like any leftover .Result calls.
"At my last company one of our services started timing out under load even though the CPU was nearly idle. The requests were spending their time waiting on database and HTTP calls, all synchronous, so every waiting request held a thread and the pool couldn't keep up. I proposed moving the data access to async. I started at the bottom, the repository methods, switching to the async versions of the database and HTTP calls, and then worked upward one layer at a time, changing each caller to await and return Task, until I reached the controllers. The rule I held the team to was no .Result or .Wait in between, because blocking halfway gives you the worst of both. I passed a CancellationToken through while I was touching every signature anyway. After release, the same traffic ran with far fewer threads, and the timeouts stopped. We also added an analyser rule that flags blocking calls on tasks."
Wrapping synchronous calls in Task.Run and calling it async, or leaving .Result calls in the middle of the chain.
Measure first: a profiler, a benchmark or allocation counts, not guessing.
Cause: the specific hot spot and why it cost so much in C# terms.
Proof: before and after numbers from the same benchmark, and a check that output didn't change.
"In my final-year project I had an import step that matched records from two files, and it took minutes on a real file. I ran it under a profiler first instead of guessing. Most of the time was in a loop where, for each record, I called FirstOrDefault on a list of the other file's records. That's a linear scan inside a loop, so it was quadratic. The same profile showed lots of short-lived strings from building a lookup key with concatenation on every pass. I built a Dictionary keyed by ID once before the loop, so each lookup became a single hash lookup, and computed each key once. I wrote a small benchmark with BenchmarkDotNet to compare the old and new versions on the same data. The run time dropped from minutes to a couple of seconds, allocations fell a lot, and I diffed the output files to confirm the results were identical."
Changing code for speed with no measurement, or claiming a speed-up with no before and after on the same data.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.