DEV Community

Manohari Jayachandran
Manohari Jayachandran

Posted on

C# Coding Interview Prep Part 2: LINQ, Generics, Delegates, Interfaces, Garbage Collection, and IEnumerable vs IQueryable

Part 1 covered the basics - variables, strings, collections, control flow, methods. This part covers the concepts that separate "I can write C#" from "I understand what's actually happening underneath" - the topics that come up constantly in real coding interviews and rarely get explained clearly in one place. Same format as Part 1 throughout: a plain-English explanation, an analogy, a real code example, and a practice question for every topic.

Topic 1: LINQ and Deferred Execution

LINQ, Language Integrated Query, lets you query collections using a consistent, readable syntax. The critical concept most people miss: LINQ queries do not execute when defined - they execute when iterated with foreach or materialized with ToList, Count, or First.

Think of writing a shopping list versus actually going to the store. Defining a LINQ query is writing the list - nothing happens yet. The query only actually runs the moment you walk into the store and start picking items off the shelf, which is iterating the results.

var posts = GetAllPosts(); // List<Post>

// This line does NOT query anything yet - it just
// builds up a description of what to do
var query = posts.Where(p => p.IsPublished);

// The query actually EXECUTES here, when iterated
foreach (var post in query) { }

// Or when materialized
var list = query.ToList();   // executes now
var count = query.Count();   // executes now

// Why this matters - data can change between
// definition and execution
posts.Add(new Post { IsPublished = true, Title = "New" });
var results = query.ToList();  // includes the new post,
                                 // because the query only
                                 // ran just now, AFTER the add

// Common LINQ methods
var published = posts.Where(p => p.IsPublished);
var titles = posts.Select(p => p.Title);
var sorted = posts.OrderByDescending(p => p.CreatedAt);
var first = posts.FirstOrDefault(p => p.Slug == "csharp-basics");
var grouped = posts.GroupBy(p => p.Tech);
var any = posts.Any(p => p.Tech == "Azure");
var total = posts.Sum(p => p.ReadingTime);
Enter fullscreen mode Exit fullscreen mode

Practice question: Write a LINQ query that groups a list of posts by their Tech tag, then returns only the groups with more than 2 posts.

Topic 2: IEnumerable vs IQueryable

This is the distinction that matters enormously once Entity Framework is involved. IEnumerable represents an in-memory sequence - once data is loaded, filtering happens in application memory. IQueryable represents a query that can be translated into another language, SQL for a database, and executed at the source, with the actual filtering happening there instead.

Think of IEnumerable as receiving an entire filing cabinet's contents shipped to your desk, then sorting through the papers yourself. IQueryable is like sending instructions to the filing clerk - "bring me only the folders from 2026" - so the clerk does the filtering before anything is even shipped to you.

// IQueryable - this does NOT hit the database yet,
// it builds an expression tree describing the query
IQueryable<Post> query = dbContext.Posts
    .Where(p => p.IsPublished);

// Still IQueryable - EF Core keeps building the SQL
query = query.Where(p => p.Tech == "Azure");

// NOW it executes - EF Core translates the WHOLE
// thing into ONE SQL query, filtering happens
// IN THE DATABASE
var results = query.ToList();
// SQL: SELECT * FROM Posts
//      WHERE IsPublished = 1 AND Tech = 'Azure'
Enter fullscreen mode Exit fullscreen mode
// THE MISTAKE - calling ToList() too early
IEnumerable<Post> earlyList = dbContext.Posts.ToList();
// ^ This ALREADY hit the database and loaded
//   EVERY post into memory, no filtering applied yet

var filtered = earlyList.Where(p => p.Tech == "Azure");
// This filtering now happens IN MEMORY, in C#,
// AFTER all posts were already pulled from SQL -
// far more data transferred than necessary

// The rule: keep building your query with IQueryable
// as long as possible, call ToList()/ToListAsync()
// LAST, once every filter is already applied
Enter fullscreen mode Exit fullscreen mode

