DEV Community

Manohari Jayachandran
Manohari Jayachandran

Posted on

C# Mid-Level Day 3: Generics, Collections, Delegates, Events, LINQ and Iterators, With Six Practice Projects

Why this post exists

Days 1 and 2 gave you the language and the object model. Day 3 is where C# stops being merely correct and starts being expressive. The six concepts here are the ones that separate code that works from code that other developers enjoy reading: generics, collections, delegates and lambdas, events, LINQ, and iterators. Almost every real .NET codebase, including every ASP.NET Core and Entity Framework Core project, is built from them.

As before, each concept has what it is, an analogy, a short example, a practice question and a complete solution with the reasons it is written that way. Try each question before reading the solution. Day 4 finishes the mid-level material, and a capstone project follows it.

All code targets .NET 8 or later with top-level statements, so you can paste each solution into the Program.cs of a new console project (dotnet new console) and run it.

Concept 1: Generics: classes, methods and constraints

What it is. Generics let you write one class or method that works with many types while the compiler still checks every use. The type is a placeholder (T) that the caller fills in: List<int>, List<string>. A constraint (where T : ...) restricts which types are allowed, so your code can safely rely on what they can do.

The analogy: a storage box design with a blank on the label. The design is written once. When someone builds a box they fill in the label: "eggs" or "chocolates". From then on the box refuses anything that does not match its label, and when you take something out you already know what it is. A constraint is a house rule printed on the box design: "only things with a barcode". Because every item has a barcode, the warehouse can scan them without checking what they are.

var number = new Box<int>(42);
var word = new Box<string>("hello");
Console.WriteLine(number);                       // Box<Int32>(42)
Console.WriteLine(word);                         // Box<String>(hello)

Console.WriteLine(Max(3, 9));                    // 9      (T inferred as int)
Console.WriteLine(Max("apple", "pear"));         // pear   (T inferred as string)
Console.WriteLine(Max(2.5, 1.5));                // 2.5

// Type safety: the compiler checks what goes in
var scores = new List<int> { 90, 75 };
// scores.Add("A+");                             // compile error: a string is not an int
int first = scores[0];                           // no cast needed on the way out

static T Max<T>(T a, T b) where T : IComparable<T>      // constraint: T must be comparable
    => a.CompareTo(b) >= 0 ? a : b;

class Box<T>                                     // T is a placeholder filled in by the caller
{
    public Box(T value) => Value = value;
    public T Value { get; }
    public override string ToString() => $"Box<{typeof(T).Name}>({Value})";
}
Enter fullscreen mode Exit fullscreen mode

Output:

Box<Int32>(42)
Box<String>(hello)
9
pear
2.5
Enter fullscreen mode Exit fullscreen mode

What is happening here:

  • Box<T> is written once. Box<int> and Box<string> are two separate closed types the compiler generates and checks.
  • Max<T> works for any type, and the compiler works out T from the arguments, so you never write Max<int>(3, 9).
  • where T : IComparable<T> promises that CompareTo exists. Without the constraint the compiler would refuse to compile a.CompareTo(b).
  • Before generics, collections stored object, which meant casting on the way out and runtime crashes for wrong types. A List<int> rejects a string at compile time.
  • Common constraints: where T : class (reference type), struct (value type), new() (has a parameterless constructor), a base class, or an interface.

Practice question 1: a generic repository

Build a generic Repository<T> with Add, Get and Remove. It must only accept types that implement an IEntity interface with an Id.

Solution

var customers = new InMemoryRepository<Customer>();
customers.Add(new Customer(1, "Asha"));
customers.Add(new Customer(2, "Ravi"));

var products = new InMemoryRepository<Product>();
products.Add(new Product(10, "Keyboard", 49.99m));

Console.WriteLine(customers.Get(2)?.Name);                       // Ravi
Console.WriteLine($"Missing customer is null: {customers.Get(99) is null}");
Console.WriteLine($"Customers: {customers.GetAll().Count}, products: {products.GetAll().Count}");

try { customers.Add(new Customer(1, "Duplicate")); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }

Console.WriteLine($"Remove customer 1: {customers.Remove(1)}");
Console.WriteLine($"Remove customer 1 again: {customers.Remove(1)}");

// customers.Add(new Product(11, "Mouse", 19.99m));    // does not compile: a Product is not a Customer

public interface IEntity
{
    int Id { get; }
}

public interface IRepository<T> where T : class, IEntity
{
    void Add(T item);
    T? Get(int id);
    bool Remove(int id);
    IReadOnlyList<T> GetAll();
}

public class InMemoryRepository<T> : IRepository<T> where T : class, IEntity
{
    private readonly Dictionary<int, T> _items = new();

    public void Add(T item)
    {
        ArgumentNullException.ThrowIfNull(item);
        if (!_items.TryAdd(item.Id, item))
        {
            throw new InvalidOperationException($"A {typeof(T).Name} with id {item.Id} already exists.");
        }
    }

    public T? Get(int id) => _items.GetValueOrDefault(id);

    public bool Remove(int id) => _items.Remove(id);

    public IReadOnlyList<T> GetAll() => _items.Values.ToList();
}

public record Customer(int Id, string Name) : IEntity;

public record Product(int Id, string Title, decimal Price) : IEntity;
Enter fullscreen mode Exit fullscreen mode

Output:

Ravi
Missing customer is null: True
Customers: 2, products: 1
A Customer with id 1 already exists.
Remove customer 1: True
Remove customer 1 again: False
Enter fullscreen mode Exit fullscreen mode

