DEV Community

Manohari Jayachandran
Manohari Jayachandran

Posted on

C# Mid-Level Day 4 (Part 1): Extension Methods, Pattern Matching and Records, With Three Practice Projects

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!);
}
Enter fullscreen mode Exit fullscreen mode

Output:

True
HELLO!
True
a,b
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
    };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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. 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);
Enter fullscreen mode Exit fullscreen mode

Output:

Person { Name = Asha, Age = 30 }
Person { Name = Asha, Age = 31 }
True
Asha is 30
Red now has 2 members
Enter fullscreen mode Exit fullscreen mode

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 Person objects with the same name and age are equal. A normal class compares by reference.
  • init properties 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: with copies the reference to a List, not the list itself. team2 and team1 share 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> or ImmutableArray<T> from System.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 };
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Why this is the best way to write it:

  1. Two kinds of record, two jobs. The OrderEvent records are facts: things that happened, never edited. Order is a derived view that is rebuilt from the facts. You can always throw the view away and recompute it.
  2. 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).
  3. Apply never mutates. Every arm returns this with { ... }, a new Order, so earlier states stay valid. That is why beforePayment and final can both exist at once.
  4. The when guards enforce the business rules. Items can only be added while the order is Placed, and shipping needs Paid. An illegal event throws, so the history can never contain a nonsense sequence.
  5. ImmutableList makes Lines.Add(...) return a new list. With a normal List the shallow-copy trap above would let two versions of the order share and corrupt one list.
  6. Version counts applied events, which is exactly what you need later for optimistic concurrency (reject an update based on an old version).
  7. 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.

  1. 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.
  2. Forgetting the using directive. An extension method only exists where its namespace is imported. If ToSlug() suddenly "does not exist", check the using first.
  3. Discarding the result of with. person with { Age = 31 }; on its own line changes nothing, because with returns a new record and leaves the original alone. Assign it: var older = person with { Age = 31 };.
  4. Believing a record is automatically immutable. with makes a shallow copy, so a List<T> inside is shared between copies. Use ImmutableList<T> or IReadOnlyList<T> for members you want to stay unchanged.
  5. 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, ToString and with; every with returns 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)