Practice question: Given a method that accepts IQueryable as a parameter, explain what happens differently if that method internally calls .ToList() before applying additional filters, versus applying filters first and calling .ToList() last.

Topic 3: Generics

Generics let you write one class or method that works safely across many types, without duplicating code for each type or giving up compile-time type checking by using object.

// Generic class - T is decided at the moment of use
public class Box<T>
{
    private T _item;
    public void Store(T item) => _item = item;
    public T Retrieve() => _item;
}

var postBox = new Box<Post>();
var numberBox = new Box<int>();

// Generic method
public T GetFirstOrDefault<T>(List<T> items)
{
    return items.Count > 0 ? items[0] : default(T);
}

// Constraints - restricting what T can be
public class Repository<T> where T : class, new()
{
    public T CreateNew() => new T();
    // class = T must be a reference type
    // new() = T must have a parameterless constructor,
    //         which is what makes "new T()" legal here
}

public T GetMax<T>(List<T> items) where T : IComparable<T>
{
    T max = items[0];
    foreach (var item in items)
        if (item.CompareTo(max) > 0) max = item;
    return max;
    // IComparable<T> constraint is what makes
    // .CompareTo() legal to call here
}
Enter fullscreen mode Exit fullscreen mode

Practice question: Write a generic method Swap that takes two ref parameters of type T and swaps their values.

Topic 4: Delegates and Events

A delegate is a type-safe reference to a method - it lets you pass behavior around as if it were data. Events build on delegates to create a publish-subscribe relationship where a publisher notifies subscribers without knowing who they are.

// Built-in delegate types cover almost every case
Func<int, int, int> add = (a, b) => a + b;   // has a return value
Action<string> log = msg => Console.WriteLine(msg); // no return value
Predicate<int> isEven = n => n % 2 == 0;      // always returns bool

Console.WriteLine(add(3, 4));  // 7
log("Hello");

// Every LINQ lambda is secretly a delegate
posts.Where(p => p.IsPublished);  // this lambda IS a
                                     // Func<Post, bool>

// Events - a delegate only the publisher can raise
public class PostPublisher
{
    public event EventHandler<string> PostPublished;

    public void Publish(string title)
    {
        PostPublished?.Invoke(this, title);
        // ?. prevents a crash if nobody subscribed
    }
}

var publisher = new PostPublisher();
publisher.PostPublished += (sender, title) =>
    Console.WriteLine($"Published: {title}");

publisher.Publish("New Post");  // triggers the subscriber
Enter fullscreen mode Exit fullscreen mode

Practice question: What's the actual difference between a delegate and an event? Why can't code outside the publisher class directly call PostPublished.Invoke()?

Topic 5: Interfaces vs Abstract Classes

Both define a contract other classes must fulfill, but an interface has no implementation of its own, traditionally, and a class can implement many interfaces, while an abstract class can provide shared, partial implementation, and a class can inherit from only one.

public interface IShape
{
    double GetArea();  // no implementation - a pure contract
}

public abstract class ShapeBase
{
    public string Name { get; set; }

    // Shared, concrete implementation every subclass gets for free
    public void PrintName() => Console.WriteLine(Name);

    // Abstract member - subclasses MUST provide this
    public abstract double GetArea();
}

public class Circle : ShapeBase, IShape
{
    public double Radius { get; set; }
    public override double GetArea() => Math.PI * Radius * Radius;
}

// A class can implement MANY interfaces
public class Employee : IWorkable, IFeedable, IRestable { }

// But only ONE base class
public class Manager : Employee /* cannot also : Contractor */ { }
Enter fullscreen mode Exit fullscreen mode

Reach for an interface when unrelated classes need to fulfill the same contract with no shared code - a Circle and a Square both have an area but share nothing else. Reach for an abstract class when related classes genuinely share common implementation, not just a shape.

Practice question: Design a small example, interface or abstract class, for a payment system with CreditCardPayment and PayPalPayment - explain which you'd choose and why.