Why this is the best way to write it:

  1. One implementation serves every entity. Customer and Product share the same repository code, and the compiler keeps them apart: adding a Product to the customer repository is a compile error, not a runtime surprise.
  2. where T : class, IEntity is what makes the body possible. Because every T has an Id, the repository can use item.Id as the dictionary key without knowing the concrete type. The class part also makes T? simply mean "may be null".
  3. An interface (IRepository<T>) sits in front of the class. Callers depend on the contract, so you can swap in a database version later or a fake for testing. Day 4 builds on exactly this idea.
  4. A Dictionary keyed by id gives constant-time Get and Remove. A List would scan every item.
  5. TryAdd rejects duplicates in one step, and the error message uses typeof(T).Name, so it says "A Customer with id 1" for each type automatically.
  6. GetAll returns a copy as IReadOnlyList<T>. Callers cannot add to or remove from the repository's internal storage by accident.
  7. Get returns null for "not found" and Remove returns a bool. Missing items are a normal outcome, so they do not throw.

Concept 2: Collections deep dive: HashSet, Queue, Stack, SortedDictionary and the collection interfaces

What it is. Day 1 gave you List and Dictionary. The .NET collections library has a tool for almost every access pattern, and choosing the right one is often the difference between code that is simple and fast and code that fights its own data structure. Alongside them sit three interfaces that describe what a caller needs: IEnumerable<T>, ICollection<T> and IList<T>.

The analogy: a HashSet is a guest list with a bouncer: you are either on it or not, and nobody is listed twice. A Queue is a supermarket checkout line: first in, first served. A Stack is a pile of plates, or your browser's back button: the last one placed is the first one taken. A SortedDictionary is a phone book that keeps itself in alphabetical order. For the interfaces: IEnumerable is a window you can only look through, one item at a time. ICollection is a box you can look through, count and add to. IList is a numbered shelf where you can also ask for "item number 3".

// HashSet<T>: no duplicates, very fast Contains
var tags = new HashSet<string> { "c#", "azure", "c#" };
Console.WriteLine(tags.Count);                    // 2
Console.WriteLine(tags.Add("azure"));             // False (already there)
Console.WriteLine(tags.Contains("c#"));           // True

// Queue<T>: first in, first out
var queue = new Queue<string>();
queue.Enqueue("first");
queue.Enqueue("second");
Console.WriteLine(queue.Peek());                  // first (look without removing)
Console.WriteLine(queue.Dequeue());               // first
Console.WriteLine(queue.Count);                   // 1

// Stack<T>: last in, first out
var stack = new Stack<int>();
stack.Push(1);
stack.Push(2);
stack.Push(3);
Console.WriteLine(stack.Pop());                   // 3
Console.WriteLine(stack.Peek());                  // 2

// SortedDictionary: always enumerates in key order
var prices = new SortedDictionary<string, int> { ["pear"] = 3, ["apple"] = 5, ["fig"] = 1 };
Console.WriteLine(string.Join(", ", prices.Keys));   // apple, fig, pear

// Interfaces describe what a caller needs, not what you have
List<int> data = new() { 4, 8, 15 };
Console.WriteLine(Average(data));                 // works with a List...
Console.WriteLine(Average(new[] { 1, 2, 3 }));    // ...an array...
Console.WriteLine(Average(data.Where(n => n > 4)));   // ...or a lazy query

static double Average(IEnumerable<int> numbers)   // asks for the least it needs: "something I can loop over"
{
    int total = 0, count = 0;
    foreach (int n in numbers) { total += n; count++; }
    return count == 0 ? 0 : (double)total / count;
}
Enter fullscreen mode Exit fullscreen mode

Output:

2
False
True
first
first
1
3
2
apple, fig, pear
9
2
11.5
Enter fullscreen mode Exit fullscreen mode

What is happening here:

  • HashSet<T>.Add returns false for a duplicate. Contains is near-instant even with a million items, whereas List.Contains scans.
  • Queue has Enqueue, Dequeue and Peek. Stack has Push, Pop and Peek. Both throw if you read from an empty one, so use TryDequeue and TryPop when empty is a normal case.
  • SortedDictionary keeps keys in order automatically, at the cost of slower inserts than a plain Dictionary.
  • Accept the widest interface you need, return the most useful one. Average takes IEnumerable<int>, so it works with a list, an array and a lazy query. Asking for List<int> would have locked callers out.
  • The hierarchy: IEnumerable<T> (loop) then ICollection<T> (adds Count, Add, Remove) then IList<T> (adds indexing and Insert).

Practice question 2: a print queue and an undo/redo stack

Implement a print queue (jobs print in the order they arrive) and an undo/redo stack for a text editor.

Solution

// Print queue: first in, first out
var printer = new PrintQueue();
printer.Submit("report.pdf");
printer.Submit("invoice.docx");
printer.Submit("photo.png");

Console.WriteLine($"Waiting: {printer.Pending}");
while (printer.PrintNext() is { } job)
{
    Console.WriteLine($"Printed #{job.Id}: {job.Document}");
}
Console.WriteLine($"Waiting: {printer.Pending}");

// Undo/redo: last in, first out
var editor = new TextEditor();
editor.Type("Hello");
editor.Type(" World");
Console.WriteLine(editor.Text);

editor.Undo();
Console.WriteLine(editor.Text);
editor.Undo();
Console.WriteLine($"'{editor.Text}'");
Console.WriteLine($"Undo with nothing left: {editor.Undo()}");

