C# Fundamentals That Trip People Up
February 3, 2025 · 10 min read
C#, .NET, Interview Prep, Backend
Series Introduction
This is the first post in my C# and .NET interview preparation series. After years of conducting and taking interviews, I've noticed the same topics come up repeatedly - and the same gotchas trip people up.
Each post follows a consistent format:
- TL;DR — Quick answer for the impatient
- The Real Explanation — What you need to understand
- Code That Works — Practical examples
- The Gotcha — What trips people up
- Interview Tip — How to answer it well
Let's dive into the fundamentals that seem simple but have depth.
Value Types vs Reference Types — It's Not What You Think
TL;DR
Value types live on the stack (usually), reference types live on the heap. But the real gotcha is understanding when this matters and when it doesn't.
The Real Explanation
// Value type - copied when assigned
int a = 5;
int b = a; // b gets a COPY of 5
b = 10; // a is still 5
// Reference type - reference copied, not the object
var list1 = new List<int> { 1, 2, 3 };
var list2 = list1; // list2 points to SAME list
list2.Add(4); // list1 now has 4 items too!
Value types: int, double, bool, char, decimal, struct, enum
Reference types: class, string, array, delegate, interface
The Gotcha
This one bit me hard early in my career when I was working on a geometry library:
public struct Point
{
public int X;
public int Y;
}
var points = new List<Point> { new Point { X = 1, Y = 2 } };
// This WON'T compile:
// points[0].X = 5; // Error: Cannot modify return value
// Because the indexer returns a COPY of the struct
// You have to do this instead:
var p = points[0];
p.X = 5;
points[0] = p;
I spent an embarrassing amount of time debugging why my point modifications weren't persisting. The struct was being copied on every access.
String is immutable but reference type:
string s1 = "hello";
string s2 = s1;
s2 = "world"; // s1 is still "hello"
// Strings are immutable - "world" creates a NEW string object
Interview Tip
Don't just say "stack vs heap." Mention:
- Value types are copied on assignment
- Structs should be small and immutable (less than 16 bytes ideally)
- Boxing/unboxing penalty when putting value types in object
- String behaves like a value type due to immutability
The ref, out, and in Keywords — When to Use What
TL;DR
ref— Pass by reference, must be initialized before callout— Pass by reference, must be assigned inside methodin— Pass by reference but read-only (performance optimization)
The Real Explanation
// ref - two-way communication
public void AddTen(ref int number)
{
number += 10;
}
int x = 5;
AddTen(ref x); // x is now 15
// out - method MUST assign a value
public bool TryParse(string input, out int result)
{
if (int.TryParse(input, out result))
return true;
result = 0; // MUST assign even on failure
return false;
}
// Modern C# - declare inline
if (int.TryParse("123", out int parsed))
{
Console.WriteLine(parsed);
}
// in - read-only reference (avoid copying large structs)
public double CalculateDistance(in Point3D p1, in Point3D p2)
{
// p1.X = 5; // Error: cannot modify 'in' parameter
return Math.Sqrt(
Math.Pow(p2.X - p1.X, 2) +
Math.Pow(p2.Y - p1.Y, 2) +
Math.Pow(p2.Z - p1.Z, 2)
);
}
The Gotcha
// This compiles but creates a DEFENSIVE COPY
public readonly struct ImmutablePoint
{
public readonly int X;
public readonly int Y;
public int Sum() => X + Y;
}
public void Process(in ImmutablePoint point)
{
// If the struct has ANY non-readonly methods,
// the compiler creates a defensive copy to prevent mutation
// Defeats the performance purpose of 'in'
}
Always use readonly struct with in parameters.
I learned this the hard way when profiling a physics simulation - my "optimization" with in was actually creating more copies than before because the struct wasn't marked readonly.
Interview Tip
Know the TryParse pattern cold — it's the most common out usage. For bonus points, mention that in is primarily for large structs (like Matrix4x4 in game dev) to avoid copying 64+ bytes.
Nullable Reference Types — The C# 8+ Game Changer
TL;DR
Enable nullable reference types. Treat compiler warnings as errors. Your future self will thank you.
The Real Explanation
#nullable enable
// Now the compiler tracks null
string name = null; // Warning: assigning null to non-nullable
string? nullableName = null; // Fine - explicitly nullable
public string GetDisplayName(User? user)
{
// Warning: possible null reference
// return user.Name;
// Option 1: Null check
if (user is null)
return "Anonymous";
// Option 2: Null-conditional
return user?.Name ?? "Anonymous";
// Option 3: Pattern matching (my favorite)
return user switch
{
{ Name: var n } => n,
_ => "Anonymous"
};
}
The Gotcha
// The null-forgiving operator (!) is a lie to the compiler
string? maybeNull = GetPossiblyNullString();
string definitelyNotNull = maybeNull!; // Compiler trusts you
// Runtime: NullReferenceException if maybeNull was null
// Don't use ! unless you KNOW it's not null
// Better:
string safe = maybeNull ?? throw new ArgumentNullException();
EF Core gotcha:
public class User
{
public int Id { get; set; }
// These will NEVER be null after EF loads them
// but the compiler doesn't know that
public string Name { get; set; } = null!; // Common pattern
public string Email { get; set; } = default!; // Alternative
// Navigation properties
public List<Order> Orders { get; set; } = new(); // Initialize to avoid null
}
When I enabled nullable reference types on an existing project at work, we found about 30 potential null reference bugs that had been lurking. Worth the migration effort.
Interview Tip
Say you enable nullable reference types on all new projects and explain the = null! pattern for ORM entities. Shows you understand both the feature and its practical limitations.
async/await — The Most Misunderstood Feature
TL;DR
async doesn't make code run on a different thread. await yields control until the task completes. Always use ConfigureAwait(false) in library code.
The Real Explanation
// This does NOT create a new thread
public async Task<string> GetDataAsync()
{
// Synchronous until the await
Console.WriteLine($"Before: Thread {Thread.CurrentThread.ManagedThreadId}");
// Yields here - no thread blocked
var data = await httpClient.GetStringAsync("https://api.example.com/data");
// Resumes (possibly on different thread in non-UI contexts)
Console.WriteLine($"After: Thread {Thread.CurrentThread.ManagedThreadId}");
return data;
}
The Gotcha
This is the classic deadlock scenario - I've seen it take down production services:
// DEADLOCK in UI/ASP.NET (pre-Core) apps
public string GetDataSync()
{
// DON'T DO THIS
return GetDataAsync().Result; // Blocks the thread
// The async continuation needs this thread
// But it's blocked waiting for the result
// = Deadlock
}
// Fix 1: Use async all the way down
public async Task<string> GetDataAsync()
{
return await GetDataAsync();
}
// Fix 2: ConfigureAwait(false) in library code
public async Task<string> GetDataAsync()
{
return await httpClient.GetStringAsync(url).ConfigureAwait(false);
}
// Fix 3 (last resort): Use Task.Run
public string GetDataSync()
{
return Task.Run(() => GetDataAsync()).Result;
}
The async void trap:
// NEVER do this (except event handlers)
public async void DoSomethingAsync()
{
await Task.Delay(1000);
throw new Exception("This exception vanishes into the void");
}
// Always return Task
public async Task DoSomethingAsync()
{
await Task.Delay(1000);
}
Production War Story
At a previous job, we had a service that would occasionally hang with no errors. Turned out someone called .Result on an async method in a synchronization context. The deadlock was intermittent because it only happened under specific load patterns. Took us two weeks to find.
Interview Tip
The deadlock scenario is a classic interview question. Explain the synchronization context, why .Result blocks, and why ConfigureAwait(false) breaks the deadlock. In ASP.NET Core, there's no sync context, so it's less of an issue - but library code should still use ConfigureAwait(false).
IEnumerable vs IQueryable — The Database Query Killer
TL;DR
IEnumerable executes in memory. IQueryable builds an expression tree that executes on the database. Wrong choice = loading entire tables into memory.
The Real Explanation
// IQueryable - builds SQL query
IQueryable<User> query = dbContext.Users
.Where(u => u.IsActive) // Added to SQL WHERE
.OrderBy(u => u.Name) // Added to SQL ORDER BY
.Take(10); // Added to SQL TOP/LIMIT
// SQL: SELECT TOP 10 * FROM Users WHERE IsActive = 1 ORDER BY Name
// IEnumerable - executes in memory
IEnumerable<User> allUsers = dbContext.Users; // Loads ALL users
var filtered = allUsers
.Where(u => u.IsActive) // Filters in C#
.OrderBy(u => u.Name) // Sorts in C#
.Take(10); // Takes in C#
// Loaded entire Users table just to get 10 rows!
The Gotcha
// This innocent-looking code is a disaster
public IEnumerable<User> GetActiveUsers()
{
return dbContext.Users.Where(u => u.IsActive);
// Returns IQueryable, but cast to IEnumerable
}
// Later...
var users = GetActiveUsers();
var admins = users.Where(u => u.Role == "Admin"); // IN MEMORY!
// The IEnumerable return type broke the query composition
// Fix: Return IQueryable when query composition is needed
public IQueryable<User> GetActiveUsers()
{
return dbContext.Users.Where(u => u.IsActive);
}
Deferred execution gotcha:
// Both IEnumerable and IQueryable use deferred execution
var query = dbContext.Users.Where(u => u.IsActive);
// Query hasn't executed yet!
// It executes when you:
var list = query.ToList(); // Materialize
var count = query.Count(); // Execute aggregate
var first = query.FirstOrDefault(); // Execute
foreach (var user in query) { } // Iterate
// Multiple enumerations = multiple database calls
var count = query.Count(); // Database call
var list = query.ToList(); // Another database call!
I once reviewed code where a developer returned IEnumerable<T> from a repository method. The calling code was chaining .Where() and .OrderBy() expecting database optimization. We were pulling 500,000 rows into memory and filtering in C#. Changing the return type to IQueryable<T> reduced query time from 30 seconds to 50 milliseconds.
Interview Tip
If asked about performance problems with EF Core, this is often the answer. Show you understand expression trees, deferred execution, and the AsEnumerable() / AsQueryable() boundary.
Summary
These fundamentals seem basic but have surprising depth. In interviews, showing you understand the why behind these features - not just the what - separates you from other candidates.
Key takeaways:
| Topic | The Trap | The Fix |
|---|---|---|
| Value/Reference | Structs in collections are copied | Use classes or handle copies explicitly |
| Nullable | The ! operator hides bugs | Proper null checks, don't abuse ! |
| async/await | .Result causes deadlocks | async all the way, ConfigureAwait(false) |
| IEnumerable/IQueryable | Wrong return type kills perf | Return IQueryable for composable queries |
Next up: Object-Oriented Design in C# - abstract classes, interfaces, SOLID principles, and dependency injection.
Part 1 of the C# Interview Prep series.