Topic 6: Garbage Collection

.NET automatically manages memory - you don't manually free objects. The garbage collector, GC, periodically identifies objects no longer reachable from your running code and reclaims their memory. It organizes objects into generations to make this efficient.

Generation 0 is where newly created objects start. They're collected very frequently and cheaply, since most objects die young - a temporary variable inside a method, for instance. Generation 1 holds objects that survived a Generation 0 collection, a middle ground collected less often. Generation 2 holds long-lived objects, like a Singleton or a cache, something alive for the app's whole lifetime, collected rarely and expensively when it happens.

This generational design exists because most objects are genuinely short-lived - checking Generation 0 constantly and Generation 2 rarely is far more efficient than treating every object identically.

// IDisposable - for resources the GC does NOT
// know how to clean up on its own (file handles,
// database connections, network sockets)
public class FileLogger : IDisposable
{
    private StreamWriter _writer;

    public FileLogger(string path)
        => _writer = new StreamWriter(path);

    public void Log(string message) => _writer.WriteLine(message);

    public void Dispose()
    {
        _writer?.Dispose();  // release the file handle
                              // DETERMINISTICALLY, not
                              // whenever GC happens to run
    }
}

// using statement - guarantees Dispose() is called,
// even if an exception occurs
using (var logger = new FileLogger("log.txt"))
{
    logger.Log("Application started");
}  // Dispose() called automatically HERE

// Modern C# - using declaration, disposes at end of scope
using var logger2 = new FileLogger("log.txt");
logger2.Log("Started");
Enter fullscreen mode Exit fullscreen mode

A finalizer, written as a destructor-style method, does eventually run before an object's memory is reclaimed, but "eventually" could be a long time, since it depends on GC timing, not your code's timing - a file handle held open for an unpredictable extra period is a real problem. IDisposable with a using block releases the resource deterministically, the moment you're actually done with it.

Practice question: Why would a class holding a database connection want to implement IDisposable rather than relying purely on the garbage collector and a finalizer?

Topic 7: Exception Handling in Depth

Beyond basic try/catch, real production code needs custom exception types, exception filters, and correct rethrowing that preserves the original stack trace.

// Custom exceptions - carry meaningful, specific
// information beyond a generic Exception
public class InsufficientFundsException : Exception
{
    public decimal RequestedAmount { get; }
    public decimal AvailableBalance { get; }

    public InsufficientFundsException(
        decimal requested, decimal available)
        : base($"Cannot withdraw {requested:C}, only {available:C} available")
    {
        RequestedAmount = requested;
        AvailableBalance = available;
    }
}

// Exception filters - catch only under a specific
// condition, without catching and immediately
// rethrowing everything else
try
{
    CallExternalApi();
}
catch (HttpRequestException ex) when (ex.Message.Contains("timeout"))
{
    // only handles TIMEOUT-related HTTP failures,
    // other HttpRequestExceptions pass through uncaught
    RetryRequest();
}

// Rethrowing CORRECTLY - preserves the original
// stack trace, showing where the error ACTUALLY
// originated
try
{
    DoSomething();
}
catch (Exception ex)
{
    LogError(ex);
    throw;              // CORRECT - preserves original
                          // stack trace

    // throw ex;         // WRONG - resets the stack
                          // trace to HERE, hiding
                          // where it actually happened
}

// finally always runs, exception or not
try { RiskyOperation(); }
finally { CleanUp(); }  // always executes
Enter fullscreen mode Exit fullscreen mode

Practice question: What is the practical difference between throw; and throw ex; inside a catch block? Write a short example showing why this matters when debugging a production error.

Topic 8: Boxing and Unboxing

Boxing wraps a value type - int, bool, struct - inside an object on the heap, so it can be treated as a reference type. Unboxing extracts the value type back out. Both have a genuine, measurable performance cost - a real heap allocation happens on every box.

int number = 42;

// Boxing - value type wrapped in an object,
// a REAL heap allocation happens here
object boxed = number;

