Static classes in C#: rules, static constructors, extension methods and C# 14 extension blocks, static versus singleton, shared state risks and testability.
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.
A static class is a class that can't be instantiated or inherited and may contain only static members. It's a container for stateless helpers, constants, extension methods and factories. I'd cover what it can contain, how it differs from a singleton, where extension methods and C# 14 extension members live, static state and thread safety, and why heavy static use hurts testability.
public static class GstCalculator
{
public const decimal Rate = 0.10m; // constant
private static readonly FrozenSet<string> Exempt = ...; // static readonly data
static GstCalculator() { } // optional static constructor
public static decimal Gst(decimal amount) => Math.Round(amount * Rate, 2, MidpointRounding.AwayFromZero);
public static bool IsExempt(string category) => Exempt.Contains(category);
}
abstract sealed in IL.| Static class | Singleton (DI singleton) | |
|---|---|---|
| Instances | None | Exactly one object |
| Can implement interfaces | No | Yes |
| Can be passed as a parameter / injected | No | Yes |
| Polymorphism / substitution in tests | No | Yes (mock the interface) |
| Dependencies | Must be static too or passed in | Constructor-injected |
| Lifetime control | Process lifetime | Managed by the DI container (disposal at shutdown) |
| Initialisation | Static constructor / field initialisers | Container-controlled, can be lazy |
In modern .NET, a "singleton" should almost always be a DI-registered singleton (services.AddSingleton<IRateCache, RateCache>()) rather than the classic Instance static property — you get the single instance without global access, and tests can substitute it.
Extension methods must be declared in a non-generic, non-nested static class:
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddOrdering(this IServiceCollection services, IConfiguration config) { ... }
}
C# 14's extension blocks also live inside static classes, and add extension properties, static extension members and operators:
public static class StringExtensions
{
extension(string? value)
{
public bool IsBlank => string.IsNullOrWhiteSpace(value);
}
}
The static class is just the container; callers use the members as if they were on the target type.
Static fields are shared by every thread and every request for the life of the process:
static List<T>, static Dictionary<,>) is a race condition waiting to happen and can leak data between users/tenants.static readonly FrozenDictionary), or thread-safe (ConcurrentDictionary, Interlocked), and bounded.PricingRules.Calculate(...) or DateTime.UtcNow is hard-wired; tests can't control behaviour.Pure, deterministic functions (maths, formatting, mapping without I/O) are perfect as statics — they're easy to test directly. The problem is statics with I/O, time, configuration or mutable state.
A multi-tenant API cached tenant settings in static Dictionary<string, TenantSettings> Cache inside a static SettingsHelper. Under concurrent load it threw intermittent InvalidOperationExceptions (concurrent modification) and once returned another tenant's settings because of a race in a check-then-add. Tests were flaky because the cache persisted between tests. We replaced it with a DI singleton ITenantSettingsProvider backed by IMemoryCache, injected where needed; tests used a fake provider and the races disappeared.
Checks basic rules plus judgement about global state.
Green flags: knows the rules and IL shape; uses static for pure helpers and extensions; knows C# 14 extension members; can contrast static with a DI singleton; knows static constructor failure mode; mentions TimeProvider for testability.
Red flags: static classes holding caches and connections everywhere; "static is faster so use it everywhere"; cannot explain why static code is hard to test.
.class public abstract auto ansi sealed beforefieldinit GstCalculator extends [System.Runtime]System.Object
abstract prevents instantiation; sealed prevents inheritance. Without an explicit static constructor, beforefieldinit lets the runtime run static field initialisers eagerly and lets the JIT skip initialisation checks; with an explicit static constructor, initialisation is precisely on first access (slightly more overhead on some code paths).
static readonly fields as constants (because the class is already initialised), folding values and devirtualising calls on static readonly object fields.static class Cache<T> has separate statics for each T — a common trick for type-keyed caches without dictionaries.[ThreadStatic] statics are per thread (don't flow across await); AsyncLocal<T> flows with the async context.If a static class's static constructor throws, every later access throws TypeInitializationException for the process's lifetime. Statics that read configuration or connect to services can turn a transient issue into a permanently broken process. Keep static initialisation pure.
Some libraries expose ambient statics (HttpContext.Current in classic ASP.NET, Transaction.Current, Activity.Current). They're convenient but implicit: behaviour depends on hidden context, and they're tricky with async. ASP.NET Core removed HttpContext.Current in favour of IHttpContextAccessor (itself backed by AsyncLocal) and explicit injection.
xUnit runs test collections in parallel by default. Two tests mutating the same static field race. Options: avoid mutable statics; put such tests in the same collection ([Collection("StaticState")]) to serialise them; reset state in a fixture. The real fix is designing without mutable statics.
| Good uses | Poor uses |
|---|---|
| Pure functions (math, parsing, formatting) | Business rules that depend on configuration |
| Extension methods/members | Data access, HTTP calls |
Constants and lookup tables (static readonly FrozenDictionary) |
Mutable caches without bounds or thread safety |
| Guard/throw helpers | Anything needing different behaviour per tenant/test |
Source-generated helpers ([LoggerMessage] partial static methods) |
Global service locators |
abstract sealed.TypeCache<T> and observe separate statics per T.DateTime.UtcNow with an injected TimeProvider.// Good: stateless helpers
public static class Guard
{
public static string NotBlank(string? value,
[CallerArgumentExpression(nameof(value))] string? name = null) =>
string.IsNullOrWhiteSpace(value) ? throw new ArgumentException("Required", name) : value;
}
// Classic extension method
public static class StringExtensions
{
public static string Truncate(this string s, int max) =>
s.Length <= max ? s : s[..max];
}
// C# 14 extension block: properties and static members
public static class EnumerableExtensions
{
extension<T>(IEnumerable<T> source)
{
public bool IsEmpty => !source.Any();
public IEnumerable<T> WhereNotNull() => source.Where(x => x is not null);
}
}
// usage: if (orders.IsEmpty) ...
// Bad: static mutable state and hidden I/O
public static class PricingRules
{
private static readonly List<Rule> _rules = LoadFromDatabase(); // runs in the type initialiser
public static decimal Apply(Order o) => _rules.Aggregate(o.Total, (t, r) => r.Apply(t));
}
// Better: injectable singleton with an interface
public interface IPricingRules { decimal Apply(Order o); }
public sealed class PricingRules(IRuleStore store, TimeProvider clock) : IPricingRules
{
public decimal Apply(Order o) => store.ActiveRules(clock.GetUtcNow())
.Aggregate(o.Total, (t, r) => r.Apply(t));
}
// builder.Services.AddSingleton<IPricingRules, PricingRules>();
What happens: static lists or dictionaries used as caches or registries.
Why it is wrong: race conditions, cross-tenant leaks and unbounded memory.
Do instead: use DI singletons with thread-safe, bounded structures or IMemoryCache.
What happens: static PricingService.Calculate reads config and calls the database.
Why it is wrong: untestable and hides dependencies.
Do instead: use injectable services; keep statics for pure functions.
Instance propertiesWhat happens: public static Foo Instance { get; } = new(); used across the codebase.
Why it is wrong: global access, hidden dependencies, hard to test.
Do instead: register a DI singleton and inject it.
What happens: static constructors load configuration or open connections. Why it is wrong: failures permanently break the type. Do instead: keep static initialisation trivial.
DateTime.Now and other ambient statics in logicWhat happens: business rules call static time and environment APIs directly.
Why it is wrong: tests can't control them; time-zone bugs slip through.
Do instead: inject TimeProvider and options.
What happens: tests pass alone and fail when run together. Why it is wrong: shared static state leaks between tests running in parallel. Do instead: remove mutable statics or isolate those tests.
What happens: customer.LoadOrders() extension doing database calls.
Why it is wrong: looks like a cheap member access; hides I/O and dependencies.
Do instead: keep extensions lightweight; put I/O in services.
What happens: one Utils class with hundreds of unrelated helpers.
Why it is wrong: low cohesion, hard to discover and maintain.
Do instead: group helpers by concept (MoneyFormatting, AbnValidator) or turn them into extensions on the relevant types.
Seniors keep static classes pure and treat static mutable state as a production risk.
| Code | Senior choice |
|---|---|
| Pure function, no dependencies | Static method |
| Extension behaviour, including properties | Static class with C# 14 extension blocks |
| Configuration, caches, connections | DI singleton behind an interface |
| Time, randomness, file system | Injected TimeProvider, Random via a seam, storage abstraction |
| Process-wide read-only lookup | static readonly frozen collection built at start-up |
They also mention that static fields on generic types are per closed type, which is a neat trick for type-keyed caches (static class Cache<T>) but surprising if you expect one shared value.
InnerException links an exception to the one that caused it, so a high-level error ("order import failed") keeps the low-level cause ("connection timeout"). GetBaseException() returns the innermost cause, and ToString() prints the whole chain. AggregateException holds several inner exceptions in InnerExceptions; you get it from Task.Wait(), .Result, Parallel.For and Task.Exception. Flatten() merges nested aggregates and Handle(predicate) marks some as handled and rethrows the rest. await unwraps it and throws only the first inner exception, so after await Task.WhenAll(...) check task.Exception.InnerExceptions to see all failures. Reflection wraps errors in TargetInvocationException, and a failing static constructor gives TypeInitializationException, which makes the type unusable for the rest of the process.
A dependency is the weakest relationship: class A uses class B, so a change to B may affect A, but A does not keep B as part of its state. It shows up as a method parameter, a local variable, a return type, a static call or a new B() inside a method. Association is stronger: A stores a reference to B. Direction matters: high-level policy (business rules) should not depend on low-level details (SQL, HTTP). The Dependency Inversion Principle says both should depend on abstractions owned by the high-level side. Dependency injection supplies those abstractions from outside (usually through constructors), so classes do not create or find their own dependencies, which makes them testable and replaceable.
How to tell it (STAR)
**Situation:** after a short database failover, our pricing API failed every request with `TypeInitializationException`. **Task:** restore service and make it survive the next failover. **Action:** I restarted the pods, then moved rule loading out of the static class into a DI singleton with retries and a health check, leaving only pure helpers static. I added an architecture test to stop I/O in static initialisers. **Result:** the next [three] failovers caused only a few retried requests and no outage.
Last updated 30 Oct 2026.