45 questions on the C# language: async and await, LINQ and deferred execution, collections, records, strings, structs, delegates, and the corners that come up in senior interviews. Every answer keeps its runnable code.
01C# / .NETFoundation
C# language
Answer
C# is a statically typed, object-oriented language with strong tooling in Visual Studio, JetBrains Rider, and VS Code with the C# extension.
Language features include generics, LINQ, async and await, records, pattern matching, nullable reference types, and spans for safer high-performance memory access.
You use C# for ASP.NET Core web apps, desktop (WPF, WinForms, Avalonia), mobile and desktop with .NET MAUI, cloud workers, and game backends.
The Roslyn compiler emits IL into assemblies. At runtime the JIT (or AOT pipeline) produces native code for the machine.
Nullable reference types add compiler warnings so you treat string and other reference types as possibly null unless annotated otherwise.
Modern C# favors expression-bodied members, local functions, and switch expressions when they keep control flow short and readable.
Versioned language features ship with the SDK. Set LangVersion or TargetFramework so the whole team compiles with the same feature set.
Interview depth often means pairing language syntax with CLR effects: value versus reference types, async state machines, and how LINQ enumerates.
Flow diagram: C# on the .NET stack
C#
publicrecordPerson(stringName, intAge);
var people = newList<Person>
{
new("Ada", 36),
new("Grace", 40),
};
var names = people
.Where(p => p.Age >= 30)
.Select(p => p.Name)
.ToList();
awaitforeach (var line inReadLinesAsync("log.txt"))
Console.WriteLine(line);
staticasyncIAsyncEnumerable<string> ReadLinesAsync(string path)
{
usingvar reader = newStreamReader(path);
while (await reader.ReadLineAsync() is { } line)
yieldreturn line;
}
02C# / .NETFoundation
Classes
Answer
A class is a user-defined reference type that groups fields (state) with methods, properties, and events (behavior).
Instances are allocated on the managed heap. Variables hold references to those instances, so assignment copies the reference, not a deep clone of the object.
Every class ultimately derives from System.Object, which supplies Equals, GetHashCode, ToString, and GetType unless you override them.
Default accessibility for a top-level class is internal. Default accessibility for members inside a class is private.
Constructors initialize new instances. You can overload constructors, chain with : this(...), or use primary constructors in newer C# versions.
Static members belong to the type, not an instance. A static constructor runs once before the type is first used.
Inheritance lets a derived class reuse and specialize a base class. Sealed classes block further derivation.
Prefer clear ownership of mutable state. Prefer interfaces or abstract base types when you need polymorphism without locking callers to one concrete class.
Class diagram: Class shape
Abstract, partial, static, sealed
Abstract: cannot be instantiated; may mix abstract and concrete members; single inheritance only.
Partial: split one class across files; compiler merges them; sealing one part seals the whole type.
Static: no instance; only static members; cannot be inherited.
Sealed: blocks further inheritance when you need a fixed hierarchy.
C#
publicabstractclassShape
{
publicabstractdoubleArea();
}
publicsealedclassCircle : Shape
{
publicdoubleRadius { get; init; }
publicoverridedoubleArea() => Math.PI * Radius * Radius;
}
publicstaticclassMathHelper
{
publicstaticdoubleHypotenuse(double a, double b) =>
Math.Sqrt(a * a + b * b);
}
03C# / .NETFoundation
Access modifiers
Answer
Access modifiers control who can see a type or member: public, private, protected, internal, protected internal, and private protected.
Types without an explicit modifier default to internal. Class members without an explicit modifier default to private.
public means any code that references the assembly can use the member. private means only the declaring type can use it.
protected allows the declaring type and derived types. internal allows any code in the same assembly.
protected internal means the member is visible to the same assembly or to derived types in other assemblies. private protected means same assembly and derived types only.
File-local types (file class) restrict a type to one source file, which is useful for generated helpers that should not leak into the assembly API.
Pick the narrowest modifier that still supports the design. Wide public surfaces become hard to change without breaking consumers.
In library design, keep implementation types internal and expose only the interfaces or classes you intend to support long term.
A private constructor prevents code outside the type from calling new Type(). Only members of the type can construct instances.
Singletons often combine a private constructor with a public static Instance property so callers get one shared object.
Static factory methods such as Create or Parse can enforce validation, caching, or subtype selection while construction stays private.
A static-only utility class can declare a private constructor so nobody instantiates it by accident. Mark the class static when every member is static.
Records and classes can expose public static factories while keeping primary or secondary constructors private for controlled creation rules.
A private constructor alone does not make a type thread-safe or immutable. It only controls who may call new.
Reflection can still create instances in some scenarios, so private constructors are a design boundary, not a security guarantee.
When you seal a class and hide constructors, document how consumers should obtain instances so the intended factory path stays clear.
Mark a base method or property virtual when derived types should be able to replace its behavior with override.
Calls through a base-typed reference dispatch to the most derived override at runtime. That is the classic polymorphic call.
override replaces the virtual slot. The derived method must match the signature and accessibility rules of the base virtual member.
Using new on a member with the same name hides the base member instead of overriding it. Calls through the base type still hit the base implementation.
abstract members have no body and force concrete derived types to supply an implementation. Abstract members are implicitly virtual.
Interface members are implemented by the concrete type. Default interface methods can supply shared behavior without a base class.
Avoid deep virtual chains in hot paths when a sealed override or a non-virtual design would keep dispatch cheaper and clearer.
Prefer composition or strategy objects when inheritance hierarchies become hard to reason about or when behavior must change at runtime without subclassing.
Arrays are contiguous blocks with CLR-specialized layout, length fields, and bounds checks on each indexer access.
List<T> stores items in a T[] buffer. When capacity is exceeded, it allocates a larger array (growth factor) and copies existing elements.
Dictionary<TKey,TValue> uses a hash table of buckets and entries. Collisions are resolved by chaining or related probing strategies depending on implementation details.
A poor GetHashCode that clusters keys into few buckets turns average O(1) dictionary operations into near-linear scans.
HashSet<T> is similar to a dictionary of keys without values. It is optimized for uniqueness checks and set operations.
ConcurrentDictionary supports concurrent readers and writers without a single global lock. It uses fine-grained locking or lock-free techniques internally.
LinkedList<T> uses node objects and pays pointer chasing costs. Prefer List or arrays for sequential scan performance unless you need mid-list inserts often.
Knowing internals helps you set Capacity up front, choose struct keys carefully, and avoid enumerating ConcurrentDictionary while mutating without understanding snapshot semantics.
Flow diagram: List growth
C#
var list = newList<int>(capacity: 1000);
for (int i = 0; i < 1000; i++)
list.Add(i); // no resize until capacity exceeded
ConcurrentDictionary
Thread-safe dictionary for shared in-memory maps without wrapping every call in lock.
Useful for shared counters, caches, and registries accessed by many threads.
Safer than locking a plain Dictionary yourself for concurrent updates.
Readers that need a frozen view should copy keys or values rather than iterate while writers mutate.
C#
var map = newConcurrentDictionary<string, int>();
map.AddOrUpdate("hits", 1, (_, old) => old + 1);
Performance notes
Hash lookup is O(1) average; linear scan in a list is O(n).
List<T> stores items in an internal array, so iteration cost is close to a raw array.
Resize of List<T> copies the backing array; pre-size when you know count.
08C# / .NETFoundation
Threading
Answer
System.Threading.Thread wraps an OS thread. Creating many threads is expensive in memory and scheduling cost, so prefer the thread pool for most work.
ThreadPool and Task-based APIs reuse worker threads for short CPU or wait-based work items instead of spinning up dedicated threads.
Shared mutable state across threads needs synchronization: lock (Monitor), Mutex, SemaphoreSlim, concurrent collections, or immutable snapshots.
Race conditions appear when two threads read and write the same field without ordering guarantees. Volatile and Interlocked help for specific low-level patterns.
Deadlocks occur when threads wait for locks in opposite orders. Keep lock scopes short and lock ordering consistent.
UI frameworks have a single UI thread. Do not block it. Marshal results back with SynchronizationContext, Dispatcher, or Control.Invoke patterns.
CancellationToken lets cooperative cancellation flow through Task and async APIs without aborting threads forcibly.
Prefer higher-level Task and Channel patterns over manual Thread management unless you have a specialized host requirement.
Flow diagram: Thread vs thread-pool work
C#
// Prefer pool work for short CPU/IO orchestrationawaitTask.Run(() => DoCpuWork());
// Dedicated thread only when you need an exclusive long-lived threadvar t = newThread(() => ListenOnSerialPort())
{
IsBackground = true,
Name = "serial-listener",
};
t.Start();
09C# / .NETFoundation
Task vs Thread
Answer
Task represents a unit of asynchronous work that usually completes on a thread-pool thread or through an IO completion callback.
Thread gives direct control of an OS thread but does not include built-in result values, structured cancellation, or composition helpers.
Task supports ContinueWith, WhenAll, WhenAny, and first-class async and await integration for composing workflows.
Task<TResult> carries a result. Faulted tasks carry exceptions that await rethrows as AggregateException unwrapped in common await usage.
Use Task.Run for CPU-bound work that should leave the UI or request thread. Do not wrap already-async IO APIs in Task.Run by default.
Creating a dedicated Thread still makes sense for long-running dedicated work that must own a thread, but most app code should prefer Task.
ValueTask and ValueTask<T> can avoid allocations when results are often already available, with stricter usage rules than Task.
Prefer awaiting tasks end to end. Blocking with Wait or Result couples async work back to synchronous threads and can deadlock under contexts.
Flow diagram: Task vs Thread
C#
var sum = awaitTask.Run(() =>
{
long total = 0;
for (int i = 0; i < 1_000_000; i++) total += i;
return total;
});
10C# / .NETFoundation
Delegates
Answer
A delegate is a type-safe reference to a method (or methods) with a matching signature, including instance and static targets.
You declare custom delegates with the delegate keyword, or you use built-in Func, Action, and Predicate shapes for common signatures.
Multicast delegates hold an invocation list. Invoking the delegate calls each target in order. An exception in one target stops later targets unless you walk GetInvocationList yourself.
Lambdas and anonymous methods convert to compatible delegate types. Closures capture variables from the enclosing scope.
Events wrap delegates with restricted add and remove accessors so external code can subscribe without freely replacing the invocation list.
Covariance and contravariance on delegate type parameters follow the same in and out rules as generic interfaces when declared that way.
Comparing delegates with == checks whether their invocation lists match, which differs from simple reference equality of the delegate objects in some cases.
Prefer Func and Action for public APIs unless a named delegate adds domain clarity that generic shapes cannot express.
Flow diagram: Delegate invoke
C#
Func<int, string> format = n => Convert.ToString(n, 16);
Console.WriteLine(format(255)); // ff
11C# / .NETFoundation
Events
Answer
An event exposes a delegate with add and remove accessors. Subscribers attach handlers. Only the declaring type should raise the event.
The common pattern is EventHandler or EventHandler<TEventArgs> with a sender object and event arguments payload.
Raising typically copies the delegate to a local, null-checks it, then invokes so concurrent unsubscribe races are less likely to NRE.
Unsubscribe (-=) when the subscriber's lifetime ends. Forgotten handlers keep both publisher and subscriber reachable and look like memory leaks.
Use weak-event patterns or careful lifetime design when long-lived publishers (static events, app services) would otherwise pin short-lived UI objects.
Custom event accessors let you add logging or thread-safe subscription storage, but most types use the compiler-generated field-like event.
Do not expose the underlying delegate as a public field. That lets callers clear or replace the entire invocation list.
For in-process messaging with many publishers, consider channels or a mediator library when classic events become hard to trace.
Unit tests verify one unit such as a method or class with dependencies replaced by mocks or fakes. Common runners are xUnit, NUnit, and MSTest.
Arrange-Act-Assert structures tests: set up inputs, invoke the unit, then assert outcomes. Clear names state the scenario and expected result.
Keep unit tests fast and isolated. Avoid real network, disk, and shared databases unless you intentionally write integration tests.
Reqnroll is a .NET BDD framework (SpecFlow successor). Scenarios live in Gherkin .feature files that non-developers can read.
Gherkin keywords include Feature, Scenario, Given, When, Then, And, But, Background, Scenario Outline, and Examples.
Step definitions are methods on a [Binding] class with [Given], [When], or [Then] attributes and a cucumber expression or regex that matches the step text.
Use Reqnroll for behavior and acceptance scenarios that describe product language. Keep fine-grained logic checks in xUnit or NUnit.
Share state between steps carefully with context injection. Avoid hidden static state that makes scenarios order-dependent.
Flow diagram: BDD steps
C# / Gherkin
// Feature file (.feature)// Scenario: Add item// Given the basket is empty// When the customer adds 1 "Book"// Then the basket has 1 item
[Binding]
publicclassBasketSteps
{
privatereadonlyBasket _basket = new();
[Given("the basket is empty")]
publicvoidEmpty() => _basket.Clear();
[When("the customer adds {int} {string}")]
publicvoidAdd(int qty, string name) => _basket.Add(name, qty);
[Then("the basket has {int} item(s)")]
publicvoidCount(int n) => Assert.Equal(n, _basket.Count);
}
14C# / .NETFoundation
IEnumerable
Answer
IEnumerable<T> exposes GetEnumerator, which returns an enumerator for forward-only iteration over a sequence of T.
foreach desugars to obtaining an enumerator, calling MoveNext until false, and reading Current on each step, then disposing when IDisposable.
LINQ to Objects operators accept IEnumerable<T> and often return another IEnumerable<T> that defers work until enumeration.
Multiple enumeration can re-run the underlying query. Materialize with ToList or ToArray when you need a stable snapshot or multiple passes.
IEnumerable is non-generic and yields object. Prefer IEnumerable<T> to avoid boxing and casts.
Yield return methods implement IEnumerable<T> via a compiler-generated state machine that produces values lazily.
Returning IEnumerable from public APIs communicates that callers should iterate, not mutate or index, unless you document more.
For async streams use IAsyncEnumerable<T> with await foreach when producing values involves asynchronous waits.
Flow diagram: IEnumerable pull
C#
IEnumerable<int> evens = Enumerable.Range(1, 10).Where(n => n % 2 == 0);
foreach (var n in evens)
Console.WriteLine(n);
15C# / .NETFoundation
List vs IEnumerable
Answer
IEnumerable<T> is a forward-only sequence you can foreach. It does not promise indexing, Count, or in-place mutation.
List<T> is a concrete growable array in memory with indexer access, Count, Add, Remove, and other mutating APIs.
Accept IEnumerable<T> or IReadOnlyList<T> on inputs when you only need to read. That lets callers pass arrays, lists, or deferred LINQ queries.
Return List<T> or IReadOnlyList<T> when callers need a materialized, countable collection they can index.
Calling ToList forces execution and allocates storage. Holding an IEnumerable may defer LINQ work until someone enumerates.
If you return a live List that you keep mutating, callers see those changes. Return a copy or immutable snapshot when you need isolation.
ICollection<T> and IList<T> sit between IEnumerable and List: they expose Count and mutation contracts without requiring the concrete List type.
Choose the narrowest type that expresses capability. Over-exposing List encourages accidental coupling to mutability and concrete storage.
Flow diagram: List vs IEnumerable
C#
IEnumerable<int> Filter(IEnumerable<int> src) => src.Where(x => x > 0);
List<int> Materialize(IEnumerable<int> src) => src.Where(x => x > 0).ToList();
16C# / .NETFoundation
IReadOnlyList
Answer
IReadOnlyList<T> extends IEnumerable<T> with Count and an indexer. It does not expose Add or Remove.
Callers can read by index and know the length without casting to List. That makes random access safe in API contracts.
Prefer IReadOnlyList<T> on public return types when you hand out a fixed snapshot that should not be mutated through the interface.
List<T> implements IReadOnlyList<T>. Returning a List as IReadOnlyList still allows a cast back to List unless you copy or wrap.
For stronger safety return array copies, ImmutableArray<T>, ImmutableList<T>, or a ToList snapshot you no longer mutate.
IReadOnlyCollection<T> gives Count without an indexer. Use it when indexing is not part of the contract.
Read-only interfaces communicate intent. They do not freeze the underlying object if that object remains mutable elsewhere.
In library design, take IReadOnlyList when you need indexing and return it when you want to discourage mutation at the call site.
IEnumerator<T> tracks iteration state with MoveNext, Current, and Reset. Reset is rarely used and often throws NotSupportedException.
Manual iteration mirrors what foreach generates: while (e.MoveNext()) use e.Current, then Dispose the enumerator when required.
Custom collections implement IEnumerable by returning a private enumerator struct or class that walks internal storage.
Enumerator structs can avoid heap allocation in foreach when the compiler uses the pattern-based foreach shape correctly.
Dispose enumerators that wrap unmanaged resources or that hold locks. foreach does this when the enumerator implements IDisposable.
Changing a collection while an enumerator is active often throws InvalidOperationException for List and Dictionary version checks.
IAsyncEnumerator<T> pairs with IAsyncEnumerable<T> and MoveNextAsync for asynchronous sequences.
Understanding enumerators clarifies why deferred LINQ pipelines pause between elements and why Dispose runs at the end of foreach.
State diagram: Enumerator state
C#
var names = newList<string> { "Ada", "Grace" };
usingvar e = names.GetEnumerator();
while (e.MoveNext())
Console.WriteLine(e.Current);
18C# / .NETFoundation
Mutability
Answer
Mutable types expose state that can change after construction. Immutable types do not expose mutating operations on their published state.
string is immutable. Concatenation and Replace return new string instances. The original contents stay unchanged for existing references.
StringBuilder is mutable. It edits a character buffer efficiently, then ToString creates the final immutable string.
Immutable collections from System.Collections.Immutable create new collection instances on update, which helps sharing across threads.
Records with init-only properties support non-destructive updates via with-expressions while keeping instances easy to treat as values.
Mutability complicates reasoning about shared objects. Two aliases can change the same instance unexpectedly.
Defensive copies and readonly fields help, but a readonly field that holds a mutable object still allows mutation of that object's contents.
Prefer immutability for DTOs and keys. Allow controlled mutation for high-churn builders and domain entities when the design needs it.
Flow diagram: Mutable vs immutable
Strings and dictionaries
Assigning str2 = str1 then mutating str1 via concatenation leaves str2 unchanged.
Dictionary<TKey,TValue> is mutable; ImmutableDictionary is safe to share across threads.
C#
string a = "Hello ";
string b = a;
a += "World";
Console.WriteLine(b); // Hello
19C# / .NETFoundation
float vs double
Answer
float is a 32-bit IEEE 754 single-precision floating-point type. double is a 64-bit IEEE 754 double-precision type.
double provides about 15 to 17 decimal digits of precision. float provides about 6 to 9. Neither type stores most decimal fractions exactly.
C# floating literals without a suffix are double. Use f for float literals such as 1.5f.
Use double by default for general calculations unless memory, SIMD, or GPU APIs require float.
Very large magnitudes overflow float to infinity sooner than double. Underflow to zero also happens earlier for float.
Equality comparisons on floats and doubles are fragile because of rounding. Prefer ranges or decimal for money.
decimal is a 128-bit decimal type for financial and exact base-10 needs. It is slower than binary floating types.
Math APIs often take double. Casting float up to double is widening. Casting double down to float can lose precision.
Flow diagram: float vs double
C#
float f = 3.14f;
double d = 3.14;
Console.WriteLine(f == (float)d); // may be false due to precision
20C# / .NETFoundation
using directive and statement
Answer
using Namespace; is a directive that imports types so you can write short names instead of fully qualified names.
using static TypeName; imports static members so you can call them without the type prefix.
The using statement and using declaration dispose an IDisposable at the end of the scope, even when exceptions occur.
await using works with IAsyncDisposable for types such as async streams or modern EF Core constructs that need async dispose.
File, stream, and database code should sit in using so handles close promptly instead of waiting for finalizers.
using var reader = ...; scopes disposal to the end of the enclosing block, which reduces nesting compared with using (...).
Aliasing with using X = Long.Namespace.Type; shortens rare type names without importing an entire namespace.
Do not confuse namespace imports with resource disposal. Both use the using keyword but solve different problems.
Flow diagram: using dispose
C#
usingSystem.IO;
usingvar stream = File.OpenRead("app.log");
// stream disposed here
21C# / .NETFoundation
Casting
Answer
Implicit conversions widen safely when no data loss is expected, such as int to long or int to double (with precision caveats for some values).
Explicit casts tell the compiler you accept possible truncation, overflow behavior, or runtime type checks.
For reference types, (Derived)obj throws InvalidCastException when the runtime type is incompatible. as returns null instead of throwing.
is and pattern matching test types without throwing and can introduce a new typed variable in one step.
Prefer int.TryParse and related Try methods over catch-based conversion from strings.
User-defined conversion operators can hide costs. Prefer named methods when conversion is expensive or surprising.
checked and unchecked contexts control whether integral overflow throws or wraps on explicit conversions and arithmetic.
Casting interfaces and structs can box. Prefer generic constraints when you need to call interface methods without boxing.
Flow diagram: Cast paths
Implicit and explicit
int to double is implicit; double to int requires (int) and truncates decimals.
Reference casts fail at runtime if types are incompatible.
C#
int i = 10;
double d = i;
double pi = 3.14;
int whole = (int)pi; // 3
22C# / .NETFoundation
Array vs ArrayList
Answer
Arrays are strongly typed and fixed length after creation. To grow, you allocate a new array and copy elements.
ArrayList is a non-generic, dynamically sized list that stores object and boxes value types on insert.
Use List<T> in modern code for a growable, typed list without boxing for value-type elements.
Arrays win on simple contiguous layout and minimal overhead when the size is known. Lists win when you need frequent Add and flexibility.
Array.Resize allocates a new array under the hood. List.Capacity growth amortizes resizes across many Add calls.
Multidimensional arrays and jagged arrays (T[][]) serve different locality and flexibility needs. Jagged arrays are arrays of arrays.
Span<T> and Memory<T> can view array segments without copying, which helps high-performance slicing.
When maintaining legacy ArrayList code, migrate element by element to List<T> and remove casts that become unnecessary.
Flow diagram: Typed array vs legacy ArrayList
C#
int[] scores = { 1, 2, 3 }; // fixed lengthvar list = newList<int> { 1, 2, 3 }; // grows, no boxing
list.Add(4);
// Legacy (avoid):var legacy = newArrayList();
legacy.Add(1); // boxes int to object
23C# / .NETFoundation
out parameters
Answer
An out parameter must be assigned by the callee before the method returns normally. The caller need not initialize it first.
TryParse patterns return bool for success and use out for the parsed value, avoiding exceptions for ordinary parse failures.
C# allows inline out variable declarations: int.TryParse(text, out var value) keeps the variable scoped to the call site.
out differs from ref: ref requires the variable to be definitely assigned before the call and permits reading before writing inside the method.
Multiple out parameters work but become noisy. Tuples (bool ok, int value) or custom result types often read clearer.
Async methods cannot have out or ref parameters because the state machine cannot safely expose those aliases across awaits.
Discard with out _ when you only care about the success flag from TryParse-style APIs.
In API design, prefer returning a value or a tuple unless an established Try pattern with out matches framework conventions.
Sequence diagram: out parameter
C#
if (int.TryParse("1640", outint number))
Console.WriteLine(number);
24C# / .NETFoundation
Func, Action, Predicate
Answer
Func<T,...,TResult> is a family of built-in delegates that take zero or more inputs and return TResult.
Action<T...> is a family of built-in delegates that take zero or more inputs and return void.
Predicate<T> is effectively Func<T, bool>. List<T>.Find and similar APIs accept Predicate<T> for filtering conditions.
Prefer Func and Action over custom delegate declarations when the signature is a simple map or callback shape.
LINQ operators are built around Func delegates for selectors, predicates, and key selectors.
Multicast Action invocation still follows delegate rules: one throw stops the remaining handlers unless you invoke the list manually.
Nullable annotations on Func and Action parameters help document whether null delegates or null results are allowed.
Named delegates remain useful when the domain name clarifies intent beyond anonymous Func shapes in public APIs.
Flow diagram: Delegate helpers
C#
Func<int, int, int> add = (a, b) => a + b;
Action<string> log = msg => Console.WriteLine(msg);
Predicate<int> isEven = n => n % 2 == 0;
Console.WriteLine(add(2, 3));
log("ok");
Console.WriteLine(isEven(4));
25C# / .NETFoundation
IEnumerable vs IQueryable
Answer
IEnumerable<T> with LINQ to Objects runs delegates in memory against objects already loaded in the process.
IQueryable<T> holds an expression tree. A query provider such as EF Core can translate that tree into SQL or another remote query language.
Filtering on IEnumerable after ToList or AsEnumerable pulls data into memory first, which can transfer more rows than needed.
Keep filters, ordering, and projections on IQueryable until you project to a DTO or materialize intentionally.
Client evaluation happens when a provider cannot translate a method call. Newer EF Core versions are stricter and throw instead of silently pulling everything.
AsQueryable on an in-memory list still runs in memory. The interface alone does not make a database query.
Returning IQueryable from repositories can leak provider details and make composition harder to control. Many teams return already-shaped results.
Choose IEnumerable for in-memory pipelines. Choose IQueryable when you intentionally compose against a remote provider.
Flow diagram: In memory vs provider
C#
// EF Core: still SQL until ToListAsyncvar page = await db.Orders
.Where(o => o.Status == Status.Open)
.OrderByDescending(o => o.CreatedAt)
.Take(20)
.ToListAsync();
26C# / .NETFoundation
Extension methods
Answer
Extension methods are static methods whose first parameter is marked with this. They appear as instance methods on the extended type.
They cannot access private members of the extended type. They only use the public (or accessible) surface.
LINQ is implemented as extension methods on IEnumerable<T> and IQueryable<T> in classes such as Enumerable and Queryable.
Put extensions in a dedicated static class and import the namespace. Without the using directive, the methods do not appear.
If an instance method with a compatible signature exists, it wins over an extension method during binding.
Extensions are ideal for fluent helpers and cross-cutting utilities without modifying the original type source.
Avoid extension methods that mutate unexpectedly or hide expensive work behind a property-like name.
Null receivers can still call extension methods; the this argument arrives as null, so null-check inside if needed.
For value types, T? is Nullable<T>. HasValue reports presence. Value reads the payload and throws if missing. GetValueOrDefault supplies a fallback.
Nullable reference types (string?) are a compiler annotation system. They warn on possible null dereferences when nullable context is enabled.
The ?? operator returns the left operand unless it is null, in which case it returns the right operand.
The ??= operator assigns the right operand only when the left variable is null.
The ?. operator short-circuits member access or invocation when the receiver is null, producing a null result of the appropriate type.
Combine patterns carefully: x?.Count ?? 0 reads a safe length when x may be null.
null-forgiving ! tells the compiler you are sure a value is non-null. Use it sparingly when you have external invariants the compiler cannot see.
APIs should document null contracts. Returning Task versus Task? or string versus string? changes caller obligations.
Flow diagram: Null handling
C#
int? count = null;
int n = count ?? 0;
string? name = GetName();
name ??= "guest";
var len = name?.Length;
30C# / .NETFoundation
Yield Return, Deferred Execution, and State Machines
Answer
yield return enables custom, stateful, lazy iteration over an IEnumerable<T> or IEnumerator<T> without building and populating an intermediate in-memory collection (such as a List<T>).
Compiler Transformation: When a method contains yield return, the C# compiler generates an internal hidden state machine class implementing IEnumerable<T>, IEnumerator<T>, and IDisposable. The method body is dissected into distinct blocks separated by yield return statements inside a MoveNext() method.
Deferred (Lazy) Execution: Code inside a yield iterator does not execute when the method is called. Execution begins only when the consumer calls MoveNext() on the enumerator (for example, via a foreach loop). Values are produced on-demand one element at a time.
Memory Efficiency: Processing a 10 GB log file with yield return consumes only a few kilobytes of RAM because only one line is held in memory at any moment, compared to reading the entire file into a List<string> which triggers OutOfMemoryException.
Sequence diagram: On-demand pulling of items via MoveNext() state transitions
C# (Asynchronous Streaming with IAsyncEnumerable)
publicstaticasyncIAsyncEnumerable<string> ReadLogEntriesAsync(
string filePath,
[EnumeratorCancellation] CancellationToken ct = default)
{
// using block disposes automatically if consumer breaks out of loopusingvar stream = File.OpenRead(filePath);
usingvar reader = newStreamReader(stream);
while (await reader.ReadLineAsync(ct) is { } line)
{
if (line.Contains("[ERROR]"))
{
yieldreturn line; // Yields item on demand without allocating entire list
}
}
}
IAsyncEnumerable<T> (Asynchronous Streaming)
Introduced in C# 8. Combining await with yield return produces an IAsyncEnumerable<T>, allowing asynchronous, non-blocking stream processing over network sockets, database cursors, and message brokers via await foreach.
Resource Disposal: If an enumeration aborts early (via break or an exception), the foreach loop automatically calls Dispose() on the enumerator state machine, ensuring that any enclosing using statements execute and release file or database handles promptly.
31C# / .NETFoundation
const vs readonly
Answer
const fields must be compile-time constants. They are implicitly static and are replaced with literal values at call sites that reference them.
readonly fields can be assigned in the declaration or in a constructor. Instance readonly values can differ per object.
static readonly fields initialize once per type, including from a static constructor, and can hold runtime-computed values.
Changing a public const is a breaking change for callers compiled against the old value because those callers may have inlined the literal.
Changing a public static readonly field is also a behavioral break, but callers typically read the field at runtime instead of inlining the old constant.
Use const for true constants such as numeric limits and fixed protocol numbers. Use readonly for values that need runtime initialization.
Local const exists for locals. Local readonly does not. Use the readonly modifier on fields and structs instead.
ref readonly and readonly struct address different problems: safe references and immutable value types, not the same as const fields.
Flow diagram: const vs readonly
C#
publicclassLimits
{
public const intMaxNameLength = 100;
publicstaticreadonlyDateTimeStarted = DateTime.UtcNow;
publicreadonlyGuidId;
publicLimits() => Id = Guid.NewGuid();
}
32C# / .NETFoundation
Records
Answer
record or record class declares a reference type with value-based equality by default, based on the contents of its members.
record struct declares a value type with concise record syntax and value equality semantics appropriate for structs.
Primary constructors and init-only properties give short declarations for data carriers. with-expressions create copies with selected members changed.
Compiler-generated ToString, Deconstruct, and equality members reduce boilerplate for DTOs and messages.
You can still add methods and implement interfaces. Records are not limited to pure data, though that is the common use.
Inheritance works with record classes, but designing equal hierarchies needs care so equality remains sensible across derived types.
Prefer records for immutable-ish models. Prefer ordinary classes when mutable identity and rich behavior dominate the design.
positional records generate public properties from the primary constructor parameters unless you customize them.
Flow diagram: Record value equality
C#
publicrecordOrderDto(intId, decimalTotal);
var a = newOrderDto(1, 10m);
var b = a with { Total = 12m };
Console.WriteLine(a == b); // False
33C# / .NETFoundation
Record vs class
Answer
A class uses reference equality by default: two instances are equal under == only if they are the same object, unless you overload operators.
A record class uses value-based equality by default: two instances compare equal when their recorded data members match.
Records provide concise primary constructors, init properties, and with-expressions for non-destructive copies.
Use a record for DTOs, messages, and immutable-ish data carriers where content equality matches domain meaning.
Use a class when you need mutable identity, rich behavioral APIs, classic OOP entity modeling, or hierarchies that do not fit record equality.
record struct is a value type. Ordinary class and record class are reference types allocated on the heap.
If you need reference equality for a record sometimes, compare with Object.ReferenceEquals explicitly.
Choosing record versus class is a design decision about equality and mutation, not only about shorter syntax.
Flow diagram: record vs class
C#
publicclassUserEntity
{
publicGuidId { get; set; }
publicstringEmail { get; set; } = "";
}
publicrecordUserDto(GuidId, stringEmail);
var a = newUserDto(Guid.Empty, "a@x.com");
var b = newUserDto(Guid.Empty, "a@x.com");
Console.WriteLine(a == b); // True (value equality)
34C# / .NETFoundation
Pattern matching
Answer
Pattern matching lets is expressions and switch statements or expressions test types, constants, properties, relations, and logical combinations.
Type patterns replace casting: if (obj is string s) uses s as a typed variable only when the test succeeds.
Switch expressions return a value and should be exhaustive. Use _ for a discard arm when remaining cases share one result.
Property patterns match nested members: person is { Address: { City: "Seattle" } } tests without deep nested casts.
Relational patterns such as > 0 and list patterns in newer C# versions express structure checks concisely.
and, or, and not combine patterns. Parentheses keep complex conditions readable.
Prefer patterns over sequences of as casts and null checks when they make control flow flatter.
Keep arms side-effect free when possible so switch expressions stay easy to reason about and test.
Flow diagram: Pattern match
C#
stringDescribe(object value) => value switch
{
int n when n > 0 => "positive int",
string { Length: 0 } => "empty string",
null => "null",
_ => "other",
};
35C# / .NETFoundation
== vs Equals
Answer
== is an operator that types can overload. For many reference types the default meaning is reference equality. string overloads == for content equality.
Equals is a virtual instance method. When you override it for value equality, override GetHashCode with a matching contract for dictionaries and sets.
Object.ReferenceEquals(a, b) always tests identity and ignores overloaded == operators.
For string operands typed as string, == and Equals compare contents. For object-typed variables holding strings, == uses reference equality.
IEquatable<T> provides a strongly typed Equals(T) that avoids boxing for value types when collections call it.
Static Object.Equals(a, b) handles nulls safely and then calls instance Equals.
Records generate value-based Equals and ==. Classes require manual overrides if you want the same behavior.
In equality design, document whether null equals null, and keep Equals and GetHashCode synchronized forever after.
Flow diagram: Equals vs ==
C#
string a = "hi";
string b = newstring("hi".ToCharArray());
Console.WriteLine(a == b); // True (content)Console.WriteLine(ReferenceEquals(a, b)); // False
36C# / .NETFoundation
var vs dynamic vs object
Answer
var asks the compiler to infer a static type from the initializer. The variable remains strongly typed after inference.
object is the ultimate base type for reference types (and boxed values). Calls need casts, patterns, or reflection to reach members.
dynamic bypasses compile-time member checking. The runtime binder resolves members when the call executes and can throw at runtime.
Prefer var when the right-hand side already makes the type obvious, such as var list = new List<int>();.
Avoid dynamic unless you interoperate with dynamic systems such as COM, HTML DOM scripting, or loosely typed JSON bags without generated types.
ExpandoObject and DynamicObject support dynamic member bags. They trade safety for flexibility.
Mixing dynamic with generics and overloads can pick surprising bindings. Keep dynamic islands small.
object and dynamic both erase static detail, but object still requires explicit conversion while dynamic tries late binding automatically.
Flow diagram: var dynamic object
C#
var n = 3; // intobject box = n; // boxing
dynamic d = "hi";
Console.WriteLine(d.Length); // runtime bind
37C# / .NETIntermediate
Generics
Answer
Generics introduce type parameters so one implementation works for many types with compile-time type checking.
List<int> stores ints without boxing. List<object> or ArrayList would box value types and require casts on read.
Constraints such as where T : class, where T : struct, where T : new(), and where T : IComparable<T> document what the type parameter must support.
Generic methods can infer type arguments from usage, which keeps call sites short while remaining strongly typed.
Covariance and contravariance on interfaces and delegates (out T and in T) let you convert generic types safely when mutation is restricted.
The CLR creates specialized representations for value-type type arguments and shared code for many reference-type arguments, which keeps generics efficient.
LINQ and the generic collections library are built on generics. Most modern .NET APIs prefer T over object.
Avoid unconstrained object parameters when a generic method would remove casts and document intent more clearly.
Flow diagram: Generic type parameter
C#
publicstaticTMax<T>(T a, T b) whereT : IComparable<T>
{
return a.CompareTo(b) >= 0 ? a : b;
}
38C# / .NETIntermediate
LINQ
Answer
LINQ provides query syntax and method-syntax operators over sequences (LINQ to Objects) and query providers (such as EF Core).
Where filters, Select projects, OrderBy sorts, GroupBy groups, and Join correlates sequences. These cover most day-to-day shaping.
Operators compose into pipelines. Execution for IEnumerable often stays deferred until you enumerate or call a materializing operator.
Method syntax (people.Where(...).Select(...)) is common in production. Query syntax (from x in ... where ...) is fine when it reads clearer.
Any, All, First, FirstOrDefault, Single, and Count express existence and cardinality checks with clear intent.
Prefer FirstOrDefault when zero matches are normal. Prefer Single when zero or many matches indicate a bug.
Avoid repeated enumeration of expensive queries. Cache with ToList when you need multiple passes.
Understand whether you are on IEnumerable or IQueryable so filters run in memory or translate to the data source.
LINQ: Deferred Execution, Multiple Enumeration, and IQueryable
Answer
LINQ query execution falls into two primary modes: Deferred (Lazy) Execution and Immediate Execution.
Deferred Execution: Operators such as Where, Select, Take, and Skip do not process data when declared. They construct an iterator pipeline or an expression tree. Execution occurs only when the query is enumerated (via foreach, ToList(), ToArray(), First(), or Any()).
The Multiple Enumeration Trap: When a deferred LINQ query is iterated multiple times, the entire query pipeline (and any underlying database queries or file reads) re-executes from scratch every single time. If the pipeline performs network I/O or expensive CPU work, multiple enumeration introduces performance degradation and unpredictable side effects.
Immediate Execution: Operators such as ToList(), ToArray(), ToDictionary(), Count(), and Sum() force immediate evaluation of the query pipeline, caching the results in memory.
Flow diagram: IQueryable SQL translation vs IEnumerable in-memory filtering
C# (Avoiding Multiple Enumeration and Preserving IQueryable)
publicasyncTaskProcessOrdersAsync(AppDbContext db, CancellationToken ct)
{
// 1. IQueryable: Builds Expression Tree, executed as single SQL query on databasevar highValueQuery = db.Orders.Where(o => o.TotalAmount > 1000 && o.Status == "Paid");
// Materialize to list to avoid multiple database executionsList<Order> orders = await highValueQuery.ToListAsync(ct);
// Safe to enumerate multiple times without re-running database queriesvar totalRevenue = orders.Sum(o => o.TotalAmount);
var uniqueCustomers = orders.Select(o => o.CustomerId).Distinct().Count();
}
IEnumerable<T> vs IQueryable<T>
IEnumerable<T>: Operates in-memory (LINQ to Objects) using compiled C# Func<T, bool> delegates. Every row is pulled into application memory, and filtering happens within the .NET process.
IQueryable<T>: Operates out-of-process (LINQ to Entities / EF Core) using Expression<Func<T, bool>> expression trees. EF Core translates the expression tree into a native SQL query, allowing filtering, sorting, and paging to execute directly on the database engine. Calling .ToList() or .AsEnumerable() switches the pipeline from IQueryable to IEnumerable.
40C# / .NETAdvanced
Concurrency vs parallelism
Answer
Concurrency means many tasks make progress overlapping in time. On one core that may be interleaving through async IO waits and time slicing.
Parallelism means multiple cores run work at the same instant, for example Parallel.For, PLINQ, or several Task.Run CPU chunks.
async and await mainly unlock concurrency for IO-bound waits. They do not by themselves add CPU cores to a computation.
Use concurrency to keep a server responsive while many requests wait on network or disk. Use parallelism to shorten CPU-heavy work when cores are free.
Parallel code must protect shared mutable state. Async code must handle SynchronizationContext, ConfigureAwait, and cancellation correctly.
Over-parallelizing small work can cost more in scheduling and cache churn than it saves. Measure before widening degree of parallelism.
ASP.NET Core request handling is concurrent by design. Adding Parallel.For inside each request can steal threads from the pool if misused.
Choose the model from the bottleneck: waiting IO points to async concurrency; hot CPU loops point to parallel CPU APIs.
Flow diagram: Overlap waits vs run on multiple cores
C#
// Concurrency: overlap waits (usually one thread at a time per request)var page = await http.GetStringAsync(url);
var rows = await db.Orders.ToListAsync();
// Parallelism: use multiple cores for CPU-bound workParallel.For(0, items.Length, i =>
{
items[i] = Process(items[i]);
});
41C# / .NETAdvanced
Async, Await, and ThreadPool Mechanics
Answer
async and await provide asynchronous, non-blocking control flow in C#. While waiting for an asynchronous I/O operation (database query, HTTP call, disk read) to complete, the thread returns to the .NET ThreadPool to process other work, rather than sitting idle in a blocked state.
Compiler Transformation: The C# compiler transforms an async method into an internal state machine struct implementing IAsyncStateMachine. The method code is divided into slices separated by await points inside a MoveNext() method, controlled by an internal integer state (-1 for running, 0, 1, 2 for pending awaits, -2 for completed).
Sync-Over-Async ThreadPool Starvation: Calling .Result, .Wait(), or GetAwaiter().GetResult() on an uncompleted Task inside ASP.NET Core blocks the current ThreadPool thread. If incoming requests spike while the pool is blocked, the pool can only inject new threads at a rate of 1-2 threads per second, leading to sudden request queuing, latency spikes, and server lockups.
SynchronizationContext: Controls where a continuation executes after an await. In classic ASP.NET and UI apps (WPF, WinForms), SynchronizationContext marshals execution back to the original request or UI thread. ASP.NET Core has no SynchronizationContext, so continuations resume on arbitrary ThreadPool threads by default.
Sequence diagram: Async state machine and ThreadPool continuation flow
Task and Task<T> are reference types allocated on the managed heap every time an asynchronous operation is initiated, creating garbage collection pressure in high-throughput hot paths.
ValueTask and ValueTask<T> are stack-allocated discriminated unions holding either a completed result T or a Task<T>/IValueTaskSource. If an operation completes synchronously (such as reading from an in-memory cache or a pre-buffered socket), ValueTask allocates zero heap memory.
ValueTask Constraints: A ValueTask must never be awaited more than once, awaited concurrently, or passed to Task.WhenAll. If multiple awaits or concurrent operations are needed, call .AsTask() first.
ConfigureAwait and Cancellation Token Propagation
ConfigureAwait(false): Tells the runtime that the continuation does not need to capture the current SynchronizationContext. Widely used in class libraries to avoid deadlocks in legacy UI or classic ASP.NET apps and reduce context switching overhead.
Cancellation Propagation: Always accept a CancellationToken and pass it down to every underlying asynchronous method. When a client cancels a web request, cooperative cancellation allows the database command or HTTP call to abort promptly, freeing database connections and memory.
42C# / .NETAdvanced
Covariance and contravariance
Answer
Covariance (out T) lets you treat a producer of a more derived type as a producer of a base type, for example IEnumerable<string> as IEnumerable<object>.
Contravariance (in T) lets you treat a consumer of a base type as a consumer of a derived type, for example Action<object> as Action<string>.
Variance applies to interfaces and delegates marked with in or out. It does not apply to mutable List<T>.
List<string> is not a List<object> because a List<object> view would allow inserting an int, which would break the string list.
IEnumerable<T> is covariant because it only produces T values. IComparer<T> is contravariant because it only consumes T values.
Arrays are historically covariant in C# for reference types, which can throw ArrayTypeMismatchException on bad stores. Prefer generic collections.
When declaring your own generic interfaces, mark out only for outputs and in only for inputs so the compiler can enforce safety.
Variance questions often appear with Func and Action: Func is covariant in its result and contravariant in its parameters.
async void is intended for UI or similar event handlers that cannot return Task. Exceptions go to the synchronization context and are hard for callers to await.
async Task and async ValueTask let callers await completion and handle faults with normal try/catch around await.
Blocking with .Result or .Wait() on a UI thread or classic ASP.NET synchronization context can deadlock when the continuation needs that same context.
In libraries, ConfigureAwait(false) avoids capturing a context you do not need. At the UI or ASP.NET Core controller edge, await without blocking.
ASP.NET Core does not use a request SynchronizationContext like classic ASP.NET, yet sync-over-async still hurts scalability by occupying threads.
Fire-and-forget async void work should be rare. Prefer queued background work with logging and cancellation.
ValueTask has stricter consumption rules than Task: await it once and do not store it casually unless you know the pattern.
Deadlock interviews often want this chain: await needs context, context thread is blocked on Wait, continuation never runs.
Flow diagram: Why .Result can hang
C#
// Event handler: async void is acceptableasyncvoidOnClick(object sender, EventArgs e)
{
try { awaitSaveAsync(); }
catch (Exception ex) { Log(ex); }
}
// API / library: return TaskpublicasyncTaskSaveAsync()
{
await db.SaveChangesAsync().ConfigureAwait(false);
}
// Avoid: var x = SaveAsync().Result;
44C# / .NETAdvanced
Advanced: closure capture in loops
Answer
A lambda captures a variable by reference into a closure object, not a snapshot of the value at the line where you wrote the lambda.
A for loop shares one loop variable across iterations. If lambdas capture that variable without a per-iteration copy, every lambda sees the final value after the loop ends.
Modern foreach uses a per-iteration variable, which removes the classic foreach capture bug. for still needs var copy = i; inside the body when you close over the index.
The same mistake appears with Task.Run, ThreadPool.QueueUserWorkItem, and event handlers created inside loops.
Capturing this keeps the enclosing instance alive for as long as the delegate lives, which can extend lifetimes unexpectedly.
Fix by introducing a local copy inside the loop, or by passing the value as a state argument to APIs that accept state objects.
Closures allocate a display class when they capture. In hot loops, that allocation cost can matter.
When reviewing code, search for lambdas inside for loops that use the loop index or shared mutable locals.
Flow diagram: Shared variable vs per-iteration copy
C#
var actions = newList<Action>();
for (var i = 0; i < 3; i++)
{
var id = i; // per-iteration copy
actions.Add(() => Console.WriteLine(id));
}
foreach (var a in actions) a(); // 0 1 2// Closing over i without a copy prints 3, 3, 3.
45C# / .NETAdvanced
Advanced: string identity vs equality
Answer
For operands typed as string, == uses value equality. Two different string instances with the same characters compare equal.
ReferenceEquals(a, b) can be false even when a == b is true, unless both references point at the same instance.
The intern pool can make identical literals share one instance, but runtime-built strings are not interned unless you call string.Intern.
If variables are typed as object, == is reference equality. Cast to string or call string.Equals when you mean textual comparison.
Do not build application logic on interning. Interning can increase memory use and is easy to misuse under load.
For keys, ordinal compares, and security-sensitive checks, use string.Equals with an explicit StringComparison such as Ordinal or OrdinalIgnoreCase.
Culture-aware comparisons change with culture settings. Prefer ordinal for machine identifiers and OrdinalIgnoreCase for case-insensitive protocol tokens.
Hash codes for strings must stay consistent with the equality comparer you use in dictionaries and sets.
Flow diagram: Value equal, maybe different instances
C#
string a = newstring("x".ToCharArray());
string b = newstring("x".ToCharArray());
Console.WriteLine(a == b); // True (string ==)Console.WriteLine(ReferenceEquals(a, b)); // Falseobject oa = a, ob = b;
Console.WriteLine(oa == ob); // False (object == is reference)Console.WriteLine(string.Equals(a, b, StringComparison.Ordinal));