editor.Redo();
editor.Redo();
Console.WriteLine(editor.Text);

editor.Undo();
editor.Type("!");                                   // a new edit throws away the redo history
Console.WriteLine($"{editor.Text}  (can redo: {editor.CanRedo})");

public record PrintJob(int Id, string Document);

public class PrintQueue
{
    private readonly Queue<PrintJob> _jobs = new();
    private int _nextId = 1;

    public int Pending => _jobs.Count;

    public int Submit(string document)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(document);
        var job = new PrintJob(_nextId++, document);
        _jobs.Enqueue(job);
        return job.Id;
    }

    public PrintJob? PrintNext() => _jobs.TryDequeue(out PrintJob? job) ? job : null;
}

public class TextEditor
{
    private readonly Stack<string> _undo = new();
    private readonly Stack<string> _redo = new();

    public string Text { get; private set; } = "";
    public bool CanUndo => _undo.Count > 0;
    public bool CanRedo => _redo.Count > 0;

    public void Type(string text)
    {
        _undo.Push(Text);
        _redo.Clear();
        Text += text;
    }

    public bool Undo()
    {
        if (!_undo.TryPop(out string? previous)) return false;
        _redo.Push(Text);
        Text = previous;
        return true;
    }

    public bool Redo()
    {
        if (!_redo.TryPop(out string? next)) return false;
        _undo.Push(Text);
        Text = next;
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Waiting: 3
Printed #1: report.pdf
Printed #2: invoice.docx
Printed #3: photo.png
Waiting: 0
Hello World
Hello
''
Undo with nothing left: False
Hello World
Hello!  (can redo: False)
Enter fullscreen mode Exit fullscreen mode

Why this is the best way to write it:

  1. The data structure is the specification. A print queue is first-in-first-out, so it is a Queue. Undo is last-in-first-out, so it is a Stack. Choosing the right one means the code needs no sorting, no index juggling and no "find the oldest" loop.
  2. TryDequeue and TryPop instead of Dequeue and Pop. An empty queue is a normal situation, not an error, and the Try versions return false instead of throwing. The while (printer.PrintNext() is { } job) loop ends neatly when the queue is empty.
  3. Two stacks make undo and redo work. Every edit pushes the old text onto the undo stack. Undo moves the current text to the redo stack and restores the previous text. Redo does the reverse.
  4. A new edit clears the redo history. This is how every real editor behaves: once you type something new, the future you undid is gone. Forgetting _redo.Clear() is the most common bug in this exercise.
  5. The collections are private readonly. Outside code can only change them through Type, Undo and Redo, so the two stacks can never be put out of step.
  6. Undo and redo return a bool so the caller (a menu or button) can disable the option when there is nothing left to do.
  7. Snapshots of the whole text keep the example simple. A real editor would store small commands (Insert, Delete) to save memory. That is the command pattern, and the stack works the same way.

Concept 3: Delegates, Func/Action/Predicate, lambdas and closures

What it is. A delegate is a variable that holds a method, so you can pass behaviour around like data. Func<...> is a delegate that returns a value, Action<...> returns nothing and Predicate<T> returns a bool. A lambda (x => x * 2) is a short way to write the method inline. A closure is a lambda that uses a variable from the code around it.

The analogy: a sticky note with instructions. A normal parameter is "here is the number 5". A delegate parameter is "here is a note saying what to do with the number". Func is a note that says "and give me the answer back", Action is "just do it", Predicate is "answer yes or no". A closure is a sticky note that remembers the room it was written in: it keeps pointing at the original variable on the desk, not a photocopy of what it said at that moment.

// A delegate is a variable that holds a method
Func<int, int, int> add = (a, b) => a + b;       // takes inputs, returns a value
Action<string> log = Console.WriteLine;          // takes inputs, returns nothing
Predicate<int> isEven = n => n % 2 == 0;         // takes one input, returns bool

Console.WriteLine(add(2, 3));                    // 5
log("hello from a delegate");
Console.WriteLine(isEven(10));                   // True

// Methods can be passed like any other value
Console.WriteLine(Apply(5, n => n * n));         // 25
Console.WriteLine(Apply(5, n => n + 100));       // 105

// Multicast: one delegate can call several methods
Action greet = () => Console.Write("Hello ");
greet += () => Console.WriteLine("World");
greet();

// Closures: a lambda remembers the variable itself, not a copy of its value
int factor = 3;
Func<int, int> times = n => n * factor;
factor = 10;
Console.WriteLine(times(2));                     // 20, not 6

// The classic loop trap
var broken = new List<Action>();
for (int i = 0; i < 3; i++) broken.Add(() => Console.Write(i));
foreach (var action in broken) action();         // 333
Console.WriteLine();

var fixedOnes = new List<Action>();
for (int i = 0; i < 3; i++)
{
    int copy = i;                                // a fresh variable per loop pass
    fixedOnes.Add(() => Console.Write(copy));
}
foreach (var action in fixedOnes) action();      // 012
Console.WriteLine();

static int Apply(int value, Func<int, int> operation) => operation(value);
Enter fullscreen mode Exit fullscreen mode

Output:

5
hello from a delegate
True
25
105
Hello World
20
333
012
Enter fullscreen mode Exit fullscreen mode

What is happening here:

  • Func<int, int, int> takes two ints and returns an int: the last type argument is always the return type. Action<string> takes a string and returns nothing.
  • Apply(5, n => n * n) passes behaviour into a method. This single idea is how LINQ's Where, Select and OrderBy work.
  • A delegate can be multicast: += adds another method and calling it runs them all in order. Events (the next concept) are built on this.
  • times(2) printed 20, not 6. The lambda captured the variable factor itself, so when factor changed to 10 the lambda saw it.
  • The for loop trap: all three lambdas share the single variable i, which is 3 when the loop ends. Declaring int copy = i inside the loop gives each lambda its own variable. (foreach already makes a fresh variable each pass.)

Practice question 3: Filter from scratch, then a pipeline

Write Filter<T>(IEnumerable<T>, Predicate<T>) from scratch, then add a pipeline of transforms that applies a sequence of steps to a value.

Solution

int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

Console.WriteLine(string.Join(", ", Filter(numbers, n => n % 2 == 0)));

int minimum = 7;                                              // captured by the lambda below
Console.WriteLine(string.Join(", ", Filter(numbers, n => n >= minimum)));

string[] names = { "Asha", "Ravi", "Anil", "Meena" };
Console.WriteLine(string.Join(", ", Filter(names, name => name.StartsWith('A'))));

// A pipeline of transforms
var slugify = new Pipeline<string>()
    .Then(s => s.Trim())
    .Then(s => s.ToLowerInvariant())
    .Then(s => s.Replace(' ', '-'));
Console.WriteLine(slugify.Run("  Hello Big World  "));

var discount = new Pipeline<decimal>()
    .Then(price => price * 0.9m)
    .Then(price => Math.Round(price, 2))
    .Then(price => Math.Max(price, 1m));
Console.WriteLine(discount.Run(19.99m));
Console.WriteLine(discount.Run(0.50m));

static List<T> Filter<T>(IEnumerable<T> source, Predicate<T> keep)
{
    ArgumentNullException.ThrowIfNull(source);
    ArgumentNullException.ThrowIfNull(keep);

    var result = new List<T>();
    foreach (T item in source)
    {
        if (keep(item)) result.Add(item);
    }
    return result;
}

class Pipeline<T>
{
    private readonly List<Func<T, T>> _steps = new();

    public Pipeline<T> Then(Func<T, T> step)
    {
        ArgumentNullException.ThrowIfNull(step);
        _steps.Add(step);
        return this;                                          // returning 'this' allows chaining
    }

    public T Run(T input)
    {
        T current = input;
        foreach (Func<T, T> step in _steps)
        {
            current = step(current);
        }
        return current;
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

2, 4, 6, 8, 10
7, 8, 9, 10
Asha, Anil
hello-big-world
17.99
1
Enter fullscreen mode Exit fullscreen mode

Why this is the best way to write it:

  1. Filter knows nothing about what it is filtering. The caller decides the rule with a lambda, so one method serves integers, names and anything else. That is what Enumerable.Where is, and now you know how it works.
  2. The closure over minimum is useful, not a trap. The rule n >= minimum uses a local variable, which is how filters get configured without extra parameters. Just remember the lambda sees the variable's current value when it runs.
  3. Predicate<T> documents intent. A parameter of type Predicate<T> says "this is a yes/no question", which reads better than Func<T, bool>, though they are interchangeable in practice.
  4. Null arguments are rejected at the top. If keep were null the failure would otherwise appear deep inside the loop with a confusing stack trace.
  5. This version builds a List immediately (eager). That keeps the first lesson simple. The built-in Where is lazy, which you will write yourself in concept 6 with yield return.
  6. Pipeline<T> stores a list of Func<T, T> steps and runs them in order. Each step is a lambda, so a new transform is one line and the pipeline never changes. Then returns this, which allows the fluent chain.
  7. The pipeline is generic. The same class slugifies text and applies a discount to a price, as the two examples show. Combining generics from concept 1 with delegates here is how most real utility code is written.

Concept 4: Events and EventHandler

What it is. An event lets one object announce that something happened without knowing who is listening. The announcing class is the publisher. Any number of other classes, the subscribers, register a method to be called when it happens. Events are multicast delegates with a safety rail: outside code can subscribe and unsubscribe but cannot fire the event or wipe the other subscribers.

The analogy: a channel subscription. A channel does not know who its viewers are. Viewers press subscribe (+=) and later unsubscribe (-=). When a new video goes up, the channel announces it once and everyone who subscribed is notified. A viewer cannot post videos in the channel's name, and cannot delete everyone else's subscriptions. That is the rail that separates an event from a plain public delegate field.

var button = new Button();

EventHandler<ClickEventArgs> handlerA = (sender, e) => Console.WriteLine($"A heard a click at ({e.X}, {e.Y})");
button.Clicked += handlerA;                                   // subscribe
button.Clicked += (sender, e) => Console.WriteLine("B heard it too");

button.Click(10, 20);

button.Clicked -= handlerA;                                   // unsubscribe
Console.WriteLine("-- A unsubscribed --");
button.Click(30, 40);

// Outside code can only += and -=. It cannot do these:
// button.Clicked = null;                                     // compile error
// button.Clicked(button, new ClickEventArgs(0, 0));          // compile error: only Button can raise it

class ClickEventArgs : EventArgs
{
    public ClickEventArgs(int x, int y) => (X, Y) = (x, y);
    public int X { get; }
    public int Y { get; }
}

class Button
{
    public event EventHandler<ClickEventArgs>? Clicked;       // null when nobody is subscribed

    public void Click(int x, int y) => Clicked?.Invoke(this, new ClickEventArgs(x, y));
}
Enter fullscreen mode Exit fullscreen mode

Output:

A heard a click at (10, 20)
B heard it too
-- A unsubscribed --
B heard it too
Enter fullscreen mode Exit fullscreen mode

What is happening here:

  • event EventHandler<ClickEventArgs>? Clicked declares the event. EventHandler<T> is the standard shape: (object? sender, T e).
  • Clicked?.Invoke(this, ...) raises it. The ?. matters: an event with no subscribers is null, and calling it directly would crash.
  • += subscribes and -= unsubscribes. The second lambda was never stored in a variable, so it can never be unsubscribed. Keep a reference if you need to remove a handler.
  • Event data goes in a class that derives from EventArgs, so you can add properties later without changing every handler's signature.
  • The publisher never references its subscribers. That loose coupling is the point: you can add a new reaction without editing the publisher.

Practice question 4: a LowStock event

Add a LowStock event to your inventory with two subscribers: a logger and a notifier.

Solution

var inventory = new Inventory(lowStockThreshold: 5);
var logger = new LowStockLogger();
var notifier = new LowStockNotifier("ops@example.com");

inventory.LowStock += logger.OnLowStock;
inventory.LowStock += notifier.OnLowStock;

inventory.Add("Mouse", 10);
inventory.Remove("Mouse", 6);       // 10 -> 4: crosses the threshold, event fires
inventory.Remove("Mouse", 1);       // 4 -> 3: already low, no second alert
inventory.Add("Mouse", 5);          // 3 -> 8: restocked
Console.WriteLine("-- notifier unsubscribed --");
inventory.LowStock -= notifier.OnLowStock;
inventory.Remove("Mouse", 5);       // 8 -> 3: fires again, but only the logger hears it

public sealed class LowStockEventArgs : EventArgs
{
    public LowStockEventArgs(string item, int quantity, int threshold) =>
        (Item, Quantity, Threshold) = (item, quantity, threshold);

    public string Item { get; }
    public int Quantity { get; }
    public int Threshold { get; }
}

public class Inventory
{
    private readonly Dictionary<string, int> _stock = new(StringComparer.OrdinalIgnoreCase);
    private readonly int _threshold;

    public Inventory(int lowStockThreshold)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(lowStockThreshold);
        _threshold = lowStockThreshold;
    }

    public event EventHandler<LowStockEventArgs>? LowStock;

    public void Add(string item, int quantity)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(item);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        _stock[item] = _stock.GetValueOrDefault(item) + quantity;
    }

    public void Remove(string item, int quantity)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(item);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);

        int before = _stock.GetValueOrDefault(item);
        if (quantity > before)
        {
            throw new InvalidOperationException($"Only {before} {item} in stock.");
        }

        int after = before - quantity;
        _stock[item] = after;

        if (before > _threshold && after <= _threshold)           // only when it CROSSES the line
        {
            OnLowStock(new LowStockEventArgs(item, after, _threshold));
        }
    }

