Why this post exists
Day 3 gave you the abstraction toolkit. Day 4 is about the tools you reach for when you build and maintain real software: extension methods, pattern matching, records, resource cleanup, JSON, unit testing and dependency injection. That is a lot for one post, so I split Day 4 into three.
- Part 1 (this post): extension methods, pattern matching and records
- Part 2: IDisposable, JSON, unit testing and dependency injection
- Part 3: the mid-level capstone, an Expense Tracker with its own test suite
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. The concepts are numbered 7 to 13 to continue the mid-level list from Day 3. Try each practice question yourself before reading the solution.
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 7: Extension methods
What it is. An extension method adds a method to an existing type without changing that type or inheriting from it. You write a static method in a static class and mark the first parameter with this. After that, you call it as if it had always belonged to the type: "hello".Shout(). All of LINQ (Where, Select, OrderBy) is extension methods on IEnumerable<T>.
The analogy: a phone case with a built-in card holder. You cannot open up the phone (a type you do not own, like string or DateTime) and add a card slot, but the case adds a feature that sits on the phone and feels like part of it. Take the case off and the phone is exactly as it was.
Console.WriteLine(10.IsEven()); // True (called like an int method) Console.WriteLine("hello".Shout()); // HELLO! string? nothing = null; Console.WriteLine(nothing.IsNullOrEmpty()); // True (extension methods work on null) string?[] raw = { "a", null, "b", null }; Console.WriteLine(string.Join(",", raw.WhereNotNull())); // a,b public static class Extensions // must be a static, non-nested class { public static bool IsEven(this int number) => number % 2 == 0; // 'this' marks the extended type public static string Shout(this string text) => text.ToUpperInvariant() + "!"; public static bool IsNullOrEmpty(this string? text) => string.IsNullOrEmpty(text); public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source) where T : class => source.Where(item => item is not null).Select(item => item!); } Output:
True HELLO! True a,b What is happening here:
- The first parameter, marked
this, is the object the method is called on. The class holding it must bestaticand not nested inside another class. - Extension methods are just static methods in disguise:
10.IsEven()compiles toExtensions.IsEven(10). That is why they work onnull: nothing is dereferenced until your code does it. - They are in scope only when the namespace is imported with
using. That is why LINQ needsSystem.Linq(included by default through implicit usings). - If the type already has a method with the same name and signature, the real method always wins. An extension can never override or replace existing behaviour.
- They can extend interfaces and generics (
IEnumerable<T>), which is the most powerful use: one method then works on every list, array and query.
Practice question 7: ToSlug, Batch and IsWeekend
Create three extension methods: string.ToSlug() (turn a title into a URL-friendly slug), IEnumerable<T>.Batch(n) (split a sequence into groups of n) and DateTime.IsWeekend().
Solution
using System.Globalization; using System.Text; Console.WriteLine("Hello, World! C# is Fun".ToSlug()); Console.WriteLine(" Crème Brûlée -- 100% Delicious ".ToSlug()); Console.WriteLine($"'{"!!!".ToSlug()}'"); Console.WriteLine(); foreach (List<int> batch in Enumerable.Range(1, 7).Batch(3)) { Console.WriteLine($"batch: {string.Join(", ", batch)}"); } Console.WriteLine(); var saturday = new DateTime(2026, 11, 7); var monday = new DateTime(2026, 11, 9); Console.WriteLine($"{saturday:ddd d MMM}: weekend? {saturday.IsWeekend()}"); Console.WriteLine($"{monday:ddd d MMM}: weekend? {monday.IsWeekend()}"); Console.WriteLine($"DateOnly overload: {new DateOnly(2026, 11, 8).IsWeekend()}"); public static class StringExtensions { public static string ToSlug(this string value) { ArgumentNullException.ThrowIfNull(value); string decomposed = value.Normalize(NormalizationForm.FormD); // "é" becomes "e" + a combining accent var slug = new StringBuilder(decomposed.Length); bool pendingDash = false; foreach (char c in decomposed) { if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) continue; // drop the accent if (char.IsAsciiLetterOrDigit(c)) { if (pendingDash && slug.Length > 0) slug.Append('-'); slug.Append(char.ToLowerInvariant(c)); pendingDash = false; } else { pendingDash = true; // collapse any run of junk into one dash } } return slug.ToString(); } } public static class EnumerableExtensions { public static IEnumerable<List<T>> Batch<T>(this IEnumerable<T> source, int size) { ArgumentNullException.ThrowIfNull(source); // checked at the call... ArgumentOutOfRangeException.ThrowIfNegativeOrZero(size); return BatchIterator(source, size); // ...the lazy part follows static IEnumerable<List<T>> BatchIterator(IEnumerable<T> items, int batchSize) { var batch = new List<T>(batchSize); foreach (T item in items) { batch.Add(item); if (batch.Count == batchSize) { yield return batch; batch = new List<T>(batchSize); } } if (batch.Count > 0) yield return batch; } } } public static class DateExtensions { public static bool IsWeekend(this DateTime date) => date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; public static bool IsWeekend(this DateOnly date) => date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; } Output:
hello-world-c-is-fun creme-brulee-100-delicious '' batch: 1, 2, 3 batch: 4, 5, 6 batch: 7 Sat 7 Nov: weekend? True Mon 9 Nov: weekend? False DateOnly overload: True Why this is the best way to write it:
-
ToSlughandles accents properly. Normalizing toFormDsplits "é" into "e" plus a combining accent mark, and the loop drops the mark. A naiveReplacefor special characters would turn "Crème Brûlée" into "cr-me-br-l-e". - It works in one pass with a
StringBuilder, and apendingDashflag collapses any run of punctuation and spaces into a single dash. That also means no leading, trailing or doubled dashes, with no cleanup step afterwards. -
char.IsAsciiLetterOrDigitkeeps slugs URL-safe. Anything else, including non-Latin letters, becomes a separator. If you need to keep other scripts, change that one condition. -
Batchis a generic extension onIEnumerable<T>, so it works on lists, arrays, other queries and lazy sequences. It is the Day 3Pagedmethod turned into something you can chain:orders.Where(...).Batch(100). -
Batchvalidates arguments straight away by splitting into a checking method and a private iterator, sosize: 0fails at the call and not later inside aforeach. (.NET 6 addedEnumerable.Chunk, which does the same job with arrays.) - Each batch is a fresh
List. Reusing one list would change the batches you already handed out. -
IsWeekendusesis ... or ...pattern matching, and there are overloads for bothDateTimeandDateOnly, so callers never need to convert. - Each extension class is small and named for what it extends (
StringExtensions,DateExtensions). One giantExtensionsclass that holds everything becomes impossible to navigate.
Concept 8: Pattern matching: is, switch patterns, property, relational and list patterns
What it is. Pattern matching lets you test the shape of a value and pull data out of it in one expression. Instead of chains of if, as and casts, you describe what you are looking for: a type, a property value, a range, a combination, or the contents of a list. The compiler checks that your patterns make sense, and a switch expression produces a value.
The analogy: airport security lanes with signs. A traveller walks up and the first sign that matches takes them: "children under 12", "crew with a badge", "anyone with more than two bags", "everyone else". Each sign describes a kind of traveller (their type, their properties, a range), and the order of the signs matters because the first match wins. The final catch-all sign guarantees nobody is left standing there.
object?[] things = { 42, -7, "", "hello", 3.5, null, new[] { 1, 2, 3 }, Array.Empty<int>(), new Point(0, 0), new Point(0, 5), new Point(2, 3), DateTime.MinValue }; foreach (object? thing in things) { Console.WriteLine(Describe(thing)); } static string Describe(object? value) => value switch { null => "nothing at all", // constant pattern int n and < 0 => $"negative int {n}", // type + relational pattern int n => $"int {n}", string { Length: 0 } => "empty string", // property pattern string s => $"string of {s.Length} letters", double d and (> 0 and < 10) => $"small double {d}", // logical patterns: and / or / not int[] and [var first, .., var last] => $"int array from {first} to {last}", // list pattern int[] and [] => "empty int array", Point { X: 0, Y: 0 } => "the origin", // nested property patterns Point { X: 0 } => "on the Y axis", Point(var x, var y) => $"point at ({x}, {y})", // positional pattern not null => $"something else ({value.GetType().Name})" }; record Point(int X, int Y); Output:
int 42 negative int -7 empty string string of 5 letters small double 3.5 nothing at all int array from 1 to 3 empty int array the origin on the Y axis point at (2, 3) something else (DateTime) What is happening here:
- Type pattern:
int ntests the type and gives you a typed variable in one step. - Relational and logical patterns:
int n and < 0,double d and (> 0 and < 10). Combine withand,orandnot. - Property pattern:
string { Length: 0 }andPoint { X: 0, Y: 0 }test properties. Nested access works too:{ Destination.Country: "US" }. - Positional pattern:
Point(var x, var y)uses the record's deconstruction. - List pattern:
[var first, .., var last]matches the first and last items of any length of list, and[]matches empty. - Order matters. The first matching arm wins, so put specific cases before general ones. The compiler reports an error if an arm can never be reached.
Practice question 8: shipping cost calculator
Write a shipping-cost calculator driven entirely by pattern matching. The cost depends on destination, weight, speed, whether the parcel is fragile and whether the destination is remote.
Solution
var samples = new (string Label, Package Package)[] { ("US standard, 0.5 kg", new(0.5m, new("US", false), ShippingSpeed.Standard, false)), ("US standard, 3 kg, fragile", new(3m, new("US", false), ShippingSpeed.Standard, true)), ("US express, 3 kg", new(3m, new("US", false), ShippingSpeed.Express, false)), ("US standard, 12 kg", new(12m, new("US", false), ShippingSpeed.Standard, false)), ("Germany standard, 2 kg", new(2m, new("DE", false), ShippingSpeed.Standard, false)), ("Germany overnight, 7 kg, remote, fragile", new(7m, new("DE", true), ShippingSpeed.Overnight, true)), }; foreach (var (label, package) in samples) { Console.WriteLine($"{label,-42} {ShippingCalculator.Cost(package),8:F2}"); } try { ShippingCalculator.Cost(new Package(0m, new Destination("US", false), ShippingSpeed.Standard, false)); } catch (ArgumentOutOfRangeException ex) { Console.WriteLine($"Rejected: {ex.ParamName}"); } public enum ShippingSpeed { Standard, Express, Overnight } public record Destination(string Country, bool IsRemote); public record Package(decimal WeightKg, Destination Destination, ShippingSpeed Speed, bool IsFragile); public static class ShippingCalculator { public static decimal Cost(Package package) { ArgumentNullException.ThrowIfNull(package); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(package.WeightKg, nameof(package)); return BaseRate(package) * SpeedMultiplier(package.Speed) + Surcharge(package); } // Tuple pattern: the country and the weight band decide the rate private static decimal BaseRate(Package p) => (p.Destination.Country, p.WeightKg) switch { ("US" or "CA", <= 1) => 5m, ("US" or "CA", <= 5) => 9m, ("US" or "CA", _) => 9m + (p.WeightKg - 5) * 1.5m, (_, <= 1) => 20m, (_, <= 5) => 35m, _ => 35m + (p.WeightKg - 5) * 4m }; private static decimal SpeedMultiplier(ShippingSpeed speed) => speed switch { ShippingSpeed.Standard => 1m, ShippingSpeed.Express => 1.8m, ShippingSpeed.Overnight => 3m, _ => throw new ArgumentOutOfRangeException(nameof(speed)) }; // Property patterns: the most specific combination goes first private static decimal Surcharge(Package p) => p switch { { IsFragile: true, Destination.IsRemote: true } => 15m, { IsFragile: true } => 6m, { Destination.IsRemote: true } => 10m, _ => 0m }; } Output:
US standard, 0.5 kg 5.00 US standard, 3 kg, fragile 15.00 US express, 3 kg 16.20 US standard, 12 kg 19.50 Germany standard, 2 kg 35.00 Germany overnight, 7 kg, remote, fragile 144.00 Rejected: package Why this is the best way to write it:
- The rules read like the business spec.
("US" or "CA", <= 1) => 5msays exactly what it means. There is no nestedif/elseto follow. - Three small switch expressions beat one giant one. Base rate, speed multiplier and surcharge are independent concerns, each easy to read and change on its own.
Costsimply combines them. - The tuple pattern
(country, weight)matches two inputs at once, so country-specific weight bands are one table. Bands are ordered from lightest to heaviest, so<= 5is only reached when<= 1did not match. - The most specific property pattern goes first.
{ IsFragile: true, Destination.IsRemote: true }is above{ IsFragile: true }. In the other order the general rule would swallow the combination and the 15 surcharge would never apply. - Every switch ends in a catch-all or a
throw, so an unexpected enum value fails loudly instead of returning a wrong price. A switch expression with no match throws at runtime anyway, but an explicit message is clearer. - Validation is at the top, before the rules. A zero or negative weight would otherwise fall into the "light parcel" band and quietly get the cheapest rate.
-
decimalfor money, as always, and an enum for speed, so a typo is a compile error.
Concept 9: Records, init, with and immutability
What it is. A record is a type built for carrying data. One line, record Person(string Name, int Age);, gives you a constructor, read-only properties, value-based equality, a readable ToString() and deconstruction. init lets a property be set during creation only. with creates a copy of a record with some values changed. Together they make immutable data easy: values that never change after they are created.
The analogy: a printed receipt and a bank ledger. You cannot edit a printed receipt; if it is wrong you print a corrected one (with), and the original still exists for the audit trail. A bank ledger works the same way. You never erase a line. You add new lines, and the balance is simply what you get by adding them all up. Tha...
