DEV Community

Manohari Jayachandran
Manohari Jayachandran

Posted on Edited on

C# Coding Interview Prep Part 2: LINQ, Generics, Delegates, Interfaces, Garbage Collection, OOP Pillars, 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.

Topic 13: The Four Pillars of OOP

C# is an object-oriented language, and four principles show up in nearly every interview at some point, sometimes named directly, sometimes just probed through a design question. Interfaces vs Abstract Classes, covered earlier, already touched two of these indirectly - this topic names all four properly.

Encapsulation means bundling data and the methods that operate on it together, while restricting direct outside access to internal details. Inheritance means a class acquiring the members and behavior of another class, forming an "is-a" relationship. Polymorphism means the same method call producing different behavior depending on the actual runtime type of the object. Abstraction means exposing only what a caller needs to know, hiding implementation detail behind a simpler interface.

Think of encapsulation as a car's dashboard. The driver interacts with a steering wheel and pedals - simple, controlled surfaces - without needing direct access to the engine's internals. The engine's complexity is fully encapsulated behind that dashboard.

public class BankAccount
{
    // private - internal detail, hidden from outside code
    private decimal _balance;

    // public - the controlled, exposed surface
    public decimal Balance => _balance;

    public void Deposit(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Amount must be positive");
        _balance += amount;
    }

    // Outside code CANNOT do "_balance = -1000" directly -
    // it can only interact through the controlled methods,
    // which enforce the actual business rules
}
Enter fullscreen mode Exit fullscreen mode

Think of inheritance as a job title hierarchy. A Manager is a specific kind of Employee - inheriting everything an Employee has, a name, a salary, while adding something extra, a team to manage. This "is-a" relationship is exactly what class inheritance models.

public class Employee
{
    public string Name { get; set; }
    public decimal Salary { get; set; }
    public virtual void Work() => Console.WriteLine($"{Name} is working");
}

public class Manager : Employee
{
    public List<Employee> DirectReports { get; set; } = new();

    // override - replacing the inherited behavior
    public override void Work()
        => Console.WriteLine($"{Name} is managing the team");
}
Enter fullscreen mode Exit fullscreen mode

Think of polymorphism as a universal play button on different music apps. Pressing play does something different depending on which app you're actually in - the button, the interface, stays consistent, but the underlying behavior varies by the actual object receiving the call.

List<Employee> staff = new List<Employee>
{
    new Employee { Name = "Alex" },
    new Manager  { Name = "Priya" }
};

foreach (var person in staff)
{
    person.Work();
    // Alex is working        (Employee's own Work())
    // Priya is managing the team  (Manager's OVERRIDDEN Work())
}
// Same method call - person.Work() - genuinely different
// behavior depending on the ACTUAL runtime type, decided
// automatically, without any if/else checking "what type
// is this" anywhere in this loop
Enter fullscreen mode Exit fullscreen mode

Think of abstraction as ordering coffee at a counter. You say "one latte" - you don't need to know which specific brewing process, grinder settings, or milk steaming technique happens behind the counter. The complexity is abstracted away behind a simple request.

public interface ICoffeeMachine
{
    void MakeCoffee(string type);
    // The CALLER only needs this simple contract -
    // not the actual grinding, heating, and brewing
    // steps happening underneath
}

public class EspressoMachine : ICoffeeMachine
{
    public void MakeCoffee(string type)
    {
        GrindBeans();
        HeatWater();
        Brew();
        Console.WriteLine($"{type} ready");
    }

    private void GrindBeans() { }
    private void HeatWater() { }
    private void Brew() { }
    // These private methods are the hidden complexity -
    // completely invisible to whoever just calls MakeCoffee()
}
Enter fullscreen mode Exit fullscreen mode

Practice question: Design a small class hierarchy for Shape, Circle, and Rectangle that demonstrates all four pillars - identify specifically which part of your design represents each one.

Topic 14: Lambda Expressions

A lambda expression is a concise, inline, unnamed function - syntactic shorthand for creating a delegate without formally declaring a named method first. Every LINQ call used throughout this post, Where(p => p.IsPublished), is a lambda expression, assigned to a delegate parameter behind the scenes.

Think of a sticky note with a quick instruction, used once exactly where needed, versus writing a full recipe card with a name that gets filed away for reuse later. A lambda is the sticky note - defined and used immediately, inline, with no separate named method cluttering the class.

// A regular named method
bool IsPublished(Post post)
{
    return post.IsPublished == true;
}

// The EXACT same logic as a lambda expression
Func<Post, bool> isPublished = post => post.IsPublished == true;

// Lambda syntax variations
x => x * 2;                    // expression lambda, single expression
x => { return x * 2; };        // statement lambda, needs braces + return
() => DateTime.UtcNow;          // no parameters
(x, y) => x + y;                // multiple parameters
(int x, int y) => x + y;        // explicit parameter types

// Where lambdas actually show up constantly
var published = posts.Where(p => p.IsPublished);
var titles = posts.Select(p => p.Title);
var sorted = posts.OrderByDescending(p => p.CreatedAt);

// Captured variables - a lambda can use variables
// from its surrounding scope, called a CLOSURE
string techFilter = "Azure";
var azurePosts = posts.Where(p => p.Tech == techFilter);
// the lambda "remembers" techFilter even if it were
// captured from a method that has already returned

// The classic closure-in-a-loop mistake
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
    int captured = i;  // capture a COPY, not the loop variable
    actions.Add(() => Console.WriteLine(captured));
}
// Without "int captured = i", every lambda would capture
// the SAME shared loop variable, and all three would
// print the SAME final value instead of 0, 1, 2
Enter fullscreen mode Exit fullscreen mode

Practice question: Write a lambda expression assigned to a Func that returns true if the first number is divisible by the second. Then explain, in your own words, why a lambda expression is really just a delegate with shorter syntax.

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.

Encapsulation, Inheritance, Polymorphism, and Abstraction are the four pillars underneath every class design decision in C# - interview design questions are frequently just these four pillars in disguise.

A lambda expression is shorthand syntax for a delegate - every LINQ call throughout this entire series has quietly been a lambda the whole time.

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)