    protected virtual void OnLowStock(LowStockEventArgs e) => LowStock?.Invoke(this, e);
}

public class LowStockLogger
{
    public void OnLowStock(object? sender, LowStockEventArgs e) =>
        Console.WriteLine($"[LOG] {e.Item} is low: {e.Quantity} left (threshold {e.Threshold})");
}

public class LowStockNotifier
{
    private readonly string _recipient;

    public LowStockNotifier(string recipient) => _recipient = recipient;

    public void OnLowStock(object? sender, LowStockEventArgs e) =>
        Console.WriteLine($"[EMAIL to {_recipient}] Please reorder {e.Item}");
}
Enter fullscreen mode Exit fullscreen mode

Output:

[LOG] Mouse is low: 4 left (threshold 5)
[EMAIL to ops@example.com] Please reorder Mouse
-- notifier unsubscribed --
[LOG] Mouse is low: 3 left (threshold 5)
Enter fullscreen mode Exit fullscreen mode

Why this is the best way to write it:

  1. The inventory has no idea who is listening. It raises LowStock and moves on. The logger and notifier are separate classes, and adding an SMS alert later means writing one class and one +=, with no change to Inventory.
  2. It fires only when stock crosses the threshold (before > _threshold && after <= _threshold). The 4 to 3 removal produced no second alert. Without this check, every sale below the line would spam the notifier.
  3. The check runs again after a restock. Going from 8 back down to 3 crosses the line again, so it alerts again. That is the behaviour a real reorder system needs.
  4. The standard EventHandler<TEventArgs> signature, with a LowStockEventArgs class that carries the item, quantity and threshold. Subscribers get everything they need from the arguments and never reach back into the inventory.
  5. protected virtual OnLowStock is the standard raise method. It keeps the null-safe ?.Invoke in one place, and a subclass can override it.
  6. Unsubscribing works with named methods. inventory.LowStock -= notifier.OnLowStock removed only the notifier, which the final output confirms: only the log line appears.
  7. A caveat worth knowing: if one subscriber throws, the remaining subscribers do not run. For critical handlers, catch inside the handler or raise through GetInvocationList(). Also, a subscriber that is never unsubscribed is kept alive by the publisher, which is a classic memory leak in long-running apps.