// Unboxing - extracting the value back out,
// requires an explicit cast
int unboxed = (int)boxed;

// Where this quietly happens without you noticing -
// non-generic collections
ArrayList list = new ArrayList();
list.Add(42);        // BOXES the int automatically
int value = (int)list[0];  // UNBOXES it back

// Generic collections AVOID this entirely - this is
// a real, meaningful reason List<T> beats ArrayList
List<int> genericList = new List<int>();
genericList.Add(42);  // stored directly as int,
                        // NO boxing at all
Enter fullscreen mode Exit fullscreen mode

Practice question: Explain why storing a million integers in an ArrayList is slower and uses more memory than storing them in a List.

Topic 9: Equality - ==, Equals, and GetHashCode

C# has multiple ways to check equality, and they don't always agree - understanding when each applies, and why they can disagree, is a genuinely common interview probe.

// For value types, == and Equals() check VALUE equality
int a = 5, b = 5;
Console.WriteLine(a == b);        // true
Console.WriteLine(a.Equals(b));   // true

// For reference types (by default, unless overridden),
// == and Equals() check REFERENCE equality - are these
// the SAME object in memory, not just "look the same"
var post1 = new Post { Title = "Hello" };
var post2 = new Post { Title = "Hello" };
Console.WriteLine(post1 == post2);        // false -
                                            // different objects
Console.WriteLine(post1.Equals(post2));   // false - same
                                            // default behavior

// string is a SPECIAL CASE - == is overridden to
// check VALUE equality even though string is a
// reference type
string s1 = "hello";
string s2 = "hello";
Console.WriteLine(s1 == s2);  // true - value equality,
                                // special-cased for string

// Overriding equality for your own class
public class Post
{
    public string Slug { get; set; }

    public override bool Equals(object obj)
    {
        if (obj is not Post other) return false;
        return Slug == other.Slug;
    }

    // MUST override GetHashCode consistently with
    // Equals, or Dictionary/HashSet lookups silently
    // break - two "equal" objects must produce the
    // SAME hash code
    public override int GetHashCode() => Slug?.GetHashCode() ?? 0;
}

// ReferenceEquals - explicitly checks "same object
// in memory", ignoring any Equals override
Console.WriteLine(ReferenceEquals(post1, post2));  // false
Enter fullscreen mode Exit fullscreen mode

Practice question: Why does overriding Equals() without also overriding GetHashCode() cause a class to behave incorrectly when used as a Dictionary key?

Topic 10: Extension Methods

Extension methods let you add new methods to an existing type, including types you don't own like built-in .NET types, without modifying the original class or using inheritance.

// Defining an extension method - static class,
// static method, "this" before the first parameter
public static class StringExtensions
{
    public static bool IsValidSlug(this string input)
    {
        return !string.IsNullOrWhiteSpace(input)
            && input == input.ToLower()
            && !input.Contains(" ");
    }
}

// Calling it - looks exactly like a real instance method,
// even though string itself was never modified
string slug = "csharp-basics";
bool valid = slug.IsValidSlug();  // true

// This is EXACTLY how all the built-in LINQ methods
// work - Where, Select, OrderBy are all extension
// methods on IEnumerable<T>, defined in the Enumerable
// class, not actual members of List<T> itself
Enter fullscreen mode Exit fullscreen mode

Practice question: Write an extension method TruncateWithEllipsis(this string input, int maxLength) that shortens a string to a max length and appends "..." if it was actually truncated.

Topic 11: Stack, Queue, and LinkedList

Beyond List, Dictionary, and HashSet, a few more built-in collections come up specifically for order-of-operations problems in interviews.

// Stack<T> - Last In, First Out (LIFO)
var undoHistory = new Stack<string>();
undoHistory.Push("Action 1");
undoHistory.Push("Action 2");
string lastAction = undoHistory.Pop();  // "Action 2" -
                                          // most recent

