Skip to main content
ANVISoftware Solutions
Lesson 40 of 62Intermediate16 min

Extension Methods

By the end of this lesson

Add methods to existing types, and know when this is inappropriate.

You cannot add a method to DateOnly. It belongs to the .NET libraries and you do not own the source. The same is true of every type from a third-party package, and of generated code that a tool will overwrite.

An extension method lets you write a method that is called as though it belonged to such a type. Underneath it is an ordinary static method, and the compiler rewrites the call. Nothing is added to the type itself — nothing could be — but the code at the call site reads as if something was.

Declaring one, and calling it
C#
namespace Payroll.Formatting;

// Extension methods live in a static class. The class name is rarely typed by callers.
public static class MoneyExtensions
{
    // The this on the first parameter is what makes this an extension method.
    public static string ToCurrencyText(this decimal amount) => $"INR {amount:N2}";

    // Extra parameters work as normal, and come after the this one.
    public static decimal WithTax(this decimal amount, decimal rate) => amount * (1m + rate);
}

public static class DateExtensions
{
    public static bool IsWeekend(this DateOnly date) =>
        date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday;

    // The Indian financial year runs April to March.
    public static string FinancialYear(this DateOnly date) =>
        date.Month >= 4
            ? $"{date.Year}-{(date.Year + 1) % 100:00}"
            : $"{date.Year - 1}-{date.Year % 100:00}";
}

// At the call site, with a using for Payroll.Formatting in scope:
decimal salary = 1_200_000m;
Console.WriteLine(salary.ToCurrencyText());              // INR 1,200,000.00 (grouping follows the current culture)
Console.WriteLine(salary.WithTax(0.18m));                // 1416000

DateOnly payDate = new DateOnly(2026, 5, 31);
Console.WriteLine(payDate.IsWeekend());                  // True
Console.WriteLine(payDate.FinancialYear());              // 2026-27

// Which the compiler turns into this. Both forms are legal C#.
Console.WriteLine(MoneyExtensions.ToCurrencyText(salary));
  • The containing class must be static, and so must the method. The this modifier goes on the first parameter and names the type being extended.
  • Inside the method, the parameter is an ordinary parameter. There is no special access and no hidden reference to anything.
  • Calls pass the receiver as that first argument. salary.ToCurrencyText() and MoneyExtensions.ToCurrencyText(salary) are the same call.
  • The last line matters for debugging. When a stack trace shows MoneyExtensions.ToCurrencyText, that is the same method you called with dot syntax.
  • The namespace is the delivery mechanism. A caller sees these methods only when Payroll.Formatting is in scope, which is why the next section is about resolution.

How the compiler decides what invoice.Total() means:

  1. Look for an instance method first

    It searches the type and everything it inherits. If a matching instance method exists, that one is used and extension methods are never considered.

  2. Then look for extension methods in scope

    Static classes in the current namespace, and in any namespace brought in by a using directive. A method that is not in scope does not exist as far as this file is concerned.

  3. Pick the closest match

    Among candidates, the compiler prefers the most specific receiver type — an extension on List<Order> beats one on IEnumerable<Order> for a list.

  4. Otherwise report that the member does not exist

    Error CS1061 says the type contains no such member. This is why a missing using looks exactly like a missing method, and why the first thing to check is the import.

The hard limit: no access to private state
C#
public class Invoice
{
    private readonly List<InvoiceLine> _lines = new List<InvoiceLine>();

    public IReadOnlyList<InvoiceLine> Lines => _lines;
    public InvoiceStatus Status { get; private set; }

    public void Cancel()
    {
        if (Status == InvoiceStatus.Paid)
        {
            throw new InvalidOperationException("A paid invoice cannot be cancelled.");
        }

        Status = InvoiceStatus.Cancelled;
    }
}

public static class InvoiceExtensions
{
    // Fine: uses only what any other outside code can see.
    public static bool HasLines(this Invoice invoice) => invoice.Lines.Count > 0;

    // Does not compile: _lines is private and this method is outside the class.
    // public static void ClearLines(this Invoice invoice) => invoice._lines.Clear();