Concept 5: LINQ: method and query syntax, deferred execution, GroupBy, Join and Aggregate

What it is. LINQ (Language Integrated Query) is a set of methods that let you filter, transform, sort, group and combine any sequence of data with the same vocabulary, whether it comes from a list, an array, a file, XML or, through Entity Framework, a database. It is SQL for objects, built from the delegates and generics you just learned.

The analogy: a factory conveyor belt. Items ride the belt through stations: a filter that removes some (Where), a machine that reshapes each (Select), a sorter (OrderBy), a station that bins them by label (GroupBy). The important part is deferred execution: you are building the belt, not running it. A LINQ query is a recipe, not a meal. Nothing happens until someone starts eating: foreach, ToList(), Count(), First() and Sum() all start the belt.

int[] nums = { 5, 3, 8, 1, 9, 2, 6 };

// Method syntax: Where filters, Select transforms, OrderBy sorts
var result = nums.Where(n => n % 2 == 0).Select(n => n * n).OrderBy(n => n).ToList();
Console.WriteLine(string.Join(", ", result));                     // 4, 36, 64

// Query syntax: the same thing, SQL-style
var same = (from n in nums where n % 2 == 0 orderby n select n * n).ToList();
Console.WriteLine(string.Join(", ", same));

// Deferred execution: a query is a recipe, not a meal
var list = new List<int> { 1, 2, 3 };
var query = list.Where(n => n > 1);                               // nothing has run yet
list.Add(4);
Console.WriteLine(query.Count());                                 // 3 (2, 3, 4): it runs NOW, on the current data

