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. That second idea has a name, event sourcing, and it is what the practice question builds.
var asha = new Person("Asha", 30); // positional record: constructor, properties, equality
var older = asha with { Age = 31 }; // a copy with one change; asha is untouched
Console.WriteLine(asha); // Person { Name = Asha, Age = 30 }
Console.WriteLine(older);
Console.WriteLine(asha == new Person("Asha", 30)); // True: compared by value
var (name, age) = asha; // deconstruction comes free
Console.WriteLine($"{name} is {age}");
var settings = new Settings { Theme = "dark" }; // init: set during creation only
// settings.Theme = "light"; // compile error
// The shallow-copy trap: 'with' copies the REFERENCE to a list, not the list
var team1 = new Team("Red", new List<string> { "Asha" });
var team2 = team1 with { Name = "Blue" };
team2.Members.Add("Ravi");
Console.WriteLine($"{team1.Name} now has {team1.Members.Count} members"); // 2, not 1
record Person(string Name, int Age);
record Settings
{
public string Theme { get; init; } = "light";
}
record Team(string Name, List<string> Members);
Output:
Person { Name = Asha, Age = 30 }
Person { Name = Asha, Age = 31 }
True
Asha is 30
Red now has 2 members
What is happening here:
-
with { Age = 31 }produces a new record and leaves the original alone. Nothing is ever edited in place, so nothing can change under another part of the program. - Records compare by value: two
Personobjects with the same name and age are equal. A normal class compares by reference. -
initproperties can be set in an object initializer or constructor, and then never again. Records can also have regular methods and computed properties. -
The shallow-copy trap:
withcopies the reference to aList, not the list itself.team2andteam1share one list, so adding to one changed the other. A record is only as immutable as the types inside it. - The fix is to use immutable members:
IReadOnlyList<T>,ImmutableList<T>orImmutableArray<T>fromSystem.Collections.Immutable.
Practice question 9: an event-sourced Order
Model an event-sourced Order using immutable records. Store what happened (placed, item added, paid, shipped, cancelled) and derive the current order by replaying those events.
Solution
using System.Collections.Immutable;
DateTimeOffset t0 = new(2026, 11, 3, 9, 0, 0, TimeSpan.Zero);
var events = new List<OrderEvent>
{
new OrderPlaced(t0, "Asha", new[] { new OrderLine("KB-1", 1, 49.99m) }),
new ItemAdded(t0.AddMinutes(2), new OrderLine("MS-2", 2, 19.50m)),
new OrderPaid(t0.AddMinutes(10), 88.99m),
new OrderShipped(t0.AddHours(5))
};
Console.WriteLine("State after each event (replayed from the stream):");
for (int n = 1; n <= events.Count; n++)
{
Console.WriteLine($" {events[n - 1].GetType().Name,-13} -> {Describe(Order.Replay(events.Take(n)))}");
}
Order final = Order.Replay(events);
Order beforePayment = Order.Replay(events.Take(2));
Console.WriteLine($"\nTime travel: {Describe(beforePayment)}");
Console.WriteLine($"Present: {Describe(final)}");
try { final.Apply(new ItemAdded(t0.AddDays(1), new OrderLine("CB-3", 1, 9m))); }
catch (InvalidOperationException ex) { Console.WriteLine($"\nRejected: {ex.Message}"); }
static string Describe(Order o) =>
$"v{o.Version} {o.Status,-9} {o.Customer}, {o.Lines.Count} line(s), total {o.Total:F2}, paid {o.PaidAmount:F2}";
public enum OrderStatus { Placed, Paid, Shipped, Cancelled }
public record OrderLine(string Sku, int Quantity, decimal UnitPrice)
{
public decimal Total => Quantity * UnitPrice;
}
// The facts: things that happened. Immutable, and never edited afterwards.
public abstract record OrderEvent(DateTimeOffset At);
public sealed record OrderPlaced(DateTimeOffset At, string Customer, IReadOnlyList<OrderLine> Lines) : OrderEvent(At);
public sealed record ItemAdded(DateTimeOffset At, OrderLine Line) : OrderEvent(At);
public sealed record OrderPaid(DateTimeOffset At, decimal Amount) : OrderEvent(At);
public sealed record OrderShipped(DateTimeOffset At) : OrderEvent(At);
public sealed record OrderCancelled(DateTimeOffset At, string Reason) : OrderEvent(At);
// The state: derived by replaying the facts. Also immutable: every change returns a new Order.
public sealed record Order
{
public string Customer { get; init; } = "";
public ImmutableList<OrderLine> Lines { get; init; } = ImmutableList<OrderLine>.Empty;
public OrderStatus Status { get; init; }
public decimal PaidAmount { get; init; }
public int Version { get; init; }
public decimal Total => Lines.Sum(line => line.Total);
public static Order Replay(IEnumerable<OrderEvent> events) =>
events.Aggregate(new Order(), (order, e) => order.Apply(e));
public Order Apply(OrderEvent e) => (e switch
{
OrderPlaced p => this with { Customer = p.Customer, Lines = p.Lines.ToImmutableList(), Status = OrderStatus.Placed },
ItemAdded i when Status == OrderStatus.Placed => this with { Lines = Lines.Add(i.Line) },
OrderPaid p when Status == OrderStatus.Placed => this with { Status = OrderStatus.Paid, PaidAmount = p.Amount },
OrderShipped when Status == OrderStatus.Paid => this with { Status = OrderStatus.Shipped },
OrderCancelled when Status is OrderStatus.Placed or OrderStatus.Paid => this with { Status = OrderStatus.Cancelled },
_ => throw new InvalidOperationException($"{e.GetType().Name} is not allowed while the order is {Status}.")
}) with { Version = Version + 1 };
}
Output:
State after each event (replayed from the stream):
OrderPlaced -> v1 Placed Asha, 1 line(s), total 49.99, paid 0.00
ItemAdded -> v2 Placed Asha, 2 line(s), total 88.99, paid 0.00
OrderPaid -> v3 Paid Asha, 2 line(s), total 88.99, paid 88.99
OrderShipped -> v4 Shipped Asha, 2 line(s), total 88.99, paid 88.99
Time travel: v2 Placed Asha, 2 line(s), total 88.99, paid 0.00
Present: v4 Shipped Asha, 2 line(s), total 88.99, paid 88.99
Rejected: ItemAdded is not allowed while the order is Shipped.
Why this is the best way to write it:
-
Two kinds of record, two jobs. The
OrderEventrecords are facts: things that happened, never edited.Orderis a derived view that is rebuilt from the facts. You can always throw the view away and recompute it. -
Replay is a single
Aggregate.events.Aggregate(new Order(), (order, e) => order.Apply(e))folds the whole history into the current state, which is why you can also replay just the first two events and get the order as it was (the "time travel" lines). -
Applynever mutates. Every arm returnsthis with { ... }, a newOrder, so earlier states stay valid. That is whybeforePaymentandfinalcan both exist at once. -
The
whenguards enforce the business rules. Items can only be added while the order isPlaced, and shipping needsPaid. An illegal event throws, so the history can never contain a nonsense sequence. -
ImmutableListmakesLines.Add(...)return a new list. With a normalListthe shallow-copy trap above would let two versions of the order share and corrupt one list. -
Versioncounts applied events, which is exactly what you need later for optimistic concurrency (reject an update based on an old version). -
Events carry their own timestamps. The order logic never calls
DateTime.Now, so replaying is deterministic and easy to test.
Five classic mistakes
Each one follows the same pattern: what people write, why it hurts, what to write instead.
- Expecting an extension method to replace a real one. If the type already has a method with the same name and signature, the real method always wins and your extension is silently ignored. Give extensions names that do not collide.
-
Forgetting the
usingdirective. An extension method only exists where its namespace is imported. IfToSlug()suddenly "does not exist", check theusingfirst. -
Discarding the result of
with.person with { Age = 31 };on its own line changes nothing, becausewithreturns a new record and leaves the original alone. Assign it:var older = person with { Age = 31 };. -
Believing a record is automatically immutable.
withmakes a shallow copy, so aList<T>inside is shared between copies. UseImmutableList<T>orIReadOnlyList<T>for members you want to stay unchanged. -
Putting the general pattern first. In a switch the first matching arm wins. A broad
{ IsFragile: true }above{ IsFragile: true, Destination.IsRemote: true }makes the specific case unreachable. Order arms from most to least specific and end with a catch-all.
Key lessons
- Extension methods add behaviour to types you do not own; the real method always wins, and they work through
using - Put the argument checks of an iterator in a normal method and return the iterator from it
- Pattern matching describes the shape of data: type, property, relational, logical and list patterns
- Order switch arms from specific to general and finish with a catch-all or a
throw - Records give value equality,
ToStringandwith; everywithreturns a new object - A record is only as immutable as the types inside it
- Store events and derive state, and you get history and time travel for free
Summary
Extension methods and pattern matching make code read like a description of the problem instead of a set of instructions, and records remove a whole family of bugs where data changes under your feet. Together they are how modern C# is written. In Part 2 we move to the practical side: cleaning up resources with IDisposable, reading and writing JSON, unit testing, and dependency injection. Part 3 then builds a complete Expense Tracker that uses all of it.
More on these topics: C# posts
Top comments (0)