    // Does not compile either: the setter on Status is private.
    // public static void ForceCancel(this Invoice invoice) =>
    //     invoice.Status = InvoiceStatus.Cancelled;
}
  • An extension method is ordinary static code that lives outside the class, so it obeys exactly the same access rules as any other outside code. The dot syntax at the call site changes nothing about visibility.
  • HasLines works because Lines is public. It is also the kind of method worth questioning: a HasLines property on Invoice itself would be shorter and closer to the data.
  • ClearLines cannot be written this way, and that is the feature working correctly. Clearing the lines is a decision about the invoice's state, and it belongs inside the invoice with whatever rules go alongside it.
  • ForceCancel is the clearest case. Cancel already exists and already carries the rule. An extension trying to set the status directly would be an attempt to route around that rule, and the compiler stops it.

The two ways a method can appear on a type:

 Instance methodExtension method
Where the code livesInside the typeIn a separate static class
Access to private membersYesNo — public surface only
Needs a using at the call siteNoYes, unless the namespace already matches
When both exist with the same signatureThis one is usedIgnored, silently
Can be virtual or overriddenYesNo
Works for every implementer of an interfaceOnly via a default interface memberYes, in one place
Requires owning the typeYesNo

Summary

  • An extension method is a static method that the compiler lets you call with dot syntax on another type
  • It is delivered through its namespace, so a missing using looks exactly like a missing method
  • An instance method always wins, which means an extension can add behaviour but never change it
  • It has no access to private state, so it cannot be where an invariant is enforced
  • Right for types you do not own; wrong as a way to move your own type's behaviour out of it

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

Write an extension method on string called IsValidSku that returns true when the value is not blank, is at least six characters, and contains a hyphen. Handle a null receiver without throwing.

Then write a second extension, on your own Order class, called MarkAsShipped. Stop when you hit the problem, and write down what it tells you.

Show solution

IsValidSku is a reasonable extension. string is not yours, the rule belongs to your project, and handling null inside the method makes the call site simpler rather than more cautious. Declaring the parameter as string? states that a null is expected rather than tolerated by accident.

MarkAsShipped runs into the wall deliberately. Setting the status means writing to a property whose setter is private, and it also means enforcing a rule — an order with no lines, or a cancelled one, should not ship. Neither is possible from outside the class.

That is the useful conclusion. The limitation is not an inconvenience to work around by widening the setter to public. It is the language pointing out that behaviour which changes an object's state belongs on the object. Making the setter public to let the extension work would trade an encapsulated type for a convenient file layout.

C#
public static class SkuExtensions
{
    // string? says a null receiver is expected. No dereference happens on the call.
    public static bool IsValidSku(this string? value) =>
        !string.IsNullOrWhiteSpace(value) && value.Length >= 6 && value.Contains('-');
}

string? missing = null;
Console.WriteLine(missing.IsValidSku());       // False, no exception
Console.WriteLine("DL-1001".IsValidSku());     // True

// What MarkAsShipped wanted to be, in the only place it can work:
public class Order
{
    public OrderStatus Status { get; private set; }
    public IReadOnlyList<OrderLine> Lines => _lines;

    public void MarkAsShipped()
    {
        if (Status != OrderStatus.Submitted)
        {
            throw new InvalidOperationException("Only a submitted order can be shipped.");
        }

        Status = OrderStatus.Shipped;
    }
}

Think about it

Think about it

A team keeps a shared Utilities project full of extension methods on their own domain types: OrderExtensions, InvoiceExtensions, CustomerExtensions. The argument is that it keeps the domain classes small.

What does a new developer lose, and what would you propose?

Show solution

They lose the ability to learn a type by reading it. Opening Order no longer tells you what an order can do, because half of it is in another project, and there is no link from the class to the extensions. Discovering the behaviour requires knowing to search for it.

Every extension is also stuck outside the encapsulation boundary, so either the methods only do trivial things, or the domain classes have been opened up with public setters to let them work. The second outcome is the expensive one: the types stop protecting themselves, and validation drifts into whichever extension remembered it.

A reasonable proposal: move behaviour that belongs to a type onto that type, and keep the Utilities project for genuine extensions on types the team does not own. If a domain class then looks too long, that is a signal the type is doing several jobs — the answer is splitting the concept, not relocating the methods.

One honest exception. If the domain classes are generated, or shared with another team who will not accept changes, extension methods may be the only route available. Say so in a comment, so the next reader knows it was a constraint rather than a preference.

Saved in this browser only.