var snapshot = list.Where(n => n > 1).ToList();                   // ToList cooks it immediately
list.Add(5);
Console.WriteLine(snapshot.Count);                                // 3 (the snapshot did not change)

// GroupBy
string[] words = { "apple", "fig", "pear", "kiwi", "plum", "banana" };
foreach (var group in words.GroupBy(w => w.Length).OrderBy(g => g.Key))
    Console.WriteLine($"{group.Key} letters: {string.Join(", ", group)}");

// Join
var customers = new[] { new { Id = 1, Name = "Asha" }, new { Id = 2, Name = "Ravi" } };
var orders = new[]
{
    new { CustomerId = 1, Total = 50m },
    new { CustomerId = 1, Total = 25m },
    new { CustomerId = 2, Total = 80m }
};
var lines = customers.Join(orders, c => c.Id, o => o.CustomerId, (c, o) => $"{c.Name}: {o.Total}");
Console.WriteLine(string.Join(" | ", lines));

// Aggregate: fold a sequence into one value
Console.WriteLine(nums.Aggregate(1, (product, n) => product * n));    // 12960
Enter fullscreen mode Exit fullscreen mode

Output:

4, 36, 64
4, 36, 64
3
3
3 letters: fig
4 letters: pear, kiwi, plum
5 letters: apple
6 letters: banana
Asha: 50 | Asha: 25 | Ravi: 80
12960
Enter fullscreen mode Exit fullscreen mode

What is happening here:

  • Where filters, Select transforms, OrderBy sorts. They chain because each takes a sequence and returns a sequence. Query syntax (from ... where ... select) does the same thing and is a matter of taste.
  • query.Count() returned 3, not 2, because the query had not run when 4 was added. It ran at Count(), against the data as it was then. ToList() runs immediately and gives a fixed snapshot.
  • GroupBy returns groups, each with a Key and the items in it. Join matches two sequences on a key. Aggregate folds a sequence into one value with a running accumulator.
  • LINQ does not change the original collection. It always produces something new.

Practice question 5: reports from 100 orders

Given 100 orders, find the top 5 customers by revenue, the monthly totals, and the average order value per category.

Solution

using System.Globalization;

List<Order> orders = BuildOrders();
Console.WriteLine($"{orders.Count} orders, total revenue {orders.Sum(o => o.Amount):F2}");

// 1. Top 5 customers by revenue
Console.WriteLine("\nTop 5 customers by revenue:");
var topCustomers = orders
    .GroupBy(o => o.Customer)
    .Select(g => new { Customer = g.Key, Revenue = g.Sum(o => o.Amount), Orders = g.Count() })
    .OrderByDescending(x => x.Revenue)
    .ThenBy(x => x.Customer)
    .Take(5);
foreach (var c in topCustomers)
{
    Console.WriteLine($"  {c.Customer,-12} {c.Revenue,9:F2}  ({c.Orders} orders)");
}

// 2. Monthly totals
Console.WriteLine("\nMonthly totals:");
var monthly = orders
    .GroupBy(o => new { o.Date.Year, o.Date.Month })
    .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
    .Select(g => new { Month = new DateOnly(g.Key.Year, g.Key.Month, 1), Total = g.Sum(o => o.Amount) });
foreach (var m in monthly)
{
    Console.WriteLine($"  {m.Month.ToString("MMM yyyy", CultureInfo.InvariantCulture)}  {m.Total,9:F2}");
}

// 3. Average order value per category
Console.WriteLine("\nAverage order value by category:");
var byCategory = orders
    .GroupBy(o => o.Category)
    .Select(g => new { Category = g.Key, Average = g.Average(o => o.Amount), Count = g.Count() })
    .OrderByDescending(x => x.Average);
foreach (var c in byCategory)
{
    Console.WriteLine($"  {c.Category,-12} {c.Average,7:F2}  ({c.Count} orders)");
}

static List<Order> BuildOrders()
{
    string[] categories = { "Books", "Electronics", "Garden", "Toys" };
    var list = new List<Order>();
    for (int i = 0; i < 100; i++)
    {
        list.Add(new Order(
            Id: i + 1,
            Customer: $"Customer {(i * 7) % 12 + 1:D2}",
            Category: categories[(i * 3) % 4],
            Date: new DateOnly(2026, 1, 1).AddDays((i * 11) % 330),
            Amount: 20m + (i * 37) % 180 + (i % 4) * 0.25m));
    }
    return list;
}

