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})";
}
Output:
Box<Int32>(42)
Box<String>(hello)
9
pear
2.5
What is happening here:
-
Box<T>is written once.Box<int>andBox<string>are two separate closed types the compiler generates and checks. -
Max<T>works for any type, and the compiler works outTfrom the arguments, so you never writeMax<int>(3, 9). -
where T : IComparable<T>promises thatCompareToexists. Without the constraint the compiler would refuse to compilea.CompareTo(b). - Before generics, collections stored
object, which meant casting on the way out and runtime crashes for wrong types. AList<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;
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
Why this is the best way to write it:
-
One implementation serves every entity.
CustomerandProductshare the same repository code, and the compiler keeps them apart: adding aProductto the customer repository is a compile error, not a runtime surprise. -
where T : class, IEntityis what makes the body possible. Because everyThas anId, the repository can useitem.Idas the dictionary key without knowing the concrete type. Theclasspart also makesT?simply mean "may be null". -
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. -
A
Dictionarykeyed by id gives constant-timeGetandRemove. AListwould scan every item. -
TryAddrejects duplicates in one step, and the error message usestypeof(T).Name, so it says "A Customer with id 1" for each type automatically. -
GetAllreturns a copy asIReadOnlyList<T>. Callers cannot add to or remove from the repository's internal storage by accident. -
Getreturnsnullfor "not found" andRemovereturns 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;
}
Output:
2
False
True
first
first
1
3
2
apple, fig, pear
9
2
11.5
What is happening here:
-
HashSet<T>.Addreturns false for a duplicate.Containsis near-instant even with a million items, whereasList.Containsscans. -
QueuehasEnqueue,DequeueandPeek.StackhasPush,PopandPeek. Both throw if you read from an empty one, so useTryDequeueandTryPopwhen empty is a normal case. -
SortedDictionarykeeps keys in order automatically, at the cost of slower inserts than a plainDictionary. -
Accept the widest interface you need, return the most useful one.
AveragetakesIEnumerable<int>, so it works with a list, an array and a lazy query. Asking forList<int>would have locked callers out. - The hierarchy:
IEnumerable<T>(loop) thenICollection<T>(addsCount,Add,Remove) thenIList<T>(adds indexing andInsert).
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;
}
}
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)
Why this is the best way to write it:
-
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 aStack. Choosing the right one means the code needs no sorting, no index juggling and no "find the oldest" loop. -
TryDequeueandTryPopinstead ofDequeueandPop. An empty queue is a normal situation, not an error, and theTryversions return false instead of throwing. Thewhile (printer.PrintNext() is { } job)loop ends neatly when the queue is empty. - 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.
-
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. -
The collections are
private readonly. Outside code can only change them throughType,UndoandRedo, so the two stacks can never be put out of step. - Undo and redo return a bool so the caller (a menu or button) can disable the option when there is nothing left to do.
-
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);
Output:
5
hello from a delegate
True
25
105
Hello World
20
333
012
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'sWhere,SelectandOrderBywork. - 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 variablefactoritself, so whenfactorchanged to 10 the lambda saw it. - The
forloop trap: all three lambdas share the single variablei, which is 3 when the loop ends. Declaringint copy = iinside the loop gives each lambda its own variable. (foreachalready 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;
}
}
Output:
2, 4, 6, 8, 10
7, 8, 9, 10
Asha, Anil
hello-big-world
17.99
1
Why this is the best way to write it:
-
Filterknows 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 whatEnumerable.Whereis, and now you know how it works. -
The closure over
minimumis useful, not a trap. The rulen >= minimumuses 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. -
Predicate<T>documents intent. A parameter of typePredicate<T>says "this is a yes/no question", which reads better thanFunc<T, bool>, though they are interchangeable in practice. -
Null arguments are rejected at the top. If
keepwere null the failure would otherwise appear deep inside the loop with a confusing stack trace. -
This version builds a
Listimmediately (eager). That keeps the first lesson simple. The built-inWhereis lazy, which you will write yourself in concept 6 withyield return. -
Pipeline<T>stores a list ofFunc<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.Thenreturnsthis, which allows the fluent chain. - 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));
}
Output:
A heard a click at (10, 20)
B heard it too
-- A unsubscribed --
B heard it too
What is happening here:
-
event EventHandler<ClickEventArgs>? Clickeddeclares the event.EventHandler<T>is the standard shape:(object? sender, T e). -
Clicked?.Invoke(this, ...)raises it. The?.matters: an event with no subscribers isnull, 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}");
}
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)
Why this is the best way to write it:
-
The inventory has no idea who is listening. It raises
LowStockand moves on. The logger and notifier are separate classes, and adding an SMS alert later means writing one class and one+=, with no change toInventory. -
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. - 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.
-
The standard
EventHandler<TEventArgs>signature, with aLowStockEventArgsclass that carries the item, quantity and threshold. Subscribers get everything they need from the arguments and never reach back into the inventory. -
protected virtual OnLowStockis the standard raise method. It keeps the null-safe?.Invokein one place, and a subclass can override it. -
Unsubscribing works with named methods.
inventory.LowStock -= notifier.OnLowStockremoved only the notifier, which the final output confirms: only the log line appears. -
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
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
What is happening here:
-
Wherefilters,Selecttransforms,OrderBysorts. 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 when4was added. It ran atCount(), against the data as it was then.ToList()runs immediately and gives a fixed snapshot. -
GroupByreturns groups, each with aKeyand the items in it.Joinmatches two sequences on a key.Aggregatefolds 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);
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)
Why this is the best way to write it:
- 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.
-
GroupBythenSelectis the pattern for almost every report. Group by the thing you are summarising, then project each group into a small result withSum,CountorAverage. All three reports follow it. -
Order matters: sort, then take.
OrderByDescending(...).Take(5)gives the top five. Reversing them would take five arbitrary customers and sort only those. -
ThenBybreaks 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. -
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. -
decimalfor the amounts, so the totals are exact to the cent. -
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. -
The queries are lazy and each runs once, in its
foreach. If you needed to loop over a result twice, callToList()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++;
}
Output:
3 2 1
Creating the sequence...
Starting the loop...
[producing 1]
got 1
[producing 2]
got 2
[done]
7, 14, 21
What is happening here:
- Each
yield returnhands 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
Noisyoutput 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, yetTake(3)finishes instantly because only as much of the sequence as is requested is ever produced. -
yield breakends the sequence early. An iterator cannot userefparameters and cannotyieldinside atrythat has acatch.
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
}
}
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
Why this is the best way to write it:
-
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. -
It stops cleanly before overflow. Without the guard, a
longquietly 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, thenyield break. -
The tuple swap
(a, b) = (b, a + b)updates both values at once, so there is no temporary variable to forget. -
Pagedis 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 meanspageSize: 0is rejected on the call, as the output shows ("Rejected immediately"), not later in some unrelated loop. -
It works on any
IEnumerable<T>, including another lazy sequence.Fibonacci().Take(10)is never materialised as a whole list. -
A new
Listis 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. -
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. -
.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.
-
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. CallToList()once when you need to use the results more than once. -
Capturing the loop variable of a
forloop. Every lambda sees the same final value, as the333output showed. Copy it to a local variable inside the loop. -
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 withClicked?.Invoke(...). -
List.Containsinside a loop. Checking every item against a list is quadratic: 100,000 by 100,000 comparisons. Put the lookup values in aHashSetand each check becomes near-instant. -
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
Pageddoes.
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:
HashSetfor uniqueness,Queuefor first-in-first-out,Stackfor last-in-first-out,Dictionaryfor lookup - Accept the narrowest interface that works (
IEnumerable<T>) and return something useful - Delegates are variables that hold methods;
Funcreturns a value,Actiondoes not,Predicateanswers 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 -
GroupBythenSelectis the shape of most reports; sort beforeTake, and addThenByfor stable ties -
yield returnproduces 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)