C# & .NET (C# 14, .NET 10 LTS) Module 6 – Classes, objects and encapsulation
Static members, static classes and shared state
Decide when a C# member should be static, see exactly when a static constructor runs, and stop shared static state from tying your tests to their order.
What you will learn
- Decide whether a member belongs to the type or to each object
- Predict when static field initializers and a static constructor run
- Replace a static counter with an object passed in, so that tests pass in any order
- Diagnose a type whose static constructor failed, and design initialisation that can recover
- Recognise what shared static state costs under threads, and protect a shared counter
Before you start
What you’ll learn · Before you start
What you will learn
- Decide whether a member belongs to the type or to each object
- Predict when static field initializers and a static constructor run
- Replace a static counter with an object passed in, so that tests pass in any order
- Diagnose a type whose static constructor failed, and design initialisation that can recover
- Recognise what shared static state costs under threads, and protect a shared counter
Before you start
On this page
A checkout that numbers its orders through a static counter passed its first test with ORD-1001 and failed the
second, whose first order came out as ORD-1002. Run the two tests the other way round and the other one fails. A
static field belongs to the whole program, so every test, every thread and every request shares it.
You will decide which members should be static, see exactly when a static constructor runs, and replace shared static state with an object that each test or service is given.
A static member belongs to the type, not to any object
tickets.cs creates three tickets. Each has its own passenger and number, but the count of tickets issued is a
static member: A field, property, method or event declared static. It belongs to the type rather than to any object, exists once in the program, and is reached through the type's name, as in Ticket.Issued.: it belongs to the type Ticket itself, exists once however many
objects there are, and is reached through the type’s name, Ticket.Issued:
// A static field belongs to the type: one value that every Ticket shares. An instance field belongs to each ticket.
var first = new Ticket("Asha Rao");
var second = new Ticket("Ravi Iyer");
var third = new Ticket("Meena Das");
Console.WriteLine($"{first.Passenger} has ticket {first.Number}");
Console.WriteLine($"{third.Passenger} has ticket {third.Number}");
Console.WriteLine($"tickets issued: {Ticket.Issued}");
Console.WriteLine($"fare for 3 adults and 2 children: {Fares.Total(adults: 3, children: 2)} paise");
class Ticket
{
// One counter for the whole program, shared by every Ticket object.
private static int s_issued;
public Ticket(string passenger)
{
s_issued++;
Passenger = passenger;
Number = 1000 + s_issued;
}
// Instance members: each ticket has its own.
public string Passenger { get; }
public int Number { get; }
// A static property: read through the type, never through a ticket.
public static int Issued => s_issued;
}
// A static class holds only static members and can never be created with new.
static class Fares
{
public const long AdultPaise = 45_000;
public static long Total(int adults, int children) => (adults * AdultPaise) + (children * AdultPaise / 2);
} Output
Asha Rao has ticket 1001 Meena Das has ticket 1003 tickets issued: 3 fare for 3 adults and 2 children: 180000 paise
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run tickets.cs
Step through the three constructors and watch the one field they all change:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Before any ticket. The static field s_issued belongs to the type Ticket, not to any ticket. It exists once in the program, and it starts at 0 before the first ticket is created.
- The first ticket. new Ticket("Asha Rao") runs the constructor, which adds 1 to the shared s_issued and gives this ticket its own Number, 1000 + 1 = 1001.
- Two more. The next two tickets add to the same field, so they get 1002 and 1003. Each object keeps its own Passenger and Number; only s_issued is shared.
- Read through the type. Ticket.Issued is a static property, read through the type name. It returned 3: one count for the whole program, whichever ticket you would ask.
- A static class. Fares.Total(3, 2) needs no object, because Fares is a static class: it holds only static members and can never be created with new. It returned 180000 paise.
A static method has no object, so it cannot touch instance fields unless an object is passed to it. That is the test
for making a member static: Fares.Total needs only its parameters and a constant, while Number describes one
ticket. Fares is a static class: A class declared static: it holds only static members, cannot be created with new and cannot be inherited from. Math and Console are static classes.: it holds only static members and is never created
with new. Static classes such as Math and Console group helpers that need no object at all. A const such as
AdultPaise is static by nature; C# allows no static const.
Making a method static because it is shorter to call
A method that reads one object’s data can be static only if that object is passed in. Moving the data into static fields to make the method static turns every object’s state into one shared value.
Checkpoint
Check: Static members and static classes
A static constructor runs once, just before the type is first used
Static fields need starting values too, and some take more than an expression. A
static constructor: A parameterless constructor marked static, such as static Tax() { }. The runtime runs it at most once, just before the type is first used, to initialise the type's static fields., static Tax() { … }, has no parameters and no access
modifier, and nobody calls it: the runtime runs it at most once, before the first instance is created or any static
member is used. first-use.cs numbers each line it prints:
// A static constructor runs once, just before its type is first used. Watch the numbered lines.
Console.WriteLine("1. the program starts");
Console.WriteLine("2. Tax has not been used yet");
Console.WriteLine($"5. Tax.Percent is {Tax.Percent}");
Console.WriteLine($"6. tax on 1000 rupees is {Tax.On(1000m)}, and nothing ran again");
static class Tax
{
public static readonly int Percent = Show("3. the static field initializer runs", 18);
static Tax()
{
Console.WriteLine("4. the static constructor runs");
}
public static decimal On(decimal amount) => amount * Percent / 100;
private static int Show(string line, int value)
{
Console.WriteLine(line);
return value;
}
} Output
1. the program starts 2. Tax has not been used yet 3. the static field initializer runs 4. the static constructor runs 5. Tax.Percent is 18 6. tax on 1000 rupees is 180, and nothing ran again
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run first-use.cs
Lines 1 and 2 printed before any code of Tax ran. Reading Tax.Percent for line 5 triggered the static field
initializer, line 3, and then the static constructor, line 4; line 6 used Tax again and nothing ran:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Not used yet. Lines 1 and 2 print before anything touches Tax, and none of its code has run: a type is initialised when it is first used, not when the program starts.
- The first use. Building line 5 reads Tax.Percent, a static member of Tax. That first use makes the runtime initialise the type before the read can finish.
- Initializers first. The static field initializers run first, in the order they are written: line 3 is Percent = 18.
- Then the constructor. The static constructor runs right after the initializers and before any other use of the type: line 4.
- The value. Only now does the read return 18, and line 5 prints "Tax.Percent is 18".
- Never again. Line 6 calls Tax.On(1000m), which gave 180, and nothing printed before it: the runtime runs a static constructor at most once.
Microsoft’s guide adds two rules: static field initializers run in the order they are written, immediately before the static constructor, as lines 3 and 4 show, and the runtime runs the static constructor inside a lock, so threads that use the type at the same moment still see it run once.
Checkpoint
Check: When static initialisation runs
Shared mutable state ties tests to their order
A static field that changes, such as a counter, is shared state of the riskiest kind: whatever one piece of code
leaves in it, the next one finds. orders.cs runs two tests of a checkout twice, in both orders, first with a
static counter and then with a counter object that each test creates:
// Two tests of a checkout that numbers its orders, run in both orders: first with a static counter, then with a
// counter object that each test creates for itself.
Console.WriteLine("static counter");
Run("first-order test, then two-orders test", FirstOrderStatic, TwoOrdersStatic);
StaticOrderNumbers.ResetForTests();
Run("two-orders test, then first-order test", TwoOrdersStatic, FirstOrderStatic);
Console.WriteLine("counter object passed in");
Run("first-order test, then two-orders test", FirstOrder, TwoOrders);
Run("two-orders test, then first-order test", TwoOrders, FirstOrder);
static void Run(string label, params Func<string>[] tests)
{
Console.WriteLine($" {label}");
foreach (var test in tests)
{
Console.WriteLine($" {test()}");
}
}
static string FirstOrderStatic() => FirstOrderIs1001(new StaticCheckout().PlaceOrder);
static string TwoOrdersStatic() => TwoOrdersAre1001And1002(new StaticCheckout().PlaceOrder);
// Each test creates the counter its checkout uses.
static string FirstOrder() => FirstOrderIs1001(new Checkout(new OrderNumbers(1001)).PlaceOrder);
static string TwoOrders() => TwoOrdersAre1001And1002(new Checkout(new OrderNumbers(1001)).PlaceOrder);
static string FirstOrderIs1001(Func<string> placeOrder)
{
string got = placeOrder();
return got == "ORD-1001" ? "first order is ORD-1001: pass" : $"first order is ORD-1001: FAIL, got {got}";
}
static string TwoOrdersAre1001And1002(Func<string> placeOrder)
{
string got = $"{placeOrder()} and {placeOrder()}";
string name = "two orders are ORD-1001 and ORD-1002";
return got == "ORD-1001 and ORD-1002" ? $"{name}: pass" : $"{name}: FAIL, got {got}";
}
// The static design: one counter for the whole process.
static class StaticOrderNumbers
{
private static int s_next = 1001;
public static int Next() => s_next++;
public static void ResetForTests() => s_next = 1001;
}
class StaticCheckout
{
public string PlaceOrder() => $"ORD-{StaticOrderNumbers.Next()}";
}
// The instance design: whoever creates a checkout decides which counter it uses.
class OrderNumbers
{
private int _next;
public OrderNumbers(int first) => _next = first;
public int Next() => _next++;
}
class Checkout
{
private readonly OrderNumbers _numbers;
public Checkout(OrderNumbers numbers) => _numbers = numbers;
public string PlaceOrder() => $"ORD-{_numbers.Next()}";
} Output
static counter
first-order test, then two-orders test
first order is ORD-1001: pass
two orders are ORD-1001 and ORD-1002: FAIL, got ORD-1002 and ORD-1003
two-orders test, then first-order test
two orders are ORD-1001 and ORD-1002: pass
first order is ORD-1001: FAIL, got ORD-1003
counter object passed in
first-order test, then two-orders test
first order is ORD-1001: pass
two orders are ORD-1001 and ORD-1002: pass
two-orders test, then first-order test
two orders are ORD-1001 and ORD-1002: pass
first order is ORD-1001: pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run orders.cs
With the static counter each test passed only when it ran first, because the first test left the counter where the second one did not expect it:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The first test. The first-order test places one order. The checkout asks the static counter, which hands out 1001 and moves on to 1002.
- It passes. ORD-1001 is what the test expected, so it passes. It leaves nothing behind but the counter, which now says 1002.
- The second test. The two-orders test places two orders. The counter has no idea that a new test started: it hands out 1002 and 1003.
- It fails. The test expected ORD-1001 and ORD-1002 and got ORD-1002 and ORD-1003. Run the two tests the other way round and the other one fails, as orders.cs printed.
ResetForTests was needed just to run the second order, and it cannot help when tests run in parallel, which is
what xUnit.net does by default with tests in different test classes. Microsoft’s unit-testing guidance asks for
isolated, repeatable tests. The counter object meets both: Checkout receives an OrderNumbers through its
constructor, each test creates its own, and all four runs passed. A test can give a checkout a fresh counter, and an
application can give every checkout the same one.
Key idea
Keep changing state in objects and pass them to the code that needs them: a static field is one value for the whole process, shared by every test, thread and request that touches it.
Checkpoint
Check: Shared mutable state and tests
A static constructor that fails, and initialisation that can recover Pro: opens with a Pro, Premium or Ultimate pass
When a static constructor throws, the type it belongs to is broken for the rest of the process. The runs below reproduce that with a configuration value that has a typing mistake, then compare two ways of initialising lazily.
In this part:
- TypeInitializationException for good: a bad setting, reproduced
- Lazy<T> caches the failure too, unless it is PublicationOnly
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Three surprises of static state: field order, generic copies and readonly lists Pro: opens with a Pro, Premium or Ultimate pass
Static fields follow rules that instance fields never meet: one order of initialization for the whole type, one copy
for each closed generic type, and a readonly that protects less than it seems. One run shows all three.
In this part:
- Static field initializers run top to bottom: a price with tax of 0.00, measured
- Each closed generic type has its own statics, and readonly does not freeze a list
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Shared statics under threads, measured, and the designs that test well Pro: opens with a Pro, Premium or Ultimate pass
Tests that share a static field interfere one after another; threads that share one interfere at the same moment. The run below takes 800,000 numbers from one static counter on eight threads, then sends eight threads at a type that has not been initialised yet.
In this part:
- Eight threads, one static counter: lost updates counted
- Where static state is safe, and where to put the rest
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: an id generator that each service is given
The old IdGenerator of idgen.cs was a static class with one counter that OrderService and InvoiceService
both called. Write it as a class whose first number is passed to its constructor, and give each service the
generator it should use. The classes you start from only throw, so all 6 checks fail:
// Practice: the old IdGenerator was a static class with one static counter that OrderService and InvoiceService both
// called, so tests depended on each other. Write it as a class that each service is given, then run this file until
// all six checks pass. Numbers start at the value passed in, which must be 1 or more.
(string Check, Func<string> Run, string Expected)[] checks =
[
("the first id", () => new IdGenerator(start: 1).Next().ToString(), "1"),
("three ids in a row", () => Three(new IdGenerator(start: 41)), "41,42,43"),
("two generators are independent", TwoGenerators, "1,2,1"),
("two services share one generator", SharedGenerator, "ORD-1,INV-2,ORD-3"),
("each service with its own", OwnGenerators, "ORD-1,INV-1"),
("a start of 0", () => new IdGenerator(start: 0).Next().ToString(), "throws ArgumentOutOfRangeException"),
];
int passed = 0;
foreach (var (check, run, expected) in checks)
{
string got;
try
{
got = run();
}
catch (Exception e)
{
got = $"throws {e.GetType().Name}";
}
bool ok = got == expected;
if (ok)
{
passed++;
}
Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {expected}");
}
Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;
static string Three(IdGenerator ids) => $"{ids.Next()},{ids.Next()},{ids.Next()}";
static string TwoGenerators()
{
var a = new IdGenerator(start: 1);
var b = new IdGenerator(start: 1);
return $"{a.Next()},{a.Next()},{b.Next()}";
}
static string SharedGenerator()
{
var ids = new IdGenerator(start: 1);
var orders = new OrderService(ids);
var invoices = new InvoiceService(ids);
return $"{orders.Create()},{invoices.Create()},{orders.Create()}";
}
static string OwnGenerators()
{
var orders = new OrderService(new IdGenerator(start: 1));
var invoices = new InvoiceService(new IdGenerator(start: 1));
return $"{orders.Create()},{invoices.Create()}";
}
class IdGenerator
{
public IdGenerator(int start) => throw new NotImplementedException();
public int Next() => throw new NotImplementedException();
}
class OrderService
{
public OrderService(IdGenerator ids) => throw new NotImplementedException();
// An order id such as ORD-1.
public string Create() => throw new NotImplementedException();
}
class InvoiceService
{
public InvoiceService(IdGenerator ids) => throw new NotImplementedException();
// An invoice id such as INV-2.
public string Create() => throw new NotImplementedException();
} Output (exit status 1)
the first id: FAIL, throws NotImplementedException instead of 1 three ids in a row: FAIL, throws NotImplementedException instead of 41,42,43 two generators are independent: FAIL, throws NotImplementedException instead of 1,2,1 two services share one generator: FAIL, throws NotImplementedException instead of ORD-1,INV-2,ORD-3 each service with its own: FAIL, throws NotImplementedException instead of ORD-1,INV-1 a start of 0: FAIL, throws NotImplementedException instead of throws ArgumentOutOfRangeException 0 of 6 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run idgen.cs
Write IdGenerator and the two services
Copy idgen.cs. Keep the next id in an instance field, refuse a start below 1 with
ArgumentOutOfRangeException.ThrowIfLessThan(start, 1), and let Next return the current number and move on. Each
service stores its generator in a readonly field. Run dotnet run idgen.cs until it prints 6 of 6 checks pass.
Then check yourself on static members, static constructors and shared state:
Practice that adapts to you
Practise this lesson
7 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · When should a C# method be static?
When it needs nothing but its parameters and other static members. A static method belongs to the type, so it has no
object and cannot read instance fields unless an object is passed to it; Math.Max(a, b) is static because the
answer depends only on a and b. A method that works on one object’s data, such as account.Withdraw(500m), must
be an instance method. Make a method static for what it needs, not to save typing new: a static method that
reaches for shared static fields drags global state into every caller.
-
Fresher to mid
Unit tests for an InvoiceNumbering class pass when run one at a time, fail when the whole suite runs, and fail differently when the suite runs in parallel. The class keeps the next invoice number in a static field. Explain what is happening and how you would change the design.
-
Mid to senior
A service reads a timeout from configuration in a static constructor. One deployment shipped the value 30s instead of 30. After the configuration was corrected, every request still failed with TypeInitializationException until the service restarted. Explain why, and how you would load the setting so that this cannot happen again.
-
Fresher to mid
A static readonly List<string> AllowedCountries validates addresses. After one test adds a country to it, other tests start accepting addresses they should reject, and the same bug once reached production. Why did readonly not protect the list, and what would you replace it with?
Key takeaways
- A static member belongs to the type and exists once in the program; static classes hold only static members and are never created.
- A static constructor runs at most once, just before the type is first used, right after the static field initializers.
- Shared state in a static field ties tests to their order; pass an object in, so that each test creates its own and the application shares one.
Terms in this lesson
- static class
- A class declared static: it holds only static members, cannot be created with new and cannot be inherited from. Math and Console are static classes.
- static constructor
- A parameterless constructor marked static, such as static Tax() { }. The runtime runs it at most once, just before the type is first used, to initialise the type's static fields.
- static member
- A field, property, method or event declared static. It belongs to the type rather than to any object, exists once in the program, and is reached through the type's name, as in Ticket.Issued.
References
- Static classes and static class members (Microsoft Learn)
- Static constructors (Microsoft Learn)
- TypeInitializationException class (Microsoft Learn)
- Lazy initialization (Microsoft Learn)
- Field design (Framework Design Guidelines) (Microsoft Learn)
- Interlocked class (Microsoft Learn)
- Best practices for writing unit tests (Microsoft Learn)
- Running tests in parallel (xUnit.net) (xUnit.net)
- .NET API changes that affect compatibility (Microsoft Learn)
- Domain reloading (Unity Manual) (Unity Technologies)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress