Dotnet Interview Questions Part 2

This is Part 2 of my .NET interview study guide. After conducting several technical interviews for .NET positions, I noticed clear patterns in what candidates struggle with the most. This post focuses on those high-failure topics, so if you are preparing for an interview, these are the areas where most people trip up.

Threading & Concurrency

This was by far the weakest area across all interviews. Almost every candidate had significant gaps here.

Thread vs Task

A Thread is an OS-level construct. You create a thread, and the operating system manages its lifecycle. A Task is a higher-level abstraction from the Task Parallel Library (TPL) that represents an asynchronous operation. Tasks use the ThreadPool internally, which means they reuse threads instead of creating new ones.

One important thing: Task is NOT a child of Thread. They are completely separate class hierarchies. Thread lives in System.Threading, while Task lives in System.Threading.Tasks.

1
2
3
4
5
6
7
8
9
10
11
// Thread: OS-level, you manage the lifecycle
var thread = new Thread(() => Console.WriteLine("Hello from thread"));
thread.Start();
thread.Join();

// Task: abstraction over ThreadPool, supports results and cancellation
Task<int> task = Task.Run(() =>
{
return 42;
});
int result = await task;

The key advantage of Tasks is that they support cancellation tokens, continuations, and returning results via Task<T>. With raw threads, you need shared state and manual synchronization for all of that.

async void vs async Task

This is a classic trap. async void methods cannot be awaited, and if they throw an exception, it will crash your entire application because the exception propagates to the SynchronizationContext instead of being captured in a Task.

1
2
3
4
5
6
7
8
9
10
11
12
13
// DANGEROUS: exception crashes the app
async void ProcessDataBad()
{
await Task.Delay(100);
throw new Exception("This will crash the application");
}

// SAFE: exception is captured in the Task
async Task ProcessDataGood()
{
await Task.Delay(100);
throw new Exception("This can be caught by the caller");
}

The only valid use case for async void is event handlers in UI frameworks (like WPF or WinForms), because the event handler signature requires void. For everything else, always use async Task.

Lock, Monitor, Mutex and Semaphore

These are all synchronization primitives, but they serve different purposes. The lock keyword is actually syntactic sugar for Monitor.Enter and Monitor.Exit wrapped in a try/finally block.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static readonly object _lockObj = new object();

// These two are equivalent:
lock (_lockObj)
{
// critical section
}

// Expanded form:
Monitor.Enter(_lockObj);
try
{
// critical section
}
finally
{
Monitor.Exit(_lockObj);
}

Here is a quick comparison:

  • lock / Monitor: Works within a single process. Lightweight and fast. Use this by default.
  • Mutex: Works across processes. OS-level primitive. Use when you need to coordinate between separate applications (e.g., ensuring only one instance of your app runs).
  • Semaphore: Allows N concurrent accesses instead of just one. SemaphoreSlim is the lightweight in-process version.
1
2
3
4
5
6
7
8
9
10
11
12
13
// Mutex: cross-process synchronization
using var mutex = new Mutex(false, "MyAppMutex");
if (mutex.WaitOne(TimeSpan.FromSeconds(5)))
{
try { /* critical section */ }
finally { mutex.ReleaseMutex(); }
}

// SemaphoreSlim: allow up to 3 concurrent accesses
var semaphore = new SemaphoreSlim(3);
await semaphore.WaitAsync();
try { /* up to 3 threads can be here */ }
finally { semaphore.Release(); }

await inside lock

This is a compiler error and many candidates do not know it. You cannot use await inside a lock block because lock requires the same thread to enter and exit the critical section, but await may resume execution on a different thread.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// This will NOT compile
lock (_lockObj)
{
await SomeAsyncMethod(); // CS1996: Cannot await in the body of a lock statement
}

// The correct approach: use SemaphoreSlim
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

await _semaphore.WaitAsync();
try
{
await SomeAsyncMethod();
}
finally
{
_semaphore.Release();
}

Thread-safe Collections

The System.Collections.Concurrent namespace provides collections designed for multi-threaded scenarios. When multiple threads read and write to a shared collection, you should use these instead of wrapping a regular collection with locks.

  • ConcurrentDictionary<TKey, TValue>: Thread-safe dictionary with atomic operations like GetOrAdd and AddOrUpdate.
  • ConcurrentQueue<T>: Thread-safe FIFO queue.
  • ConcurrentBag<T>: Thread-safe unordered collection, optimized for scenarios where the same thread produces and consumes items.
  • BlockingCollection<T>: Wrapper that adds bounding and blocking capabilities. Great for producer-consumer patterns.
1
2
3
4
5
6
7
var cache = new ConcurrentDictionary<string, int>();

// Atomically adds value if key doesn't exist, or returns existing value
int value = cache.GetOrAdd("counter", key => ExpensiveComputation(key));

// Atomically updates or adds
cache.AddOrUpdate("counter", 1, (key, oldValue) => oldValue + 1);

AutoResetEvent vs ManualResetEvent

Both are signaling mechanisms that allow threads to wait for a signal before proceeding. Think of AutoResetEvent as a turnstile: it lets one person through and automatically closes. ManualResetEvent is like a gate: it opens and stays open until you manually close it, letting everyone through.

1
2
3
4
5
6
7
8
9
var autoEvent = new AutoResetEvent(false);
var manualEvent = new ManualResetEvent(false);

// AutoResetEvent: releases ONE waiting thread, then resets automatically
autoEvent.Set(); // one thread proceeds, event resets to non-signaled

// ManualResetEvent: releases ALL waiting threads, stays signaled
manualEvent.Set(); // all waiting threads proceed
manualEvent.Reset(); // must manually reset to block threads again

The volatile keyword

The volatile keyword tells the compiler and CPU to always read the variable from main memory instead of using a cached value. It prevents instruction reordering optimizations around that variable.

However, volatile does NOT make operations atomic. For atomic operations, use the Interlocked class.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private volatile bool _running = true;

public void Stop() => _running = false;

public void DoWork()
{
while (_running)
{
// Without volatile, the compiler might cache _running
// and this loop could run forever even after Stop() is called
}
}

// For atomic increment/decrement, use Interlocked
private int _counter = 0;
Interlocked.Increment(ref _counter);

LINQ Deep Dive

LINQ is one of those topics where candidates think they know it because they use it daily, but they struggle to explain what is happening under the hood.

Deferred vs Immediate Execution

Most LINQ operators like Where, Select, OrderBy, and Take use deferred execution. This means they do not execute immediately. Instead, they build up a pipeline that only runs when you enumerate the result (e.g., with foreach, ToList(), ToArray(), Count(), or First()).

This is NOT the same as Entity Framework lazy loading. Deferred execution is about when the LINQ query runs. Lazy loading is about when EF loads related entities.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var numbers = new List<int> { 1, 2, 3, 4, 5 };

// This does NOT execute yet - it just stores the query definition
var query = numbers.Where(n => n > 2);

// Modify the source AFTER defining the query
numbers.Add(6);

// NOW it executes - and includes 6 because execution was deferred
foreach (var n in query)
Console.Write($"{n} "); // Output: 3 4 5 6

// ToList() forces immediate execution - result is "frozen"
var frozen = numbers.Where(n => n > 2).ToList();
numbers.Add(7);
// frozen still contains [3, 4, 5, 6] - 7 is NOT included

IQueryable vs IEnumerable

This distinction has real performance implications. IEnumerable<T> processes data in memory using LINQ to Objects. IQueryable<T> builds an expression tree that gets translated to SQL and executed on the database server.

If you return IEnumerable<T> from a repository method and then filter it, the entire table is loaded into memory before filtering. If you return IQueryable<T>, the filter is pushed down to the database as a SQL WHERE clause.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// BAD: loads ALL products into memory, then filters in C#
public IEnumerable<Product> GetProducts()
{
return _context.Products; // SELECT * FROM Products (everything loaded)
}
var expensive = repo.GetProducts().Where(p => p.Price > 100); // filtered in C#

// GOOD: filter is translated to SQL WHERE clause
public IQueryable<Product> GetProducts()
{
return _context.Products; // query not executed yet
}
var expensive = repo.GetProducts().Where(p => p.Price > 100);
// SELECT * FROM Products WHERE Price > 100 (filtered at DB level)

Custom LINQ Extension Methods

LINQ methods are just extension methods on IEnumerable<T>. You can write your own using extension method syntax combined with yield return for deferred execution.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static class LinqExtensions
{
public static IEnumerable<T> WhereNot<T>(
this IEnumerable<T> source,
Func<T, bool> predicate)
{
foreach (var item in source)
{
if (!predicate(item))
yield return item;
}
}
}

// Usage:
var nonExpensive = products.WhereNot(p => p.Price > 100);

The yield return keyword makes this method an iterator, which means it uses deferred execution just like the built-in LINQ methods.

Modern C# Features

Many candidates are not up to date with recent C# features. Interviewers love asking about these because they reveal whether a candidate keeps learning.

Primary Constructors (C# 12)

Primary constructors let you declare constructor parameters directly in the class or struct declaration. The parameters are available throughout the entire class body.

One important distinction: unlike records, primary constructors do NOT auto-generate public properties. The parameters are captured but not publicly exposed unless you explicitly assign them.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Primary constructor (C# 12)
public class UserService(IUserRepository repository, ILogger<UserService> logger)
{
public async Task<User?> GetUser(int id)
{
logger.LogInformation("Fetching user {Id}", id);
return await repository.GetByIdAsync(id);
}
}

// Equivalent traditional constructor:
public class UserService
{
private readonly IUserRepository _repository;
private readonly ILogger<UserService> _logger;

public UserService(IUserRepository repository, ILogger<UserService> logger)
{
_repository = repository;
_logger = logger;
}
}

The in Parameter Modifier

The in keyword passes a parameter by reference but makes it read-only inside the method. The method cannot modify the value. It exists for performance when working with large structs, avoiding the cost of copying.

1
2
3
4
5
6
7
8
9
// 'in' = read-only reference. The method CANNOT modify the value.
public double CalculateDistance(in Point3D a, in Point3D b)
{
// a.X = 10; // Compiler error: cannot assign to 'in' parameter
return Math.Sqrt(
Math.Pow(b.X - a.X, 2) +
Math.Pow(b.Y - a.Y, 2) +
Math.Pow(b.Z - a.Z, 2));
}

Quick comparison:

  • in: Read-only reference. Caller’s value is protected.
  • ref: Read-write reference. Method can modify the caller’s value.
  • out: Must be assigned before the method returns. Used for multiple return values.

Covariance and Contravariance

These are about type compatibility in generic types. The out keyword on a generic type parameter means covariance (you can use a more derived type). The in keyword means contravariance (you can use a less derived type).

1
2
3
4
5
6
7
8
9
10
// IEnumerable<out T> is COVARIANT
// Dog is more derived than Animal, so this works:
IEnumerable<Animal> animals = new List<Dog>();

// But List<T> is NOT covariant (it has both input and output positions):
// List<Animal> animals = new List<Dog>(); // COMPILER ERROR

// Action<in T> is CONTRAVARIANT
// Animal is less derived than Dog, so this works:
Action<Dog> feedDog = (Action<Animal>)((animal) => animal.Feed());

The rule of thumb: out = output positions (return types), in = input positions (method parameters).

Boxing and Unboxing

Boxing is wrapping a value type inside an object reference, which moves the value from the stack to the heap. Unboxing is extracting the value type back. Both have performance overhead because they involve memory allocation and copying.

1
2
3
4
5
6
7
8
9
10
11
12
13
int number = 42;
object boxed = number; // Boxing: value copied from stack to heap
int unboxed = (int)boxed; // Unboxing: value copied from heap to stack

// Performance trap: ArrayList uses object, so every int is boxed
var oldList = new ArrayList();
oldList.Add(1); // boxing happens here
oldList.Add(2); // and here

// Generic List<T> avoids boxing entirely
var newList = new List<int>();
newList.Add(1); // no boxing
newList.Add(2); // no boxing

Common places where boxing happens: non-generic collections, string.Format with value types, casting a struct to an interface.

SOLID Principles

The SOLID principles are a set of five design guidelines that help you write maintainable and flexible software. Almost every interview asks about them, and candidates often know the acronyms but struggle to explain them with practical examples.

  • S — Single Responsibility: A class should have one reason to change. If your OrderService handles orders, sends emails, AND writes logs, it has too many responsibilities.
  • O — Open/Closed: Classes should be open for extension but closed for modification. Use interfaces and inheritance to add behavior without changing existing code.
  • L — Liskov Substitution: Subtypes must be substitutable for their base types without breaking the program. The classic violation is Square inheriting from Rectangle and breaking the SetWidth/SetHeight contract.
  • I — Interface Segregation: No client should be forced to depend on methods it does not use. Split large interfaces into smaller, focused ones.
  • D — Dependency Inversion: High-level modules should depend on abstractions, not on concrete implementations. This is the principle behind Dependency Injection.

For detailed examples in C# with code, check out my dedicated post: Principios de Diseno SOLID

Design Patterns

Part 1 listed all 23 GoF patterns. Here is a quick reference with links to my dedicated posts for each pattern I have covered:

Creational Patterns - Deal with object creation mechanisms.

PatternPurposePost
SingletonEnsures a class has only one instance with a global access pointSee example below
Factory MethodDelegates object creation to subclassesRead more
Abstract FactoryCreates families of related objects without specifying concrete classesRead more
BuilderConstructs complex objects step by stepRead more

Structural Patterns - Deal with object composition and relationships.

PatternPurposePost
DecoratorAdds behavior to objects dynamically without modifying themRead more
FacadeProvides a simplified interface to a complex subsystemRead more
CompositeTreats individual objects and compositions uniformly (tree structures)Read more

Behavioral Patterns - Deal with communication between objects.

PatternPurposePost
ObserverNotifies dependents automatically when state changes (pub/sub)Read more
StrategyEncapsulates interchangeable algorithms that can be swapped at runtimeRead more
StateAllows an object to change behavior when its internal state changesRead more
MediatorReduces direct dependencies between objects. MediatR is a popular .NET library for this, commonly used with CQRS-

Singleton is the most commonly asked pattern in interviews. In modern .NET, you should prefer registering services with AddSingleton in the DI container. But if you need to implement it manually, use Lazy<T> for thread safety:

1
2
3
4
5
6
7
8
9
public sealed class ConnectionManager
{
private static readonly Lazy<ConnectionManager> _instance =
new Lazy<ConnectionManager>(() => new ConnectionManager());

public static ConnectionManager Instance => _instance.Value;

private ConnectionManager() { }
}

Memory Management & Garbage Collector

Part 1 covered the basics of garbage collection. Let’s go deeper into the topics that interviewers expect senior candidates to know.

GC Generations

The .NET Garbage Collector uses a generational approach. Objects are divided into three generations based on their lifetime:

  • Gen 0: Short-lived objects. Most objects die here. Collection is very fast.
  • Gen 1: A buffer between short-lived and long-lived objects.
  • Gen 2: Long-lived objects like static data, caches, and objects that survived multiple collections. Collection is expensive.

Objects that survive a collection are promoted to the next generation. The GC collects Gen 0 frequently and Gen 2 rarely.

1
2
3
4
5
6
7
8
var obj = new object();
Console.WriteLine(GC.GetGeneration(obj)); // 0

GC.Collect();
Console.WriteLine(GC.GetGeneration(obj)); // 1

GC.Collect();
Console.WriteLine(GC.GetGeneration(obj)); // 2

Finalization Queue and IDisposable

Objects that have a finalizer (~ClassName) are placed on the finalization queue, which delays their garbage collection by at least one extra generation. This is why the proper Dispose pattern calls GC.SuppressFinalize(this) — to remove the object from the finalization queue and allow it to be collected sooner.

Here is the full Dispose pattern:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class DatabaseConnection : IDisposable
{
private bool _disposed = false;
private IntPtr _handle; // unmanaged resource

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // remove from finalization queue
}

protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// Free managed resources (other IDisposable objects)
}
// Free unmanaged resources
CloseHandle(_handle);
_disposed = true;
}
}

~DatabaseConnection() // Finalizer: called by GC if Dispose was not called
{
Dispose(false);
}
}

The using statement automatically calls Dispose() at the end of the scope:

1
2
using var connection = new DatabaseConnection();
// connection.Dispose() is called automatically when scope ends

Memory Profiling Tools

When you need to investigate memory issues in .NET, these are the main tools:

  • dotnet-counters: Real-time monitoring of GC, CPU, and memory counters.
  • dotnet-dump: Captures and analyzes process dumps for memory leaks.
  • dotnet-trace: Collects diagnostic traces for performance analysis.
  • Visual Studio Diagnostic Tools: Built-in memory profiler with heap snapshots.
  • BenchmarkDotNet: Measures allocations and performance of code benchmarks.

SQL Advanced Concepts

Database knowledge was a common weak point. These are the topics candidates struggled with the most.

ACID Principles

ACID defines the four guarantees that relational databases provide for transactions:

  • Atomicity: All operations in a transaction either succeed together or fail together. If transferring money from account A to B, both the debit and credit must happen, or neither does.
  • Consistency: A transaction takes the database from one valid state to another. All constraints, foreign keys, and rules are respected.
  • Isolation: Concurrent transactions do not interfere with each other. Each transaction sees a consistent snapshot of the data.
  • Durability: Once a transaction is committed, the data is permanently saved even if the system crashes.

Transaction Isolation Levels

Isolation levels control how much a transaction is isolated from other concurrent transactions. Higher isolation means fewer anomalies but more locking and lower throughput.

1
2
3
4
5
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN TRANSACTION;
SELECT * FROM Products WHERE Price > 100;
-- other operations
COMMIT;
Isolation LevelDirty ReadNon-Repeatable ReadPhantom Read
Read UncommittedPossiblePossiblePossible
Read Committed (default)PreventedPossiblePossible
Repeatable ReadPreventedPreventedPossible
SerializablePreventedPreventedPrevented
  • Dirty Read: Reading data that another transaction has modified but not yet committed.
  • Non-Repeatable Read: Reading the same row twice and getting different values because another transaction modified it.
  • Phantom Read: Running the same query twice and getting different rows because another transaction inserted or deleted rows.

Many-to-Many Relationships

In relational databases, many-to-many relationships require a junction table (also called bridge or join table). For example, a Student can enroll in many Courses, and a Course can have many Students.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CREATE TABLE Students (
Id INT PRIMARY KEY,
Name NVARCHAR(100)
);

CREATE TABLE Courses (
Id INT PRIMARY KEY,
Title NVARCHAR(200)
);

-- Junction table
CREATE TABLE StudentCourses (
StudentId INT FOREIGN KEY REFERENCES Students(Id),
CourseId INT FOREIGN KEY REFERENCES Courses(Id),
PRIMARY KEY (StudentId, CourseId)
);

In EF Core 5+, you can configure this with skip navigations, so you do not need to explicitly create the junction entity:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Student
{
public int Id { get; set; }
public string Name { get; set; }
public List<Course> Courses { get; set; }
}

public class Course
{
public int Id { get; set; }
public string Title { get; set; }
public List<Student> Students { get; set; }
}

ASP.NET Core Advanced

Middleware vs Filters

Both are part of the request pipeline, but they operate at different levels.

Middleware runs on every HTTP request and works with raw HttpContext. It is configured in Program.cs and executes before the MVC framework even knows which controller to call. Use middleware for cross-cutting concerns like logging, CORS, authentication, and response compression.

Filters operate within the MVC pipeline and only run for requests that reach a controller action. They have access to MVC-specific context like ActionContext. Use filters for authorization, model validation, and action-level exception handling.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Custom Middleware
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;

public RequestTimingMiddleware(RequestDelegate next) => _next = next;

public async Task InvokeAsync(HttpContext context)
{
var sw = Stopwatch.StartNew();
await _next(context); // call the next middleware
sw.Stop();
context.Response.Headers["X-Response-Time"] = $"{sw.ElapsedMilliseconds}ms";
}
}

// Custom Action Filter
public class ValidateModelFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
context.Result = new BadRequestObjectResult(context.ModelState);
}

public void OnActionExecuted(ActionExecutedContext context) { }
}

Background Processing

When you need to run long-running tasks in the background (like processing queues, scheduled jobs, or health monitoring), use IHostedService or its simpler abstract base class BackgroundService.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class QueueProcessorService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;

public QueueProcessorService(IServiceScopeFactory scopeFactory)
=> _scopeFactory = scopeFactory;

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<IQueueProcessor>();
await processor.ProcessNextBatchAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}

// Register in Program.cs:
builder.Services.AddHostedService<QueueProcessorService>();

IMemoryCache vs IDistributedCache