record Order(int Id, string Customer, string Category, DateOnly Date, decimal Amount);
Enter fullscreen mode Exit fullscreen mode

Output:

100 orders, total revenue 10767.50

Top 5 customers by revenue:
  Customer 01    1044.00  (9 orders)
  Customer 03     994.50  (9 orders)
  Customer 02     990.00  (8 orders)
  Customer 11     976.00  (8 orders)
  Customer 10     969.75  (9 orders)

Monthly totals:
  Jan 2026    1228.00
  Feb 2026    1301.00
  Mar 2026    1552.00
  Apr 2026     515.00
  May 2026    1029.50
  Jun 2026     768.25
  Jul 2026    1048.00
  Aug 2026     965.75
  Sep 2026     659.25
  Oct 2026    1012.00
  Nov 2026     688.75

Average order value by category:
  Books         111.20  (25 orders)
  Electronics   107.75  (25 orders)
  Garden        106.50  (25 orders)
  Toys          105.25  (25 orders)
Enter fullscreen mode Exit fullscreen mode

Why this is the best way to write it:

  1. Each report is one declarative query, not a loop. The code says what you want (group by customer, sum, sort, take five) and leaves the how to LINQ. There are no manual dictionaries, running totals or sort routines to get wrong.
  2. GroupBy then Select is the pattern for almost every report. Group by the thing you are summarising, then project each group into a small result with Sum, Count or Average. All three reports follow it.
  3. Order matters: sort, then take. OrderByDescending(...).Take(5) gives the top five. Reversing them would take five arbitrary customers and sort only those.
  4. ThenBy breaks ties. Two customers with the same revenue would otherwise appear in whatever order the data arrived, which makes output unstable between runs. A deterministic tie-breaker is a sign of careful code.
  5. The month key is a pair of numbers (Year, Month), not a formatted string. Sorting numbers gives correct chronological order; sorting strings like "Feb 2026" would put April first.
  6. decimal for the amounts, so the totals are exact to the cent.
  7. Anonymous types hold the results. new { Customer, Revenue, Orders } creates a small throwaway shape for each row without declaring a class. If you needed to return results from a method, use a record instead.
  8. The queries are lazy and each runs once, in its foreach. If you needed to loop over a result twice, call ToList() first, otherwise the whole query would run twice.

Concept 6: Iterators: yield return and custom IEnumerable

What it is. An iterator is a method that produces a sequence one item at a time using yield return. The compiler turns it into a small state machine that remembers where it stopped. It is how LINQ itself is built, and it lets you produce sequences that are enormous or even infinite without ever holding them in memory.

The analogy: a bookmark. When you read a book you do not photocopy all 500 pages first. You read a page, put the bookmark in, and come back for the next one when you want it. yield return is the bookmark: hand back one value, remember the place, and continue from exactly there on the next request. An iterator is a tap, not a bucket: water on demand instead of filling a bucket you may never use.

foreach (int n in Countdown(3)) Console.Write($"{n} ");           // 3 2 1
Console.WriteLine();

// Laziness: the method body runs a little at a time, only when asked
Console.WriteLine("Creating the sequence...");
IEnumerable<int> noisy = Noisy();                                 // nothing printed yet
Console.WriteLine("Starting the loop...");
foreach (int n in noisy)
{
    Console.WriteLine($"  got {n}");
}

// An infinite sequence is fine, as long as you only ask for a few
Console.WriteLine(string.Join(", ", Naturals().Where(n => n % 7 == 0).Take(3)));   // 7, 14, 21

static IEnumerable<int> Countdown(int from)
{
    for (int i = from; i >= 1; i--)
    {
        yield return i;                                           // hand one value back, remember the place
    }
}

static IEnumerable<int> Noisy()
{
    Console.WriteLine("  [producing 1]");
    yield return 1;
    Console.WriteLine("  [producing 2]");
    yield return 2;
    Console.WriteLine("  [done]");
}

static IEnumerable<int> Naturals()
{
    int n = 1;
    while (true) yield return n++;
}
Enter fullscreen mode Exit fullscreen mode

Output:

3 2 1 
Creating the sequence...
Starting the loop...
  [producing 1]
  got 1
  [producing 2]
  got 2
  [done]
7, 14, 21
Enter fullscreen mode Exit fullscreen mode

What is happening here:

  • Each yield return hands one value to the caller and pauses the method. When the caller asks for the next one, the method resumes on the line after.
  • The Noisy output proves laziness: nothing printed when the sequence was created, and the messages are interleaved with the loop's own output because the body runs a piece at a time.
  • Naturals() never ends, yet Take(3) finishes instantly because only as much of the sequence as is requested is ever produced.
  • yield break ends the sequence early. An iterator cannot use ref parameters and cannot yield inside a try that has a catch.

Practice question 6: a Fibonacci generator and a paged Chunk

Write a lazy Fibonacci generator and a paged Chunk method that splits any sequence into pages of a given size.

Solution

Console.WriteLine(string.Join(", ", Fibonacci().Take(12)));
Console.WriteLine($"First Fibonacci number above 1000: {Fibonacci().First(f => f > 1000)}");
Console.WriteLine($"Largest that fits in a long: {Fibonacci().Last()} (term number {Fibonacci().Count() - 1})");

Console.WriteLine();
foreach (List<long> page in Paged(Fibonacci().Take(10), pageSize: 4))
{
    Console.WriteLine($"page of {page.Count}: [{string.Join(", ", page)}]");
}

