Extension methods beyond the basics: binding and precedence rules, namespace scoping, null receivers, ref this for structs, C# 14 extension members, and good versus bad uses.
Extension methods are static methods with this on the first parameter. The compiler binds them at compile time, only after looking for instance members, and it searches the namespaces you've imported, closest first. An instance method with the same signature always wins, so adding one to the type can quietly change what your call does. The receiver can be null, and with this ref you can extend structs without copying. C# 14 adds extension blocks, so you can declare extension properties and static members too. I use them for fluent APIs and DI registration, not to hide business logic.
Extension methods are static methods that the compiler lets you call as if they were instance methods on another type. They're how LINQ adds operators to IEnumerable<T>, how ASP.NET Core builds fluent configuration (services.AddX(), app.UseY()), and how you add convenience to types you don't own. I'd explain binding rules, why instance methods win, null receivers, ref extensions on structs, the C# 14 extension block syntax, and the design mistakes they invite.
namespace Ordering.Extensions;
public static class MoneyExtensions
{
public static decimal WithGst(this decimal amount, decimal rate = 0.10m) => decimal.Round(amount * (1 + rate), 2);
}
using Ordering.Extensions;
var gross = 100m.WithGst(); // compiled as MoneyExtensions.WithGst(100m)
Rules: a static method in a non-generic, non-nested static class, with this on the first parameter.
How the compiler binds receiver.Method(args):
Method on the receiver's type (and base types/interfaces). If any applicable instance method exists, use it — extension methods aren't considered.using directives at each level. At the first scope containing applicable candidates, run overload resolution among them; stop there.Binding is compile-time and based on the receiver's static type — no virtual dispatch.
It protects types from having their behaviour changed by someone else's extension: if List<T> has Contains, a Contains extension can never hijack calls on a List<T>. The flip side: if a library later adds an instance method with the same name and compatible signature, your extension silently stops being called on the next compile — behaviour can change without any code change on your side.
Because an extension call is just a static call, the receiver can be null:
public static bool IsBlank(this string? s) => string.IsNullOrWhiteSpace(s);
string? name = null;
name.IsBlank(); // true, no NullReferenceException
Useful for null-tolerant helpers, but it can mislead readers (it looks like an instance call that should throw). Validate (ArgumentNullException.ThrowIfNull) unless null-tolerance is the explicit purpose, and annotate nullability.
ref extension methods on structs (C# 7.2)public static void Advance(ref this Cursor cursor, int count) => cursor.Position += count; // mutates caller's struct
public static int Remaining(in this Cursor cursor) => cursor.Length - cursor.Position; // no copy (readonly struct)
ref this lets an extension mutate the caller's struct in place (receiver must be a variable, not an rvalue).in this avoids copying large readonly structs.ref this requires struct constraint for generic receivers).extension blocksC# 14 introduces a new syntax that groups extension members by receiver and supports extension properties, static extension members and operators:
public static class EnumerableExtensions
{
extension<T>(IEnumerable<T> source)
{
public bool IsEmpty => !source.Any(); // extension property
public IEnumerable<T> WhereNotNull() => source.Where(x => x is not null); // extension method
}
extension<T>(IEnumerable<T>)
{
public static IEnumerable<T> Empty => []; // static extension member
}
}
if (orders.IsEmpty) return;
var none = IEnumerable<Order>.Empty;
The old this-parameter syntax still works and is binary compatible; the new syntax compiles to similar static methods plus metadata. Extension properties are still computed — they can't add state to the type.
order.CalculateDiscount() in a utilities namespace) — hard to find, can't access private state, multiple competing versions.customer.LoadOrders()) disguised as a cheap member call.object, string or unconstrained T in common namespaces appear everywhere.ToJson, ToSlug), with behaviour depending on which using is in scope.Good uses: fluent configuration (IServiceCollection, IApplicationBuilder), LINQ-style operators, adapters/convenience over types you don't own, and domain-specific helpers on value objects in a clearly named namespace.
Two shared libraries both defined string ToSlug(this string) with different rules; which one ran depended on using order per file, so product URLs differed between the website and the sitemap generator, breaking SEO links. We consolidated into one implementation in a dedicated namespace, banned the duplicate via BannedSymbols.txt, and added tests comparing slugs across services.
Checks deeper understanding than "static method with this".
Green flags: binding order and instance precedence; null receivers; ref extensions; C# 14 extension blocks correctly described; design judgement on when not to use them.
Red flags: thinks extensions can override instance methods or access private members; unaware of namespace scoping.
The compiler marks extension methods (and their containing class and assembly) with [Extension] (System.Runtime.CompilerServices.ExtensionAttribute). Callers compile x.M(a) to call ExtClass::M(x, a). Reflection sees an ordinary static method. Other .NET languages (VB.NET) honour the attribute.
using, global using) or encloses the call site.using static to import extension methods from a specific static class.IEnumerable<T>), so all implementers get them.GetEnumerator extension methods make types usable in foreach; GetAwaiter extensions make types awaitable; Deconstruct extensions enable deconstruction.The compiler emits:
get_IsEmpty(IEnumerable<T> source) for an extension property getter),orders.IsEmpty and IEnumerable<T>.Empty.Disambiguation: you can call the static form directly via the containing class. Existing this-style methods and new-style members can coexist.
Extension calls are ordinary static calls — inlinable, no overhead beyond the method itself. in/ref extensions on large structs avoid copies.
Microsoft.Extensions.DependencyInjection for AddX methods so they appear where services are configured).Contains for List<int> and observe that the instance method is always called.ref this and in this extensions on a struct and inspect copies with a mutable field.extension block with a property.namespace Advanced.Extensions;
public sealed record Order(int Id, decimal Total, bool IsCancelled);
public struct Counter { public int Value; }
public static class StringExtensions
{
// Classic extension method; works with a null receiver
public static bool IsBlank(this string? value) => string.IsNullOrWhiteSpace(value);
}
public static class CounterExtensions
{
// ref extension: mutates the caller's struct, no copy
public static void Increment(this ref Counter counter) => counter.Value++;
}
public static class OrderExtensions
{
// C# 14 extension block: properties and static members
extension(Order order)
{
public bool IsLarge => order.Total > 1_000m;
public Order Cancel() => order with { IsCancelled = true };
}
extension(Order)
{
public static Order Empty => new(0, 0m, false);
}
// Sequence helper in LINQ style
public static IEnumerable<Order> ActiveOnly(this IEnumerable<Order> orders) => orders.Where(o => !o.IsCancelled);
}
public static class Program
{
public static void Main()
{
string? missing = null;
Console.WriteLine(missing.IsBlank()); // True, no NullReferenceException
var counter = new Counter();
counter.Increment();
counter.Increment();
Console.WriteLine(counter.Value); // 2
var big = new Order(1, 2_500m, false);
Console.WriteLine(big.IsLarge); // extension property
Console.WriteLine(big.Cancel().IsCancelled); // True
Console.WriteLine(Order.Empty.Id); // static extension member
List<Order> orders = [big, new(2, 50m, true)];
Console.WriteLine(orders.ActiveOnly().Count());
}
}
What happens: core domain logic implemented as extensions in utility namespaces. Why it is wrong: scattered, duplicated and unable to protect invariants. Do instead: put behaviour on the domain type or a domain service.
What happens: user.GetPermissions() performs database calls.
Why it is wrong: looks cheap; used in loops; untestable.
Do instead: use injected services with explicit async methods.
object or unconstrained T broadlyWhat happens: public static string ToJson<T>(this T value) in a common namespace.
Why it is wrong: IntelliSense pollution and accidental use everywhere.
Do instead: constrain, or place in a dedicated namespace.
What happens: two ToSlug methods in different namespaces.
Why it is wrong: behaviour depends on imports; ambiguities.
Do instead: single owned implementation; banned APIs for duplicates.
What happens: relying on an extension that a later library version shadows with an instance method. Why it is wrong: behaviour changes silently on upgrade. Do instead: test behaviour, prefer distinctive names, review upgrade notes.
What happens: extensions that accept null receivers without documenting it. Why it is wrong: readers assume non-null receivers. Do instead: validate or clearly name null-tolerant helpers.
What happens: trying to add state with C# 14 extension properties.
Why it is wrong: they're computed; no storage is added.
Do instead: use ConditionalWeakTable for attached data, or a wrapper type.
What happens: static extension methods reading configuration or services via static locators. Why it is wrong: hidden dependencies and poor testability. Do instead: inject services.
Extension methods are a compile-time convenience with real design consequences: binding by namespace, losing to instance members, and invisibility to mocking. Seniors use them for fluent and adapter code and keep domain behaviour on the types themselves. C# 14 extension members are a genuine improvement for library authors (extension properties and static factories) and I would adopt them gradually, since they are source- and binary-compatible with existing extension methods.
A method is a named block of code with a signature: its name plus the number, types, order and ref/out/in kinds of its parameters (the return type is not part of it, so you cannot overload by return type alone). Instance methods run on an object and can use this; static methods belong to the type and cannot. Overloading means several methods with the same name and different signatures; the compiler picks the best match at compile time, preferring exact types over conversions and normal forms over params. Expression-bodied methods (=> expr) are short forms for one expression. Extension methods are static methods with a this parameter that look like instance methods; an instance method always wins if one fits. C# 14 adds extension blocks, which also allow extension properties and static extension members.
A static class cannot be instantiated or inherited and contains only static members; in IL it is abstract sealed. It is the right home for stateless helpers (Math, Path), constants and extension methods, which must be declared in a non-generic, non-nested static class. C# 14 adds extension blocks (extension(Order o) { ... }) inside static classes, so you can add extension properties and static extension members too. Static classes cannot implement interfaces or be injected, so static state is global, shared across threads and hard to replace in tests. Prefer a DI-registered singleton when something has state, configuration or I/O.
Deconstruction unpacks a value into separate variables, like var (id, name) = customer;. Tuples support it built in, positional records generate a Deconstruct method, and any other type works if it has a Deconstruct(out ..., out ...) method, which can be an extension, so you can add it to types you don't own. You can skip parts with _, deconstruct dictionary entries in foreach, and since C# 10 mix new and existing variables. Positional patterns like p is (0, var y) use the same Deconstruct method.
How to tell it (STAR)
**S:** Two endpoints showed different prices for the same order. **T:** Find the cause before customers noticed. **A:** I traced it to two extension methods with the same name in different namespaces, bound by each file's usings. I consolidated mapping into one explicit class and added contract and architecture tests. **R:** Prices were consistent and the duplicate-mapping class of bug could not come back unnoticed.
Last updated 30 Oct 2026.