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 Source:https://dev.to/manoharij/c-mid-level-day-3-generics-collections-delegates-events-linq-and-iterators-with-six-practice-3k17Related posts
Build a Group MMS Delivery Tracker with TypeScript
One bank CSV, three importers: the format QuickBooks Online, Xero and Wave will all accept
Detecting a missing bank statement from balance carry-forward (not from dates)
