📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is a static class in C#? Explain 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 use of static classes hurts testability.

Static classes in C#: rules, static constructors, extension methods and C# 14 extension blocks, static versus singleton, shared state risks and testability.

Junior Theory Beginner csharp extension-members extension-methods oop singleton static-class testability

🎯 Key points

  • A static class cannot be instantiated or inherited, holds only static members and is abstract sealed in IL
  • Use static classes for pure, stateless helpers and constants
  • Extension methods must be in a top-level, non-generic static class
  • C# 14 extension blocks add extension properties and static extension members
  • Static constructors run once per type, are thread-safe, and a failure causes TypeInitializationException forever
  • Static classes cannot implement interfaces, so they cannot be injected or faked
  • Static mutable state is global and shared by threads and tests; prefer a DI singleton
  • Replace hidden static dependencies such as `DateTime.UtcNow` with `TimeProvider`

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. What a static class can contain

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);
}
  • Only static members: methods, properties, fields, events, constants, nested types, a static constructor.
  • No instance constructors, no instance members, can't be used as a type argument, variable type or base class.
  • The compiler marks it abstract sealed in IL.

2. Static class vs singleton

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.

3. Extension methods and C# 14 extension members

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.

4. Static state and thread safety

Static fields are shared by every thread and every request for the life of the process:

  • Mutable static state (static List<T>, static Dictionary<,>) is a race condition waiting to happen and can leak data between users/tenants.
  • Static references are GC roots — anything stored there is never collected (classic static cache memory leak).
  • Static initialisation is thread-safe (the runtime runs the static constructor once under a lock), but subsequent reads/writes are not.
  • If you need static state: make it immutable (static readonly FrozenDictionary), or thread-safe (ConcurrentDictionary, Interlocked), and bounded.

5. Why heavy static use hurts testability

  • Can't substitute: code calling PricingRules.Calculate(...) or DateTime.UtcNow is hard-wired; tests can't control behaviour.
  • Hidden dependencies: a static method that reads configuration, the database or the clock hides those dependencies from the constructor.
  • Shared state between tests: static state set by one test leaks into others, causing order-dependent flaky tests (especially when xUnit runs test classes in parallel).
  • Configuration: statics initialised from config can't vary per test or per tenant.

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.

6. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. IL representation

.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).

2. Static fields in the runtime

  • Primitive statics live in per-type static storage; reference-type statics hold references to heap objects (on the frozen/pinned or normal heap depending on version and type).
  • After tier-1 compilation, the JIT treats static readonly fields as constants (because the class is already initialised), folding values and devirtualising calls on static readonly object fields.
  • Per-generic-instantiation statics: 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.

3. Static constructor failure

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.

4. Ambient context pattern

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.

5. Statics and parallel tests

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.

6. When static classes are the right tool

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

Try it yourself

  1. Decompile a static class and confirm abstract sealed.
  2. Write a static generic class TypeCache<T> and observe separate statics per T.
  3. Create two xUnit tests that mutate the same static and run them in parallel until one fails.
  4. Replace a static helper that uses DateTime.UtcNow with an injected TimeProvider.

💻 Code

// 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>();

⚠ Common mistakes

1. Mutable static state in web apps

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.

2. Static classes for business logic with dependencies

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.

3. Classic singletons with Instance properties

What 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.

4. Static constructors with I/O

What happens: static constructors load configuration or open connections. Why it is wrong: failures permanently break the type. Do instead: keep static initialisation trivial.

5. Using DateTime.Now and other ambient statics in logic

What 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.

6. Flaky tests from static state

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.

7. Extension methods that hide heavy work

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.

8. Huge "Utils" static classes

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions a static constructor failing Why: Checks TypeInitializationException and inner exceptions
    How to answer

    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.

  2. When: Candidate prefers a DI singleton Why: Probes dependency injection and DIP
    How to answer

    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.

🛠 Real-world problems: how you handle them

All requests failing after a static constructor threw at start-up
Situation
A pricing API used a `static class PricingRules` whose static field initialiser loaded rules from SQL Server.
Signals
After a [two-minute] database failover early one morning, every pricing request returned 500 with `TypeInitializationException`, even after the database recovered.
Investigation
The inner exception was a `SqlException` from the first access during the failover. The type initialiser does not re-run, so the type stayed broken until the process restarted.
Resolution
Restarted the pods to recover. Then moved the loading into a `PricingRuleService` singleton that loads lazily with Polly retries and exposes a health check; kept only pure calculation helpers static.
Prevention
Code review rule and an architecture test (NetArchTest) that fails if a static constructor or static field initialiser in the solution calls a database or HTTP client.

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.

↩ Usually asked after

More in C# OOP Features

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me