try { _ = Paged(new[] { 1, 2, 3 }, pageSize: 0); }                // fails right here, not later
catch (ArgumentOutOfRangeException ex) { Console.WriteLine($"\nRejected immediately: {ex.ParamName}"); }

static IEnumerable<long> Fibonacci()
{
    long a = 0, b = 1;
    while (true)
    {
        yield return a;
        if (b > long.MaxValue - a)                                // the term after b would overflow a long
        {
            yield return b;                                       // b itself still fits: hand it over, then stop cleanly
            yield break;
        }
        (a, b) = (b, a + b);
    }
}

static IEnumerable<List<T>> Paged<T>(IEnumerable<T> source, int pageSize)
{
    ArgumentNullException.ThrowIfNull(source);                    // checked when called...
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pageSize);
    return PagedIterator(source, pageSize);                       // ...then the lazy part starts

    static IEnumerable<List<T>> PagedIterator(IEnumerable<T> items, int size)
    {
        var page = new List<T>(size);
        foreach (T item in items)
        {
            page.Add(item);
            if (page.Count == size)
            {
                yield return page;
                page = new List<T>(size);                         // a new list, never reuse the one just returned
            }
        }
        if (page.Count > 0) yield return page;                    // the last, shorter page
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89
First Fibonacci number above 1000: 1597
Largest that fits in a long: 7540113804746346429 (term number 92)

page of 4: [0, 1, 1, 2]
page of 4: [3, 5, 8, 13]
page of 2: [21, 34]

Rejected immediately: pageSize
Enter fullscreen mode Exit fullscreen mode

Why this is the best way to write it:

  1. Fibonacci() is lazy and endless in design, so the caller decides how much to take: Take(12), First(f => f > 1000), or all of it. It never builds a list.
  2. It stops cleanly before overflow. Without the guard, a long quietly wraps around to negative numbers after term 92, which produces a wrong answer and no error. The check hands over the last term that fits, then yield break.
  3. The tuple swap (a, b) = (b, a + b) updates both values at once, so there is no temporary variable to forget.
  4. Paged is split into a checking method and an iterator on purpose. Iterator methods do not run any code, including argument checks, until the first item is requested. Splitting them means pageSize: 0 is rejected on the call, as the output shows ("Rejected immediately"), not later in some unrelated loop.
  5. It works on any IEnumerable<T>, including another lazy sequence. Fibonacci().Take(10) is never materialised as a whole list.
  6. A new List is created for each page. Reusing and clearing one list would make every page you already returned change under the caller's feet. Always yield a fresh object.
  7. The final partial page is returned, which is why the last page has 2 items. Forgetting the trailing if (page.Count > 0) silently drops data.
  8. .NET already ships Enumerable.Chunk (it returns arrays). Writing your own once is how you learn what it does, and how you would write any custom sequence operator.

Five classic Day 3 mistakes

Each one follows the same pattern: what people write, why it hurts, what to write instead.

  1. Enumerating a query twice. var q = items.Where(Expensive); if (q.Any()) { foreach (var x in q) ... } runs the whole query two times, and if the source changes, you get two different answers. Call ToList() once when you need to use the results more than once.
  2. Capturing the loop variable of a for loop. Every lambda sees the same final value, as the 333 output showed. Copy it to a local variable inside the loop.
  3. Never unsubscribing from events. The publisher holds a reference to every subscriber, so a short-lived object subscribed to a long-lived one is never freed. Unsubscribe with -= when you are done, and always raise with Clicked?.Invoke(...).
  4. List.Contains inside a loop. Checking every item against a list is quadratic: 100,000 by 100,000 comparisons. Put the lookup values in a HashSet and each check becomes near-instant.
  5. Assuming an iterator validates its arguments straight away. It does not: the check inside an iterator runs only when someone enumerates. Validate in a normal method and return the iterator from it, as Paged does.

Key lessons

  • Generics give type safety without duplication; constraints tell the compiler what a type can do
  • Choose the collection that matches the access pattern: HashSet for uniqueness, Queue for first-in-first-out, Stack for last-in-first-out, Dictionary for lookup
  • Accept the narrowest interface that works (IEnumerable<T>) and return something useful
  • Delegates are variables that hold methods; Func returns a value, Action does not, Predicate answers yes or no
  • A closure captures the variable, not its value; beware loop variables
  • Events let a publisher announce without knowing its subscribers; always ?.Invoke, and unsubscribe when finished
  • A LINQ query is a recipe: nothing runs until you enumerate it, so call ToList() to take a snapshot
  • GroupBy then Select is the shape of most reports; sort before Take, and add ThenBy for stable ties
  • yield return produces a sequence lazily and lets you work with endless data
  • Validate arguments in a normal method and return the iterator from it

Summary

Day 3 gave you the abstraction toolkit. Generics remove duplication without losing type safety. The right collection turns a tangled algorithm into one obvious line. Delegates and lambdas let you pass behaviour as easily as data. Events decouple the code that notices something from the code that reacts. LINQ lets you describe a result instead of coding the steps. And iterators let you produce data only when it is asked for. Notice how they combine: LINQ is generics plus delegates plus iterators, and events are delegates with a safety rail. Tomorrow, Day 4 adds extension methods, pattern matching, records and with, IDisposable, JSON, unit testing and dependency injection, and then the Expense Tracker capstone pulls all of the mid-level material together.


More on these topics: C# posts

Top comments (0)