C# methods end to end: instance and static, signatures, overloading rules, expression-bodied form, and classic and C# 14 extension members.
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.
I cover four things: instance vs static (what this is and where state lives), signatures and overloading (and how the compiler picks an overload), expression-bodied methods (syntax only), and extension methods, including the new C# 14 extension blocks.
| Instance method | Static method | |
|---|---|---|
| Called on | An object: order.AddLine(...) |
The type: Order.Create(...) |
Has this |
Yes (hidden first argument) | No |
| Can access | Instance and static members | Only static members (or what is passed in) |
Can be virtual / implement interfaces |
Yes | Only via static abstract/static virtual interface members (C# 11) |
| Typical use | Behaviour that uses or changes the object's state | Factories, pure helpers, utilities |
Static methods with no state are easy to test and reason about. Static methods that touch static mutable state (caches, singletons in statics) are the opposite: hidden global state, hard to test, and not thread-safe by default. In ASP.NET Core, shared state belongs in DI singletons, not static fields.
A method's signature for overloading is its name, the number of type parameters, and the types and ref-kinds of its parameters. It does not include:
params, optional defaults,string vs string?).So int Parse(string s) and long Parse(string s) cannot coexist. M(int) and M(ref int) can, but M(ref int) and M(out int) cannot.
The compiler does this at compile time using the static types of the arguments:
params expanded form, optional parameters and named arguments.string over object); non-generic beats generic when otherwise equal; normal form beats params expanded form; candidates that do not need optional defaults filled beat those that do.Pitfalls:
void Log(object o) => Console.WriteLine("object");
void Log(string s) => Console.WriteLine("string");
object value = "hi";
Log(value); // "object" - chosen by STATIC type, not runtime type
Log(null); // "string" - string is more specific than object
Overloading is not polymorphism: runtime dispatch only happens for virtual/interface calls on the receiver. If you need dispatch on argument runtime types, use pattern matching (or dynamic, rarely).
public decimal Gst() => Subtotal * 0.1m; — identical IL to a block body. Use for single-expression methods; use a block when there are guards, logging or multiple steps.
Classic syntax (C# 3):
public static class StringExtensions
{
public static bool IsBlank(this string? value) => string.IsNullOrWhiteSpace(value);
}
if (name.IsBlank()) { ... } // compiled as StringExtensions.IsBlank(name)
this on the first parameter.using).null receivers (they are just static methods), which is why IsBlank above works on null.C# 14 extension members add extension blocks that support extension properties, static extension members and operators, not just methods:
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<T>(IEnumerable<T>)
{
public static IEnumerable<T> Empty => []; // static extension member
}
}
if (orders.IsEmpty) { ... }
Existing this-parameter extension methods remain valid and binary compatible; the new syntax compiles to the same kind of static methods plus metadata.
A team added public static class StringExtensions { public static string ToSlug(this string s) ... } in a shared package, and a different package added another ToSlug extension with slightly different rules. Depending on which using was in scope, URLs were generated differently, and two services produced different slugs for the same product, breaking links. We resolved it by owning one implementation in a single namespace, adding analyser rules for banned APIs (BannedSymbols.txt with Microsoft.CodeAnalysis.BannedApiAnalyzers), and preferring explicit static helpers for business rules. Extensions are great for fluent, discoverable APIs (services.AddOrderingModule()), but business logic hidden behind them is hard to find and easy to duplicate.
Checks solid knowledge of C# method rules and API design sense.
thisIn IL, an instance method has an implicit first argument ldarg.0 = this. Calls use call or callvirt:
callvirt for almost all instance calls on reference types — even non-virtual ones — because callvirt performs a null check on the receiver, giving you NullReferenceException at the call site rather than inside the method.virtual methods, callvirt dispatches through the method table (vtable). For interface methods, the runtime uses virtual stub dispatch with per-call-site caches.call: no receiver, no null check, no dispatch — which makes them easy to inline.For structs, this is a managed reference (ref T) to the struct, which is why a mutating struct method changes the variable it is called on (and why calling it on a copy changes the copy).
Better conversion target rules (simplified):
T1 to T2 but not back, T1 is better (string is better than object; int is better than long).int over uint).Tie-breakers when conversions are equal:
M(string) over M<T>(T)).M<T>(List<T>) over M<T>(T)).params expanded form.OverloadResolutionPriorityAttribute, letting library authors steer callers toward a new overload (e.g. a span overload) without breaking existing ones.ReadOnlySpan<T> overloads with first-class span conversions in more cases.The base/derived trap: if a derived class declares an applicable method, the compiler does not even consider base-class overloads that might be a better match:
class Base { public void Write(int x) => Console.WriteLine("Base.int"); }
class Derived : Base { public void Write(double x) => Console.WriteLine("Derived.double"); }
new Derived().Write(5); // "Derived.double" - int converts to double
| Concept | Same name, different... | Resolved | Keyword |
|---|---|---|---|
| Overloading | Parameter list | Compile time | none |
| Overriding | Same signature, derived class | Run time (vtable) | virtual / override |
| Hiding | Same signature, derived class | Compile time (static type) | new |
Hiding with new is almost always a smell — which method runs depends on the variable's declared type.
name.IsBlank();
// compiles to
StringExtensions.IsBlank(name);
The method is marked with [Extension] in metadata. Lookup order:
using directives, from the innermost scope outwards, stopping at the first scope that has an applicable candidate.Consequences:
IEnumerable<T>) are how LINQ adds dozens of operators without changing the interface.The extension(Receiver r) { ... } block declares members for a receiver type. The compiler emits:
orders.IsEmpty), static extension members (IEnumerable<T>.Empty), and user-defined operators on types you do not own.The disambiguation syntax still exists: you can call EnumerableExtensions.get_IsEmpty(orders)-style members via the containing static class when two extensions clash. Extension properties are still computed on every access — they cannot add state to the target type.
Static methods can now take part in polymorphism through generics:
public interface IParsable<TSelf> where TSelf : IParsable<TSelf>
{
static abstract TSelf Parse(string s, IFormatProvider? provider);
}
static T ParseAll<T>(string s) where T : IParsable<T> => T.Parse(s, CultureInfo.InvariantCulture);
This is the basis of .NET 7+ generic math (INumber<T>). The call is resolved per closed generic type, with no virtual dispatch at run time for value types.
using-free design or by adding the overload to the derived class.IEnumerable<T> and look at the generated metadata in ILSpy.Log(null) with Log(object) and Log(string), then add Log(Uri) and read CS0121.public sealed class Order
{
private readonly List<OrderLine> _lines = [];
// Static factory: no 'this', validates input
public static Order Create(int customerId)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(customerId);
return new Order { CustomerId = customerId };
}
public int CustomerId { get; private init; }
// Overloads: same intent, different inputs
public void AddLine(OrderLine line) => _lines.Add(line);
public void AddLine(string sku, int quantity, decimal price) =>
AddLine(new OrderLine(sku, quantity, price));
// Expression-bodied instance method
public decimal Total() => _lines.Sum(l => l.Quantity * l.Price);
}
public sealed record OrderLine(string Sku, int Quantity, decimal Price);
// Classic extension method
public static class OrderExtensions
{
public static bool IsLarge(this Order order) => order.Total() > 1_000m;
}
// C# 14 extension members: methods, properties and static members
public static class StringExtensions
{
extension(string? s)
{
public bool IsBlank => string.IsNullOrWhiteSpace(s); // extension property
public string Truncate(int max) =>
s is null ? "" : s.Length <= max ? s : s[..max];
}
extension(string)
{
public static string JoinLines(IEnumerable<string> lines) => // static extension
string.Join(Environment.NewLine, lines);
}
}
// Usage
var order = Order.Create(customerId: 7);
order.AddLine("SKU-1", 2, 19.95m);
bool large = order.IsLarge();
bool blank = " ".IsBlank;
string text = string.JoinLines(["a", "b"]);
What happens: Handle(object o) and Handle(Order o) overloads, called with a variable typed object that contains an Order.
Why it is wrong: overloads are chosen at compile time from the static type, so Handle(object) runs.
Do instead: use virtual methods, a switch on type patterns, or a dictionary of handlers.
What happens: Send(string to, int retries) and Send(string to, long timeoutMs).
Why it is wrong: callers passing an int literal get the first; changing a literal to a variable of type long silently switches overloads.
Do instead: give methods different names (SendWithRetries) or use an options record.
What happens: Get(int id) plus Get(int id, bool includeLines = false).
Why it is wrong: confusing resolution rules (the one without defaults wins), and adding more optional parameters later becomes ambiguous.
Do instead: choose one approach per method group; prefer overloads for public APIs because they are binary-compatible to extend.
What happens: static Dictionary<string, Rate> _cache populated by static helper methods in a web app.
Why it is wrong: not thread-safe, impossible to reset in tests, and shared across all tenants/requests.
Do instead: move state into a DI-registered singleton using concurrent or immutable collections, or IMemoryCache.
What happens: services become static classes to avoid DI. Why it is wrong: the call overhead difference is negligible; you lose testability (no substitution), configuration and lifetime management. Do instead: use static for pure, stateless helpers; use DI for anything with dependencies or state.
What happens: order.CalculateDiscount() implemented as an extension in a utilities namespace.
Why it is wrong: core rules become hard to find, cannot access private state, and multiple versions can appear in different namespaces.
Do instead: put domain behaviour on the domain type; keep extensions for fluent configuration (services.AddX()), adapters and convenience over types you do not own.
null handlingWhat happens: customer.GetDisplayName() works when customer is null because the extension checks for null.
Why it is wrong: it looks like an instance call, so readers assume customer is non-null; it masks bugs.
Do instead: keep null-accepting extensions rare and obvious (IsNullOrBlank), and annotate parameters with nullability.
What happens: two libraries define ToSlug or ToJson extensions for the same type.
Why it is wrong: compile errors at best; at worst, the closer namespace silently wins and behaviour differs between projects.
Do instead: own extensions in one well-named namespace, avoid generic names on common types, and use banned-API analysers.
new to hide base methodsWhat happens: a derived class declares public new void Save().
Why it is wrong: which method runs depends on the variable's declared type; calls through the base type skip the new logic.
Do instead: make the base method virtual and override it, or rename the derived method.
What happens: a long chain of calls and ternaries squeezed into one => line.
Why it is wrong: hard to read and debug; breakpoints cover the whole expression.
Do instead: use a block body once there is more than one obvious step.
Seniors think about methods as API surface: how they evolve, how callers bind to them, and how clear they are.
| Decision | Senior default | Reason |
|---|---|---|
| Static or instance | static for pure helpers and factories | no hidden state |
| Overloads vs optional params | overloads in public libraries | versioning safety |
| Many parameters | options record | readability, evolvability |
| Extension vs instance | instance on owned types | encapsulation |
| Extension properties (C# 14) | for read-only computed helpers | discoverability |
| Expression-bodied | single, clear expression only | readability |
They also know binding is mostly compile time: overloads and extension methods are chosen by static types, only virtual dispatch happens at run time. That explains many "wrong method called" surprises.
C# passes everything by value by default. For a value type that copies the data; for a reference type it copies the reference, so the method can change the object but reassigning the parameter does not affect the caller. ref passes the caller's variable itself; out is a ref the callee must assign, used by TryParse; in and ref readonly pass a read-only reference, useful for large structs. params takes a variable number of arguments, and since C# 13 it can be a ReadOnlySpan<T> to avoid allocating. Optional parameters' defaults are compiled into the caller, which matters when versioning public APIs.
The return type is the contract, so I would make it honest. Not-found becomes Task<Order?> with nullable reference types on, so callers are forced to handle null, or a Result<Order> if there are several expected failure reasons. The two out parameters must go because async methods cannot have out parameters; their information moves into the result object. The internal List<Order> is replaced with IReadOnlyList<Order> (or a copy) so callers cannot mutate our state. C# gives us void, values, tuples, records, out/Try pattern, ref returns, IEnumerable<T>/IAsyncEnumerable<T>, and Task/ValueTask.
A local function is a named method declared inside another method. Unlike a lambda it can be recursive, generic, an iterator or async, and called before it is declared. The big practical difference is allocation: if a local function captures variables and is only called directly, the compiler passes them in a struct by reference, so there is no heap allocation, while a capturing lambda always allocates a closure object and a delegate. A static local function cannot capture anything, which guarantees no hidden allocations. I use them for helpers used in one method and for the eager-validation pattern in iterators and async methods.
How to tell it (STAR)
**Situation:** At [company] our email library changed a default so that open tracking was on, but after the release only some services behaved differently. **Task:** I was asked why the change was inconsistent. **Action:** I decompiled one of the services and found the old default baked into its call. That is how C# optional parameters work: the value is compiled into the caller. I redesigned the public API with overloads and an options record so defaults live inside the library, rebuilt the consumers, and added an API review rule plus package validation. **Result:** All services behaved the same after one coordinated rebuild, and later default changes rolled out with a package update alone.
Last updated 30 Oct 2026.