ASP.NET Core provides two built-in caching abstractions:

IMemoryCache stores data in the application process memory. It is fast but the cache is lost on restart and is not shared across multiple app instances.

IDistributedCache uses an external store like Redis or SQL Server. Data persists across restarts and is shared across all instances of your application.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// IMemoryCache: in-process, fast, single instance
public class ProductService
{
private readonly IMemoryCache _cache;

public Product GetProduct(int id)
{
return _cache.GetOrCreate($"product:{id}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
return _repository.GetById(id);
});
}
}

// IDistributedCache: external store, shared across instances
public class SessionService
{
private readonly IDistributedCache _cache;

public async Task<string?> GetSessionData(string key)
{
return await _cache.GetStringAsync(key);
}

public async Task SetSessionData(string key, string value)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
};
await _cache.SetStringAsync(key, value, options);
}
}

Health Checks

ASP.NET Core has built-in support for health check endpoints. You can check your application’s dependencies (databases, external APIs, disk space) and expose a /health endpoint for load balancers and monitoring tools.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Program.cs
builder.Services.AddHealthChecks()
.AddSqlServer(connectionString)
.AddCheck<CustomApiHealthCheck>("external-api");

app.MapHealthChecks("/health");

// Custom health check
public class CustomApiHealthCheck : IHealthCheck
{
private readonly HttpClient _client;

public CustomApiHealthCheck(HttpClient client) => _client = client;

public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var response = await _client.GetAsync("/ping", cancellationToken);
return response.IsSuccessStatusCode
? HealthCheckResult.Healthy()
: HealthCheckResult.Unhealthy("API is not responding");
}
}

Entity Framework Core Advanced

AsNoTracking and the Change Tracker

By default, EF Core tracks all queried entities in the Change Tracker. This has memory and performance overhead because EF keeps a snapshot of every entity to detect changes at SaveChanges() time.

For read-only queries, use .AsNoTracking() to skip tracking and improve performance:

1
2
3
4
5
6
7
// Tracked: EF monitors this entity for changes (slower)
var product = await _context.Products.FirstOrDefaultAsync(p => p.Id == id);

// Not tracked: read-only, faster, less memory
var product = await _context.Products
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == id);

Do NOT use AsNoTracking when you intend to update the entity, because EF will not detect the changes.

Calling Stored Procedures in EF Core

Use FromSqlInterpolated for queries that return entities and ExecuteSqlInterpolated for non-query operations like INSERT, UPDATE, or DELETE.

1
2
3
4
5
6
7
8
// Query that returns entities
var products = await _context.Products
.FromSqlInterpolated($"EXEC GetProductsByCategory {categoryId}")
.ToListAsync();

// Non-query operation
await _context.Database
.ExecuteSqlInterpolatedAsync($"EXEC UpdatePrices {percentage}");

Always use the interpolated versions instead of FromSqlRaw to avoid SQL injection — EF automatically parameterizes the interpolated values.

Inheritance Mapping (TPH, TPT, TPC)

EF Core supports three strategies for mapping class hierarchies to database tables:

  • TPH (Table Per Hierarchy): All types in one table with a discriminator column. This is the default in EF Core. Fast queries but lots of nullable columns.
  • TPT (Table Per Type): One table per class in the hierarchy, joined by foreign key. Better normalization but slower queries due to JOINs.
  • TPC (Table Per Concrete type): One table per concrete class, no base table. No nullable columns, no JOINs, but data duplication for shared properties.
1
2
3
4
5
6
7
8
9
10
11
12
// TPH configuration (default)
modelBuilder.Entity<Payment>()
.HasDiscriminator<string>("PaymentType")
.HasValue<CreditCardPayment>("CreditCard")
.HasValue<BankTransferPayment>("BankTransfer");

// TPT configuration
modelBuilder.Entity<CreditCardPayment>().ToTable("CreditCardPayments");
modelBuilder.Entity<BankTransferPayment>().ToTable("BankTransferPayments");

// TPC configuration (EF Core 7+)
modelBuilder.Entity<Payment>().UseTpcMappingStrategy();

Exception Handling

This was listed as an interview topic in Part 1 but never covered. Here are the key concepts.

Exception Hierarchy