// Queue<T> - First In, First Out (FIFO)
var taskQueue = new Queue<string>();
taskQueue.Enqueue("Task 1");
taskQueue.Enqueue("Task 2");
string nextTask = taskQueue.Dequeue();  // "Task 1" -
                                          // first one in

// LinkedList<T> - doubly-linked list, efficient
// insertion/removal at any point WITHOUT shifting
// every other element, unlike List<T>, which shifts
// elements when inserting in the middle
var linked = new LinkedList<int>();
linked.AddLast(1);
linked.AddLast(2);
linked.AddFirst(0);
// Genuinely useful when frequent insertion/removal
// in the middle of a sequence matters more than
// indexed random access
Enter fullscreen mode Exit fullscreen mode

Practice question: Using a Stack, write a method that checks whether a string of parentheses like "(())" or "(()" is properly balanced.

Topic 12: String Formatting

Beyond basic interpolation, C# offers format specifiers for numbers, dates, and custom types, worth knowing for anything display-facing.

decimal price = 1234.5m;
DateTime date = DateTime.Now;

Console.WriteLine($"{price:C}");      // $1,234.50
Console.WriteLine($"{price:N2}");     // 1,234.50
Console.WriteLine($"{date:yyyy-MM-dd}"); // 2026-08-13
Console.WriteLine($"{date:MMMM dd, yyyy}"); // August 13, 2026

// Alignment and padding in interpolation
string name = "Alex";
Console.WriteLine($"{name,10}");   // right-aligned, 10 wide
Console.WriteLine($"{name,-10}|"); // left-aligned, 10 wide

// ToString overrides for custom types
public class Post
{
    public string Title { get; set; }
    public override string ToString() => $"Post: {Title}";
}
var post = new Post { Title = "Hello" };
Console.WriteLine(post);  // "Post: Hello" - uses the override
Enter fullscreen mode Exit fullscreen mode

Practice question: Given a decimal representing a price, write code that formats it as currency with exactly 2 decimal places, and explain the difference between the "C" and "N" format specifiers.

Key Lessons

LINQ queries use deferred execution - they run when iterated or materialized, ToList or Count, not when defined, which explains a lot of "unexpected" behavior around data changing mid-query.

IQueryable lets filtering happen at the source, SQL for EF Core, while IEnumerable means the data is already loaded and filtering happens in memory - calling ToList() too early is a genuine, common performance mistake.

Generic constraints - where T : class, new(), IComparable - are what let you safely call specific operations inside a generic method.

Garbage collection uses generations - short-lived objects, Generation 0, are collected cheaply and often; long-lived objects, Generation 2, rarely and expensively - IDisposable with using gives deterministic cleanup for unmanaged resources.

Overriding Equals() without GetHashCode() breaks Dictionary and HashSet lookups silently - the two must stay consistent.

Boxing has a real, measurable cost - generic collections like List avoid it entirely, which is a genuine reason to prefer them over legacy non-generic collections.

What's Next

Part 3 covers advanced concepts - async/await and Task in depth, Task.WhenAll and Task.WhenAny, CancellationToken, ConcurrentDictionary and thread-safe collections, reflection, and memory and performance considerations.

Summary

These mid-level concepts are where C# knowledge genuinely starts to separate candidates in interviews, not because they're obscure, but because they require understanding the mechanism underneath, not just the syntax on top. LINQ's deferred execution, the IEnumerable and IQueryable split, generic constraints, garbage collection generations, and correct equality overrides all reward the same kind of thinking: knowing not just what a line of code does, but when it actually runs and why.


Originally published at TechStack Blog: https://www.techstackblog.com/post.html?slug=csharp-interview-prep-midlevel-part2

Part 1 of this series (Basics): https://www.techstackblog.com/post.html?slug=csharp-interview-prep-basics-part1

More from TechStack Blog: C# / .NET: https://www.techstackblog.com/category.html?cat=csharp
CS Fundamentals: https://www.techstackblog.com/category.html?cat=cs-fundamentals

Top comments (0)