C# Extension Methods, a Practical Guide with a Few Tricks I Use
Suppose you are handling webhook events from a payment provider. Each event arrives with its status as a plain string, something like "succeeded", but the rest of your application works with a proper enum:
public enum PaymentStatus
{
Pending,
Succeeded,
Refunded,
Failed
}
Assume webhookEvent is the object your application has created from the incoming webhook, and its Status property contains that raw status string.
At some point, that string has to be converted into the enum. The straightforward way is to write the conversion right where you need it:
var status = webhookEvent.Status switch
{
"pending" => PaymentStatus.Pending,
"succeeded" => PaymentStatus.Succeeded,
"refunded" => PaymentStatus.Refunded,
"failed" => PaymentStatus.Failed,
_ => throw new ArgumentException($"Unknown payment status: {webhookEvent.Status}")
};
That works, but it does not scale. The moment you need this same conversion in a second place, another controller, a background job, a test, you are either copying the whole switch again or hunting down where you last wrote it.
What you really wish you could have is something like a custom String method, then call it like this everywhere you need it:
var status = webhookEvent.Status.ToPaymentStatus();
It reads well: take the status, then convert it. There is just one problem. For that line to work, ToPaymentStatus would need to be a real method on string. But string is a type built into .NET. You did not write it, and you cannot add a method to it directly.
Extension methods are what make that dream possible. They let you call a method on a type as though it belonged to that type, when in fact you wrote it somewhere else entirely.
This post covers what they are, what the compiler actually does with them, the rules that catch people out, and a handful of places where they earn their keep. That includes a few tricks with Entity Framework that can take a sprawling configuration method down to a handful of readable lines.
A first example
Here is what an extension method actually looks like. This example implements our PaymentStatus converter.
using Billing.Payments.Constants;
using Billing.Payments.Exceptions;
namespace Billing.Payments.Enums.Extensions;
public static class StringPaymentStatusExtensions
{
public static PaymentStatus ToPaymentStatus(this string? status)
{
return status switch
{
PaymentStatusConstants.Pending => PaymentStatus.Pending,
PaymentStatusConstants.Succeeded => PaymentStatus.Succeeded,
PaymentStatusConstants.Refunded => PaymentStatus.Refunded,
PaymentStatusConstants.Failed => PaymentStatus.Failed,
_ => throw new UnknownPaymentStatusException(nameof(PaymentStatus), status)
};
}
}
It is under twenty lines, and every rule of the feature is visible in it. Let me take it apart.
The class must be static
public static class StringPaymentStatusExtensions
A static class is one you can never create an instance of. It exists purely as a container for methods. There is no new StringPaymentStatusExtensions(), and there is no state to hold.
The class also has to be a top-level, non-generic class. You cannot declare extension methods inside another class, inside a struct, or inside a generic class. If the compiler complains that your method "must be defined in a non-generic static class", this is why.
People sometimes call these "extension classes". It is a reasonable name, but worth being precise about: the class is only a box. The feature lives on the method.
The method must be static
public static PaymentStatus ToPaymentStatus(...)
A normal method is called on an object. A static method is called on the class itself.
Because a static class can never be instantiated, there is no object to call a normal method on. Its methods therefore have to be static too.
That suits extension methods perfectly. The extension class is only there to hold the method. The value you are actually working with is passed into the method as a parameter.
The first parameter carries this
public static PaymentStatus ToPaymentStatus(this string? status)
The first parameter is what makes this an extension method. Each part has a job:
thistells the compiler that the method can be called using extension syntax, such aswebhookEvent.Status.ToPaymentStatus().string?is the type being extended. The?means the parameter may also benull.statusis the parameter name used inside the method.
When you write:
webhookEvent.Status.ToPaymentStatus()
the value of webhookEvent.Status is passed into the status parameter. The compiler treats the call as though you had written:
StringPaymentStatusExtensions.ToPaymentStatus(webhookEvent.Status)
The value before .ToPaymentStatus() is often called the receiver. Here, webhookEvent.Status is the receiver.
The body is ordinary C#
return status switch
{
PaymentStatusConstants.Pending => PaymentStatus.Pending,
PaymentStatusConstants.Succeeded => PaymentStatus.Succeeded,
PaymentStatusConstants.Refunded => PaymentStatus.Refunded,
PaymentStatusConstants.Failed => PaymentStatus.Failed,
_ => throw new UnknownPaymentStatusException(nameof(PaymentStatus), status)
};
This is a switch expression, the more modern form of switch you may not have seen if you have been away from C# for a few years.
The main difference is that each match produces a value directly, using =>, so the whole switch can be returned as one expression.
The _ at the bottom is the catch-all. Anything that did not match one of the known statuses lands there, and in this case throws.
That means an unrecognised status can never quietly turn into the wrong enum value. It fails loudly with a specific domain exception instead, which matters here because a bad payment-status conversion can have real consequences, such as treating a refunded payment as successful.
The constants behind the switch
You might reasonably ask why the switch matches against PaymentStatusConstants.Pending rather than the literal "pending". The constants class sits alongside the extension method:
public static class PaymentStatusConstants
{
public const string Pending = "pending";
public const string Succeeded = "succeeded";
public const string Refunded = "refunded";
public const string Failed = "failed";
}
That indirection is deliberate, for two reasons.
The literal "pending" is not your value. It belongs to the payment provider, and you do not control it. If the provider ever changes how it spells a status, or you add a second provider that spells the same status differently, you want exactly one place in the codebase to update, not a search-and-replace through every switch that happens to mention it.
The constant is also reusable in the other direction. The same PaymentStatusConstants.Succeeded shows up again when you build an outgoing filter, such as ?status=succeeded, or write a test fixture that asserts on the raw payload. A string literal buried inside a switch arm cannot be reused anywhere else. A named constant can.
What the compiler actually does
This is the part worth internalising, because nearly every rule that follows falls straight out of it.
An extension method is not attached to the type. Nothing is injected into System.String. Nothing is patched at runtime. The call is simply rewritten.
When you write this:
var status = webhookEvent.Status.ToPaymentStatus();
the compiler emits this:
var status = StringPaymentStatusExtensions.ToPaymentStatus(webhookEvent.Status);
Both lines compile to identical IL. The method call was always a plain static call. You just got to write it in a nicer order.
This is sometimes called syntactic sugar: a nicer way to write something the language could already express. That is not a criticism. Reading order matters a great deal, and moving the subject to the front of the sentence is worth a lot on a busy line of code.
It does, however, mean extension methods come with some sharp edges. Here are the five that actually bite.
Existing type methods always win
If the type already has a method with the same name and compatible parameters as your extension method, C# will use the method on the type instead.
For example:
public static class ListExtensions
{
public static void Add<T>(this List<T> list, T item) =>
Console.WriteLine("This is never printed.");
}
var names = new List<string>();
names.Add("Loukas"); // Calls List<T>.Add, not the extension method.
List<T> already has an Add method that accepts a string, so C# uses that method. Your extension method is never used.
This is worth keeping in mind when naming extension methods on types you do not control. If a matching method is later added to that type, it will take precedence over your extension method.
You can call an extension method on null
This one can be surprising. You can call an extension method even when the value before the dot is null:
public static bool HasContent(this string? value) =>
!string.IsNullOrWhiteSpace(value);
string? description = null;
if (description.HasContent()) // Runs fine and returns false.
{
// ...
}
That works because HasContent is really a static method. C# effectively turns this:
description.HasContent()
into this:
StringExtensions.HasContent(description)
If description is null, then null is simply passed into the method. Whether that is safe depends on what your extension method does with it.
In this example, string.IsNullOrWhiteSpace already handles null, so HasContent returns false.
The payment status extension from earlier uses the same idea deliberately:
public static PaymentStatus ToPaymentStatus(this string? status)
The parameter is string?, so the method explicitly accepts null. A null status reaches the switch just like any other value, fails to match one of the known statuses, and results in an UnknownPaymentStatusException.
That gives you an error about the actual problem instead of an unrelated NullReferenceException later on.
If your extension method is designed to accept null, mark the parameter as nullable. If null is not valid, check for it and throw an appropriate exception.
LINQ methods take the second approach. Passing a null source to methods such as Where or Select results in an ArgumentNullException.
There is one more useful detail if your extension method checks for null.
Consider this method:
public static bool HasContent(this string? value) =>
!string.IsNullOrWhiteSpace(value);
You know that if HasContent() returns true, then value cannot be null. The compiler does not automatically know that, though.
So with this code:
string? description = GetDescription();
if (description.HasContent())
{
Console.WriteLine(description.Length);
}
the compiler may still treat description as nullable inside the if block.
You can tell it what HasContent guarantees by adding NotNullWhen(true):
using System.Diagnostics.CodeAnalysis;
public static bool HasContent([NotNullWhen(true)] this string? value) =>
!string.IsNullOrWhiteSpace(value);
NotNullWhen(true) means: if this method returns true, the argument passed to value is not null.
Now, inside:
if (description.HasContent())
{
Console.WriteLine(description.Length);
}
the compiler knows that description is not null, so it does not produce a nullable warning.
The attribute does not change what the method does at runtime. It only gives the compiler more information for its null checks.
The namespace has to be available
Writing an extension method is not enough. The file where you want to use it also needs access to the namespace that contains it.
For example, if your extension lives here:
namespace MyApp.Extensions;
public static class StringExtensions
{
public static bool HasContent(this string? value) =>
!string.IsNullOrWhiteSpace(value);
}
then another file will normally need:
using MyApp.Extensions;
before this will compile:
description.HasContent();
Without that namespace, C# cannot find your extension method and reports that string has no definition for HasContent.
So if an extension method exists but C# cannot see it, one of the first things to check is whether its namespace is available in that file.
Extension methods are chosen from the variable's declared type
Suppose you write two extension methods with the same name:
public static string Describe(this object value) => "an object";
public static string Describe(this string value) => "a string";
Now consider this:
object thing = "hello";
Console.WriteLine(thing.Describe());
The value stored in thing is a string, but the variable itself is declared as object.
Because of that, C# chooses:
Describe(this object value)
and the output is:
an object
C# chooses an extension method using the type the variable is declared as. It does not wait until runtime to inspect the actual object and choose a different extension method.
That is different from overridden instance methods. A virtual instance method can be chosen based on the object's actual runtime type. Extension methods cannot be overridden and do not work that way.
Why not just write a static helper?
You could solve the payment status problem with an ordinary static method:
var status = PaymentStatusParser.Parse(webhookEvent.Status);
That already fixes the main problem with putting the switch directly at every call site. The conversion now lives in one place and can be reused anywhere.
So why make it an extension method instead?
One reason is reading order.
With an extension method:
var status = webhookEvent.Status.ToPaymentStatus();
you start with the value you are working with and then say what you want to do with it.
With a static helper:
var status = PaymentStatusParser.Parse(webhookEvent.Status);
the operation comes first and the value being converted is passed in afterwards.
The difference becomes more obvious when several operations are combined.
Static helper methods often end up nested:
var result = StringHelper.Truncate(
StringHelper.StripHtml(
StringHelper.Trim(input)), 200);
To understand that expression, you have to start with the innermost method and work your way out:
Trim
StripHtml
Truncate
Extension methods let the same sequence read in the order it happens:
var result = input
.Trim()
.StripHtml()
.Truncate(200);
Start with input, trim it, remove the HTML, then truncate it.
There is also discoverability. When you type a dot after a value, your editor can show extension methods alongside the methods already available on that type.
That means someone working with a string can discover ToPaymentStatus, HasContent, or any other relevant extension without first knowing which helper class contains it.
A static helper such as PaymentStatusParser is much harder to discover unless you already know that class exists.
Where extension methods earn their keep
1. You have been using them all along: LINQ
Here is the reveal. Where, Select, OrderBy, First, Any, ToList, all of it. Every one is an extension method.
IEnumerable<T> is a tiny interface. It has exactly one method, and it is not Where. The entire LINQ vocabulary lives in a static class called System.Linq.Enumerable, and every method in it starts like this:
public static IEnumerable<TSource> Where<TSource>(
this IEnumerable<TSource> source,
Func<TSource, bool> predicate)
That is why using System.Linq; is the difference between a list having dozens of methods and having almost none. And it is why LINQ works on arrays, lists, dictionaries, database queries and your own custom collections without any of them knowing LINQ exists. One set of extension methods on one interface reaches everything that implements it.
This is the deepest use of the feature, and it is worth copying. Writing your own LINQ-style operator is straightforward:
public static class EnumerableExtensions
{
/// <summary>Drops nulls and tells the compiler the result has none.</summary>
public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source)
where T : class
{
return source.Where(item => item is not null)!;
}
}
Used like anything else in the chain:
var covers = posts.Select(post => post.Cover)
.WhereNotNull()
.Distinct()
.ToList();
2. Wiring up services
If you have written an ASP.NET Core app, you have seen this:
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(connection));
builder.Services.AddAuthentication().AddJwtBearer();
IServiceCollection has no AddControllers method. It is barely more than a list. Every one of those calls is an extension method shipped by the package that needs it.
You can and should do the same. Registration code tends to sprawl across Program.cs until nobody can find anything. Group it:
namespace Microsoft.Extensions.DependencyInjection;
public static class BlogServiceCollectionExtensions
{
public static IServiceCollection AddBlogServices(this IServiceCollection services)
{
services.AddScoped<IPostService, PostService>();
services.AddScoped<ISeriesService, SeriesService>();
services.AddScoped<IDefinitionService, DefinitionService>();
return services;
}
}
Note the return services; at the end. Returning the receiver is what makes calls chainable, and it is the convention across the whole .NET ecosystem. Follow it.
Program.cs then reads as a table of contents:
builder.Services
.AddBlogServices()
.AddAnalytics()
.AddJwtAuth(builder.Configuration);
Tips for where to use extension methods
Extension methods work best when an operation naturally belongs to the value you are working with, especially when the same rule or transformation appears in more than one place.
Here are a few places where they are particularly useful.
Keeping Entity Framework configuration readable
OnModelCreating has a habit of growing. Extension methods can move larger pieces of configuration behind names that make the method easier to scan.
For example, suppose every decimal property in your model should use the same precision:
public static ModelBuilder ApplyDecimalPrecision(
this ModelBuilder modelBuilder,
int precision = 18,
int scale = 2)
{
var properties = modelBuilder.Model
.GetEntityTypes()
.SelectMany(entity => entity.GetProperties())
.Where(property =>
property.ClrType == typeof(decimal) ||
property.ClrType == typeof(decimal?));
foreach (var property in properties)
{
property.SetPrecision(precision);
property.SetScale(scale);
}
return modelBuilder;
}
Your OnModelCreating method can then apply that rule with one line:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyDecimalPrecision();
}
Add another decimal property later and the same rule applies automatically.
This is a good use of an extension method because the rule applies to the model as a whole.
For configuration that belongs to one specific entity, EF Core already has IEntityTypeConfiguration<T> and ApplyConfigurationsFromAssembly(...). Those are usually a better fit than writing your own ModelBuilder extension for every table.
Reusing query rules
If the same filtering rule appears in several queries, an extension method gives that rule one definition.
Suppose unpublished posts should normally be hidden:
public static IQueryable<PostModel> PublishedUnless(
this IQueryable<PostModel> posts,
bool includeUnpublished) =>
includeUnpublished
? posts
: posts.Where(post => post.Published != string.Empty);
You can then use that rule as part of a larger query:
var posts = await _context.BlogPosts
.PublishedUnless(includeUnpublished)
.OrderByDescending(post => post.Published)
.Take(10)
.ToListAsync();
The benefit is not just that the query becomes shorter. The definition of what counts as published now lives in one place.
When the extension is meant to be part of an Entity Framework query, extend IQueryable<T> rather than IEnumerable<T>.
IQueryable<T> lets EF inspect the query and translate the filtering into SQL. If you switch to IEnumerable<T> too early, the remaining work happens in C# instead.
Reusing database projections
Extensions can also package up a projection that appears in several queries.
Suppose the API often needs this smaller version of a post:
public static IQueryable<PostIndexDto> SelectIndexDtos(
this IQueryable<PostModel> posts) =>
posts.Select(post => new PostIndexDto
{
Slug = post.Slug,
Published = post.Published,
UpdatedAt = post.UpdatedAt
});
You can then write:
var index = await _context.BlogPosts
.SelectIndexDtos()
.ToListAsync();
EF can see the Select, so it knows exactly which properties are needed and can fetch only those columns from the database.
That is different from writing an extension on a single PostModel:
public static PostIndexDto ToIndexDto(this PostModel post) => new()
{
Slug = post.Slug,
Published = post.Published,
UpdatedAt = post.UpdatedAt
};
That version is useful when you already have a PostModel in memory, but it is not a good replacement for a database projection. EF cannot look inside an ordinary C# method to work out which columns the query needs.
A useful distinction is:
post.ToIndexDto();
for an object you already have in memory, and:
query.SelectIndexDtos();
when you want the database query itself to produce the DTO shape.
Making repeated checks easier to read
Small extensions can be useful too, particularly when the same check appears in several places.
For example:
public static T OrThrow<T>(this T? value, string message)
where T : class =>
value ?? throw new InvalidOperationException(message);
You can then write:
var post = (await _context.BlogPosts.FindAsync(id))
.OrThrow($"Post {id} was not found.");
The extension is not doing anything complicated. It gives a common operation a name and lets the code read in the order it happens: get the post, then throw if there is no post.
When not to reach for an extension method
Extension methods work best when the operation feels like something the value itself can naturally do. They are less useful when they simply make unrelated code look convenient.
This, for example, would make me suspicious:
post.SendNotificationEmail();
It makes sending email look like something a PostModel knows how to do, even though the operation probably depends on an email service, configuration, logging, and other parts of the application.
Making the call site shorter does not necessarily make the design better.
A few other cases are worth watching for.
If the behaviour genuinely belongs to a type you own, consider putting it on the type itself. An extension method can otherwise move closely related behaviour into a separate file for no real benefit.
Be careful with work that hits a database, file system, or network. Something like
post.WithAuthor()may look harmless while quietly running another database query. If an extension performs expensive I/O, its name should make that clear.Avoid extending
objectunless there is a very good reason. An extension onobjectbecomes available on almost every value in scope and quickly clutters editor suggestions.Keep widely used extension names reasonably specific. If two imported namespaces both define the same extension for the same type, a call such as
value.Truncate()can become ambiguous. You then have to call the extension through its static class, or otherwise disambiguate which one you mean.Do not use an extension method when you actually need to add state to a type. Extensions can add callable methods, but they cannot add fields, stored properties, or constructors. If the new behaviour needs its own state, a separate class or wrapper is usually the better fit.
A useful test is to read the code out loud. If value.DoSomething() sounds like a natural operation on that value, and the method does not hide surprising behaviour, an extension method is probably a reasonable fit.
Where to put them, and what to call them
Name the class after what it extends, plus Extensions. StringExtensions, ModelBuilderExtensions, PostQueryExtensions. The example at the top of this post goes further with StringPaymentStatusExtensions, naming both the type it extends and the type it produces. On a codebase with several string conversions, that is worth the extra word.
Then choose a namespace, and understand the trade. There are two strategies, and they are genuinely different.
The first is a dedicated namespace, like the ...Enums.Extensions in the example. Anyone who wants the methods needs an explicit using. This keeps IntelliSense clean and makes the dependency visible in the file header.
The second is to declare the extensions in the namespace of the type being extended, so they appear without any extra import. This is not a hack. It is exactly what Microsoft does: AddControllers() ships in the MVC package but is declared in the Microsoft.Extensions.DependencyInjection namespace, which is why it turns up on builder.Services with no using at all. That is also why the service example above declares that namespace directly.
Default to the first. Use the second only when the method is a core part of how a library is meant to be used, and its absence would be confusing.
What is coming: extension members
Everything above is the classic syntax, and it is what you will find in essentially all existing C# code. It is not going anywhere.
C# 14, which shipped with .NET 10 in November 2025, added extension members. The receiver is declared once for a whole block, and that block can hold properties and static members, not just methods:
public static class PostQueryExtensions
{
extension(IQueryable<PostModel> posts)
{
// An extension property.
public bool HasDrafts => posts.Any(post => post.Published == string.Empty);
// An extension method, in the same block.
public IQueryable<PostModel> OnlyPublished() =>
posts.Where(post => post.Published != string.Empty);
}
}
if (_context.BlogPosts.HasDrafts)
{
// ...
}
The parameter is stated once at the top of the block rather than repeated on every signature, and HasDrafts is a property, which the old syntax could not express at all.
If you are on .NET 8, this on the first parameter is still your tool. Learn that shape first. The new syntax is the same idea with a wider reach.
Wrapping up
An extension method is a static method wearing a disguise. The compiler turns value.DoThing() back into Helpers.DoThing(value) before anything runs, and once you hold that one fact, the rest of the behaviour stops being surprising. No private access. No overriding. No exception on null. Instance methods win. The using matters.
What you get in exchange is the ability to put a method where a reader expects to find it, on the type it acts on, even when that type is sealed inside a framework you will never touch. That is why LINQ exists in the shape it does, why builder.Services.AddControllers() reads the way it does, and why a three hundred line OnModelCreating can become five lines without changing a single migration.
The rule of thumb is short. If you own the type, add a method to it. If you do not, and the operation genuinely belongs to that type in the reader's head, that is exactly what extension methods are for.