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 be static and not nested inside another class.
  • Extension methods are just static methods in disguise: 10.IsEven() compiles to Extensions.IsEven(10). That is why they work on null: nothing is dereferenced until your code does it.
  • They are in scope only when the namespace is imported with using. That is why LINQ needs System.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:

  1. ToSlug handles accents properly. Normalizing to FormD splits "é" into "e" plus a combining accent mark, and the loop drops the mark. A naive Replace for special characters would turn "Crème Brûlée" into "cr-me-br-l-e".
  2. It works in one pass with a StringBuilder, and a pendingDash flag 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.
  3. char.IsAsciiLetterOrDigit keeps slugs URL-safe. Anything else, including non-Latin letters, becomes a separator. If you need to keep other scripts, change that one condition.
  4. Batch is a generic extension on IEnumerable<T>, so it works on lists, arrays, other queries and lazy sequences. It is the Day 3 Paged method turned into something you can chain: orders.Where(...).Batch(100).
  5. Batch validates arguments straight away by splitting into a checking method and a private iterator, so size: 0 fails at the call and not later inside a foreach. (.NET 6 added Enumerable.Chunk, which does the same job with arrays.)
  6. Each batch is a fresh List. Reusing one list would change the batches you already handed out.
  7. IsWeekend uses is ... or ... pattern matching, and there are overloads for both DateTime and DateOnly, so callers never need to convert.
  8. Each extension class is small and named for what it extends (StringExtensions, DateExtensions). One giant Extensions class 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 n tests 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 with and, or and not.
  • Property pattern: string { Length: 0 } and Point { 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:

  1. The rules read like the business spec. ("US" or "CA", <= 1) => 5m says exactly what it means. There is no nested if/else to follow.
  2. 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. Cost simply combines them.
  3. 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 <= 5 is only reached when <= 1 did not match.
  4. 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.
  5. 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.
  6. 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.
  7. decimal for 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...