skip to content

Mutable class-level state is a global variable wearing a class name. Compare how different languages' initialization and isolation models change how dangerous that is, and what each of them actually gives you for testing code that depends on it.

level: seniorimportance: should knowfreq 48%

answer

  1. global with a class name in front
  2. one static per closed generic type in C#, one per erased class in Java
  3. Go: init() once per process, no reset
  4. Rust: static mut needs unsafe, use OnceLock/Mutex
  5. Elixir: give it an owner process, not a keyword

basics

~20 s

Class-level mutable state has process-wide lifetime and no owner. Languages diverge sharply: Rust forces a OnceLock or Mutex, C# gives each closed generic type its own copy while Java's erasure gives one, Python lets a test rebind the attribute, Go offers no reset hook, Elixir has no such state at all.

solid answer

~60 s

The keyword is not the hazard; unscoped lifetime and an unnameable owner are. - **Java**: one static field per class *per classloader*, initialised lazily on first active use under a JVM lock. The only reset is a fresh classloader or a forked JVM — isolation exists but costs a process. - **C#**: statics live per *closed constructed* generic type, so `Cache<int>` and `Cache<string>` hold separate copies, where Java's erasure gives `Cache` exactly one. The same source shape produces a different number of globals. - **Go**: package vars plus `init()` run once per process in dependency order with no reload hook — precisely why idiomatic Go demotes them to struct fields and injects them. - **Rust**: `static mut` needs `unsafe`; the sanctioned forms are `OnceLock`, `LazyLock` or a `Mutex`, so the synchronisation is written down. - **Python**: module and class attributes are plain names, so `unittest.mock.patch` swaps them per test; PEP 684 (3.12) adds per-interpreter copies. - **Elixir**: module attributes are compile-time constants; the mutable equivalent is a named process or ETS table — it has an owner, a supervisor and a lifetime.

code

csharp · 5 lines
csharp
class Counter<T> { public static int N; }

Counter<int>.N++;
Console.WriteLine(Counter<int>.N);     // 1
Console.WriteLine(Counter<string>.N);  // 0  -- separate storage

go deeper

for a junior

Know that class-level state is shared by every user of the type and lives for the whole process, so writes from one place are visible everywhere.

for a middle

Be able to describe initialization timing and at least one language that removes the hazard structurally (Rust's OnceLock, Elixir's processes) rather than by convention.

for a senior

Diagnose order-dependent test failures back to class-level state, and know what reset mechanism your runtime actually provides — classloader, interpreter, process, or nothing.

for a principal

Argue the design rule from mechanism: state needs a nameable owner and a scoped lifetime; pick the language idiom that supplies both, and treat process-wide singletons as suspect once the service runs replicated.

## Why this is a design question, not a style rule "Avoid mutable statics" is repeated everywhere and understood almost nowhere, because the reason is usually given as "globals are bad". The precise problems are four, and each language's initialization model changes which of them bites. **Lifetime you cannot scope.** Instance state dies with its object. Class-level state lives as long as the loaded type, which in most runtimes means as long as the process. Nothing in the type system tells you when it starts or ends. **An owner you cannot name.** With instance state you can ask who holds the reference. With class-level state anyone who can name the type can mutate it, so there is no place to put an invariant. **Initialization order.** Class-level state is initialised by a runtime-chosen event, and cycles between initialisers produce partially built values rather than errors. **Test coupling.** One test's write becomes the next test's precondition, and the failure surfaces as order dependence. ## How the languages diverge **Java.** A static field exists once per class *per classloader*, and the initialiser runs lazily at first active use, under a JVM-internal lock — which is why a deadlock is possible if two initialisers reference each other from different threads. Because the field is keyed by classloader, real isolation is available: a test harness can load the class in a fresh classloader, and build tools fork JVMs per test class for exactly this reason. It is isolation with a process-sized price tag. **C#.** The .NET runtime instantiates a generic type once per closed construction, and static fields go with it. `Counter<int>` and `Counter<string>` therefore have *different* counters. On the JVM, erasure means `Counter` has exactly one field shared by every parameterisation. Identical-looking source, different global cardinality — a divergence worth knowing before porting a cache or a registry between the two. **Go.** Package-level variables plus `init()` functions run once per process, in dependency order across packages, with no way to re-run or reset them. This is not a gap the standard library papers over; it is the reason the dominant Go idiom is to hang the state on a struct and pass the struct, which turns process lifetime into a value you construct and discard. **Rust.** The language refuses to let you have this quietly. `static mut` access requires `unsafe`, so shared mutable class-level state has to be spelled as `OnceLock`, `LazyLock`, or a `Mutex`/`RwLock` in a `static`. The synchronisation strategy therefore appears in the type, and the compiler will not let two threads race on it. **Python.** Module-level and class-level attributes are just names in a dictionary, so a test can rebind them (`unittest.mock.patch`) and restore them afterwards. That gives Python cheap per-test isolation that the JVM lacks, in exchange for no compile-time protection at all: anything can rebind anything. Per-interpreter GIL work (PEP 684, Python 3.12) additionally lets a process hold several independent copies of module state. **Erlang and Elixir.** There is no mutable module-level state to have. Module attributes are evaluated at compile time and are constants. When you need long-lived mutable state you start a named process or an ETS table — which means the state has an owning process, a supervision strategy, and a defined restart behaviour. That is the design lesson the other languages arrive at the long way round: the fix for a global is not to hide it, but to give it an owner and a lifetime. ## What to do about it The reliable move is to convert class-level state into instance state owned by something the caller constructs — the Go struct, the Elixir process, the injected service. Where you keep it, keep it immutable, or make it explicitly synchronised in a language that lets you say so. Some class-level state is legitimate: caches keyed only by their inputs, interned constants, metrics counters whose only contract is monotonicity. Those tolerate process lifetime because they carry no invariant a test can violate. One systems note, since it recurs: a process-wide singleton silently becomes a per-replica singleton the moment the service runs more than one instance, so any correctness argument that depended on there being exactly one copy has to be re-made at the cluster level. ## Testing, concretely Ask what reset mechanism the language actually gives you. Python: rebind the attribute. Java: fresh classloader or forked JVM. Go: none — refactor. Rust: construct the `OnceLock` per test or take it as a parameter. Elixir: start a fresh process per test, which the test framework already does. Answering "how do I reset this" is usually the fastest way to discover the state should never have been class-level.

  • You port a registry keyed by a type parameter from C# to the JVM. What breaks?
    In C# each closed construction has its own statics, so the registry was implicitly partitioned per type argument. On the JVM erasure collapses them: one field serves every parameterisation, so all type arguments now share the registry and entries collide. The fix is to make the partition explicit — a map keyed by the type token — rather than relying on the runtime to give you one copy per instantiation.
  • When is class-level mutable state actually acceptable?
    When it carries no invariant that a caller can violate and no lifetime a test needs to control: interned constants, a monotonic metrics counter, or a pure cache keyed only by its inputs where a stale or empty cache is still correct. The test is whether an arbitrary write from an unrelated module could make any other module wrong. If yes, it needs an owner.

saying these in an interview costs you the question

  • Claiming a mutable static is safe because 'only one class touches it' — the type name is the access control, and every module can write it.
  • Assuming a generic type's static field behaves the same on .NET and the JVM.
  • Believing a lazily initialised static is automatically thread-safe in every runtime rather than only where the runtime guards the initialiser.
  • Using a singleton holder to hide process-wide mutable state and calling that dependency injection.
  • Answering the testability problem with more mocking frameworks instead of moving the state onto an owned object.

context