.NET exceptions inherit from System.Exception. The two main branches are SystemException (runtime errors like NullReferenceException, InvalidOperationException) and ApplicationException (originally intended for user-defined exceptions, but Microsoft now recommends deriving directly from Exception instead).

The golden rule: catch specific exceptions, not general ones.

1
2
3
4
5
6
7
8
// BAD: swallows everything, including OutOfMemoryException
try { DoWork(); }
catch (Exception) { }

// GOOD: catch only what you can handle
try { DoWork(); }
catch (HttpRequestException ex) { logger.LogWarning(ex, "API call failed"); }
catch (TimeoutException ex) { logger.LogError(ex, "Operation timed out"); }

Exception Filters

C# 6 introduced exception filters using the when keyword. The filter runs before the stack unwinds, so you can inspect the exception without catching and re-throwing it.

1
2
3
4
5
6
7
8
9
10
11
12
13
try
{
await httpClient.GetAsync(url);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null; // handle 404 specifically
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
await Task.Delay(1000);
// retry logic
}

Global Exception Handling in ASP.NET Core

For API applications, you should handle unhandled exceptions globally to return consistent error responses. In .NET 8+, use IExceptionHandler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;

public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
=> _logger = logger;

public async ValueTask<bool> TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception, "Unhandled exception");

context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An error occurred",
Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1"
}, cancellationToken);

return true;
}
}

// Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();

Architecture & Patterns

The Outbox Pattern

The Outbox pattern solves a common problem: when you need to save data to a database AND publish a message/event, one might succeed while the other fails, leaving your system in an inconsistent state.

The solution is to store the outgoing message in an outbox table within the same database transaction as your business data. A separate background process polls the outbox and publishes the messages.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Save business data AND outbox message in the same transaction
public async Task PlaceOrderAsync(Order order)
{
order.Status = OrderStatus.Placed;
_context.Orders.Add(order);

_context.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
Type = "OrderPlaced",
Payload = JsonSerializer.Serialize(new OrderPlacedEvent(order.Id)),
CreatedAt = DateTime.UtcNow,
Processed = false
});

await _context.SaveChangesAsync(); // single transaction
}

// Background service publishes outbox messages
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var messages = await _context.OutboxMessages
.Where(m => !m.Processed)
.OrderBy(m => m.CreatedAt)
.Take(50)
.ToListAsync(stoppingToken);

foreach (var msg in messages)
{
await _messageBus.PublishAsync(msg.Type, msg.Payload);
msg.Processed = true;
}
await _context.SaveChangesAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}

.NET Standard

.NET Standard is a specification (not a runtime or framework) that defines a set of APIs that all .NET implementations must support. It exists so that libraries can target multiple platforms (.NET Framework, .NET Core, Xamarin) with a single binary.

.NET Standard 2.0 is the most widely supported version and covers most APIs you would need.

With the unification of .NET starting from .NET 5, new libraries should target net6.0 or later directly instead of .NET Standard, unless they need to support .NET Framework as well.

Caching Strategies

  • Cache-Aside (Lazy Loading): The application checks the cache first. On a miss, it loads from the database, stores in cache, and returns. Most common pattern.
  • Write-Through: Data is written to the cache and the database simultaneously. Ensures the cache is always up to date.
  • Write-Behind (Write-Back): Data is written to the cache immediately and to the database asynchronously. Faster writes but risk of data loss.

The hardest part of caching is cache invalidation — knowing when to remove or update cached data. Common approaches include time-based expiration (TTL), event-based invalidation, and versioned keys.

Testing

Testing was a universal weak spot. Most candidates knew what unit tests were but struggled with testing methodology and principles.

BDD vs TDD

TDD (Test-Driven Development) follows the Red-Green-Refactor cycle: write a failing test first, write the minimum code to pass it, then refactor.

BDD (Behavior-Driven Development) extends TDD by writing tests in business-readable language using the Given/When/Then format. BDD bridges the gap between developers and stakeholders.

1
2
3
4
5
6
7
8
9
10
11
12
13
// TDD style (xUnit)
[Fact]
public void Withdraw_SufficientFunds_ReducesBalance()
{
var account = new BankAccount(100);
account.Withdraw(30);
Assert.Equal(70, account.Balance);
}

// BDD style (SpecFlow / Gherkin)
// Given a bank account with a balance of $100
// When I withdraw $30
// Then the balance should be $70

In .NET, use xUnit, NUnit, or MSTest for TDD. Use SpecFlow for BDD.

FIRST Principles

These are the five qualities of good unit tests:

  • Fast: Tests should run in milliseconds. Slow tests discourage developers from running them.
  • Isolated/Independent: Each test should be self-contained. No test should depend on the result of another test.
  • Repeatable: Running the same test multiple times should always produce the same result, regardless of environment.
  • Self-validating: Tests should pass or fail without human inspection. No manual checking of output.
  • Timely: Tests should be written close in time to the production code, ideally before it (TDD).

Flaky Tests

A flaky test is one that passes and fails intermittently without any code changes. Flaky tests erode trust in your test suite.

Common causes:

  • Shared mutable state between tests
  • Timing-dependent assertions (e.g., Thread.Sleep then check)
  • External service dependencies (APIs, databases)
  • Test order dependency

How to fix:

  • Isolate test state (fresh setup for each test)
  • Use deterministic clocks and fakes instead of real time
  • Mock external dependencies
  • Quarantine known flaky tests and fix them as a priority

Common Live Coding Patterns

Interviews often include live coding exercises. Here are the patterns candidates fail the most.

Valid Parentheses

This is a classic problem: given a string containing brackets ()[]{}, determine if the brackets are properly matched and nested. The correct approach uses a Stack, not a counter.

A counter fails on cases like )( — the counter reaches zero, but the string is clearly invalid. It also fails on mixed brackets like [(]).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static bool IsValid(string s)
{
var stack = new Stack<char>();
var pairs = new Dictionary<char, char>
{
{ ')', '(' },
{ ']', '[' },
{ '}', '{' }
};

foreach (char c in s)
{
if (pairs.ContainsValue(c))
{
stack.Push(c);
}
else if (pairs.ContainsKey(c))
{
if (stack.Count == 0 || stack.Pop() != pairs[c])
return false;
}
}

return stack.Count == 0;
}

Null Handling in Modern C#

Modern C# provides several operators and patterns for safe null handling. Candidates often struggle with basic null checks in live coding, especially with value types.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Null-conditional operator: short-circuits on null
string? city = user?.Address?.City;

// Null-coalescing operator: provides a default
string name = user?.Name ?? "Unknown";

// Null-coalescing assignment: assigns only if null
List<string>? items = null;
items ??= new List<string>();

// ArgumentNullException.ThrowIfNull (C# 10+)
public void Process(Order order)
{
ArgumentNullException.ThrowIfNull(order);
// proceed safely
}

// Pattern matching
if (result is not null)
{
Console.WriteLine(result.Value);
}

Thread Safety in Static Methods

A common misconception is that static methods run on a single thread and are therefore always thread-safe. This is wrong. A static method is thread-safe only if it does not access shared mutable state.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// THREAD-SAFE: pure function, no shared state
public static int Add(int a, int b) => a + b;

// NOT THREAD-SAFE: modifies shared static field
private static int _counter = 0;
public static void Increment()
{
_counter++; // race condition! Multiple threads can read/write simultaneously
}

// Fix: use Interlocked for atomic operations
public static void IncrementSafe()
{
Interlocked.Increment(ref _counter);
}

Anonymous Methods vs Lambda Expressions

Anonymous methods use the delegate keyword and were introduced in C# 2.0. Lambda expressions (=>) were introduced in C# 3.0 as a more concise syntax. Lambdas also support expression trees, which is what makes LINQ to SQL possible.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Anonymous method (C# 2.0)
Func<int, int, int> add1 = delegate(int a, int b) { return a + b; };

// Lambda expression (C# 3.0) - same thing, shorter syntax
Func<int, int, int> add2 = (a, b) => a + b;

// Expression lambda (single expression, implicit return)
Func<int, bool> isEven = n => n % 2 == 0;

// Statement lambda (block body, explicit return)
Func<int, bool> isPositive = n =>
{
Console.WriteLine($"Checking {n}");
return n > 0;
};

In practice, lambda expressions have almost entirely replaced anonymous methods. The main conceptual difference is that lambdas can be converted to expression trees (Expression<Func<T>>), which is how EF Core translates LINQ queries into SQL.