C# & .NET (C# 14, .NET 10 LTS) Module 6 – Classes, objects and encapsulation
Primary constructors for classes and structs
Declare C# 12 primary constructors on classes and structs, see which parameters the compiler keeps, and stop one value from being stored twice.
What you will learn
- Declare a primary constructor on a class or a struct, and chain other constructors to it with this(...)
- Predict which primary constructor parameters the compiler keeps in hidden fields, and what a record does instead
- Find and fix a value that a primary constructor stores twice, the problem behind warning CS9124
- Prevent values from changing after creation with readonly copies and readonly structs
- Choose between a primary constructor, an ordinary constructor and a record for a type's arguments and checks
Before you start
What you’ll learn · Before you start
What you will learn
- Declare a primary constructor on a class or a struct, and chain other constructors to it with this(...)
- Predict which primary constructor parameters the compiler keeps in hidden fields, and what a record does instead
- Find and fix a value that a primary constructor stores twice, the problem behind warning CS9124
- Prevent values from changing after creation with readonly copies and readonly structs
- Choose between a primary constructor, an ordinary constructor and a record for a type's arguments and checks
Before you start
On this page
A reminder service that needs a clock and an outbox spends seven lines on two fields and a constructor that only copies its arguments into them. A primary constructor writes the same two parameters after the class name, and every member of the class can use them.
You will declare primary constructors for classes and structs, chain other constructors to them, predict which parameters the compiler keeps, and stop a value from being stored twice.
Parameters on the type’s first line, in scope in every member
service.cs writes one service twice. ReminderService(TimeProvider clock, Outbox outbox) declares a
primary constructor: A parameter list written right after a class or struct name, as in class Fare(string route, int paise). Its parameters are in scope in the whole body of the type, and every other constructor must call it with this(...). Records have one too, and only theirs become properties., and ClassicReminderService does the same job with two
fields and an ordinary constructor:
using System.Globalization;
// The same reminder service twice: with a primary constructor, then with a constructor and two fields.
// The clock always gives the same time, so every run prints the same lines.
var clock = new FixedClock(new DateTimeOffset(2026, 10, 17, 4, 0, 0, TimeSpan.Zero));
var primaryOutbox = new Outbox();
var primary = new ReminderService(clock, primaryOutbox);
primary.Remind("Asha", "coach B2 opens at 10:15");
primary.Remind("Ravi", "seat 12B is confirmed");
primaryOutbox.Print("primary constructor");
var classicOutbox = new Outbox();
var classic = new ClassicReminderService(clock, classicOutbox);
classic.Remind("Asha", "coach B2 opens at 10:15");
classic.Remind("Ravi", "seat 12B is confirmed");
classicOutbox.Print("classic constructor");
// clock and outbox are parameters of the whole class: every member below can use them.
sealed class ReminderService(TimeProvider clock, Outbox outbox)
{
private static readonly TimeSpan India = TimeSpan.FromHours(5.5);
public void Remind(string name, string text) => outbox.Add($"{Stamp()} {name}: {text}");
private string Stamp() => clock.GetUtcNow().ToOffset(India).ToString("HH:mm", CultureInfo.InvariantCulture);
}
// The same class without a primary constructor: two fields, a constructor and two assignments.
sealed class ClassicReminderService
{
private static readonly TimeSpan India = TimeSpan.FromHours(5.5);
private readonly TimeProvider _clock;
private readonly Outbox _outbox;
public ClassicReminderService(TimeProvider clock, Outbox outbox)
{
_clock = clock;
_outbox = outbox;
}
public void Remind(string name, string text) => _outbox.Add($"{Stamp()} {name}: {text}");
private string Stamp() => _clock.GetUtcNow().ToOffset(India).ToString("HH:mm", CultureInfo.InvariantCulture);
}
// A clock that always gives the same time: a TimeProvider with a primary constructor of its own.
sealed class FixedClock(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
sealed class Outbox
{
private readonly List<string> _lines = [];
public void Add(string line) => _lines.Add(line);
public void Print(string title)
{
Console.WriteLine($"{title}: {_lines.Count} reminders");
foreach (var line in _lines)
{
Console.WriteLine($" {line}");
}
}
} Output
primary constructor: 2 reminders 09:30 Asha: coach B2 opens at 10:15 09:30 Ravi: seat 12B is confirmed classic constructor: 2 reminders 09:30 Asha: coach B2 opens at 10:15 09:30 Ravi: seat 12B is confirmed
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run service.cs
Both sent the same two reminders at 09:30, the time of the fixed clock. Inside ReminderService, Stamp reads
clock and Remind reads outbox: the parameters are in scope in the whole body of the type. They stay parameters,
not members, so outside code cannot write primary.clock, and inside the class this.clock does not compile.
Primary constructors for classes and for structs arrived in C# 12; records had them since C# 9, and only a record
turns its parameters into public properties. Primary constructors for classes suit types like this one, a service
that keeps what it was given.
Checkpoint
Check: Parameters in scope in every member
Other constructors call this(…), and a struct keeps its zero default
journey.cs gives a struct and a class a primary constructor each, plus a second constructor that fills in a
default:
// A struct and a class with primary constructors, the constructors that call them with this(...), and the
// constructors each type really has.
var trip = new Journey(from: 3, to: 9);
var shortTrip = new Journey(stations: 4);
var empty = new Journey();
var berth = new Berth("B2", 12);
var chained = new Berth(40);
Console.WriteLine($"new Journey(3, 9): {trip}");
Console.WriteLine($"new Journey(4): {shortTrip}");
Console.WriteLine($"new Journey(): {empty}");
Console.WriteLine($"new Berth(\"B2\", 12): {berth}");
Console.WriteLine($"new Berth(40): {chained}");
ListConstructors(typeof(Journey));
ListConstructors(typeof(Berth));
static void ListConstructors(Type type)
{
var signatures = type.GetConstructors()
.OrderByDescending(c => c.GetParameters().Length)
.Select(c => $"({string.Join(", ", c.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"))})");
Console.WriteLine($"{type.Name} declares {string.Join(" and ", signatures)}");
}
// A struct: new Journey() still works and gives every parameter its default value, 0.
struct Journey(int from, int to)
{
// Any other constructor must call the primary constructor with this(...).
public Journey(int stations)
: this(1, 1 + stations)
{
}
public int Stops => Math.Abs(to - from);
public override string ToString() => $"station {from} to {to}, {Stops} stops";
}
// A class: once it has a primary constructor, it has no parameterless constructor.
sealed class Berth(string coach, int number)
{
public Berth(int number)
: this("S1", number)
{
}
public override string ToString() => $"{coach}-{number}";
} Output
new Journey(3, 9): station 3 to 9, 6 stops
new Journey(4): station 1 to 5, 4 stops
new Journey(): station 0 to 0, 0 stops
new Berth("B2", 12): B2-12
new Berth(40): S1-40
Journey declares (Int32 from, Int32 to) and (Int32 stations)
Berth declares (String coach, Int32 number) and (Int32 number)
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run journey.cs
new Journey(4) ran this(1, 1 + 4) and became station 1 to 5. Every other constructor must call the primary
constructor, directly or through another constructor, with : this(...), so that the parameters have values in
every object; errors CS8862 in a class and CS8982 in a struct refuse a constructor without it. From there, structs
and classes differ in one rule:
| A class with a primary constructor | A struct with a primary constructor | |
|---|---|---|
new T() with no arguments |
does not compile: the class has no parameterless constructor | always works: unless the struct declares its own, every parameter is zero |
| Other constructors | each calls this(...) |
each calls this(...) |
Reflection listed two constructors for each type, yet new Journey() worked and gave station 0 to 0: every struct
has a public parameterless constructor, and one the struct does not declare itself fills every field with zeros. A
primary constructor does not take it away. Give a struct parameters whose zero values make sense.
Checkpoint
Check: this(...) and the struct default
The compiler keeps a parameter only when a member reads it
capture.cs asks reflection for every field the compiler generated, for a class with three parameters and for a
record with the same three:
using System.Reflection;
// Which primary constructor parameters does the compiler keep? Reflection lists the fields it generated for a class
// and for a record with the same three parameters.
ShowFields(typeof(Fare));
ShowFields(typeof(FareRecord));
var fare = new Fare("NDLS-AGC", 450_00, 3);
Console.WriteLine(fare.Describe());
static void ShowFields(Type type)
{
const BindingFlags all = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
var fields = type.GetFields(all).Select(f => f.Name).Order(StringComparer.Ordinal);
var properties = type.GetProperties().Select(p => p.Name).Order(StringComparer.Ordinal);
Console.WriteLine(type.Name);
Console.WriteLine($" fields: {string.Join(", ", fields)}");
Console.WriteLine($" public properties: {string.Join(", ", properties)}");
}
class Fare(string route, int basePaise, int stops)
{
// basePaise is read only by this initializer, so no copy of the parameter is kept.
public int BasePaise { get; } = basePaise;
// stops and route are read by members, so the compiler keeps each in a hidden field.
public int TotalPaise => BasePaise + (stops * 25_00);
public string Describe() => $"{route}: {TotalPaise / 100} rupees for {stops} stops";
}
// A record turns the same parameters into public properties.
record FareRecord(string Route, int BasePaise, int Stops); Output
Fare fields: <BasePaise>k__BackingField, <route>P, <stops>P public properties: BasePaise, TotalPaise FareRecord fields: <BasePaise>k__BackingField, <Route>k__BackingField, <Stops>k__BackingField public properties: BasePaise, Route, Stops NDLS-AGC: 525 rupees for 3 stops
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run capture.cs
basePaise is read only by the initializer of BasePaise, which runs while the object is being created, so no copy
of the parameter is kept: the property’s backing field holds the value. route and stops are read by members that
run later, so the compiler keeps each in a hidden field, <route>P, a name no C# code can write. Such a
captured parameter: A primary constructor parameter that a member of the type reads after construction. The compiler keeps it in a hidden private field for as long as the object lives; that field is not readonly unless the type is a readonly struct. lives as long as the object. The record kept the same values
as three public properties instead. Step through where each parameter ends up:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Three parameters. new Fare("NDLS-AGC", 450_00, 3) passes three values. Inside Fare they are parameters, in scope in every member, and none of them is a member that other code can read.
- Read once, kept once. basePaise is read only by the initializer of BasePaise, which runs while the object is being created. The value goes into the backing field of the property, and no copy of the parameter is kept.
- Read later, captured. Describe() runs long after the constructor has finished, so route has to outlive it. The compiler keeps it in a private field named <route>P, a name that no C# code can write.
- Captured too. TotalPaise reads stops every time it is called, so stops is captured as well. Reflection listed exactly these three fields, and Describe() printed 525 rupees for 3 stops.
- Hidden, and writable. The two captured fields are not readonly: a method of Fare could assign to route, and every later Describe() would show the new value. A record would have made all three values public properties instead.
Two rules follow. A parameter that a member captures and an initializer also copies into a property is stored twice, and warning CS9124 points at the copy. A method may assign to a captured parameter, because its field is readonly only in a readonly struct.
Key idea
A primary constructor parameter belongs to the whole type: the compiler stores it only when a member reads it, in a hidden field that is writable unless the type is a readonly struct, and never as a property unless the type is a record.
Checkpoint
Check: Which parameters the compiler keeps
One value stored twice: CS9124 and CS9107 reproduced, with the fixes Pro: opens with a Pro, Premium or Ultimate pass
A parameter can be copied into a property and captured by a member as well, or handed to a base class and captured again. The run below does both, and the copies drift apart as soon as the object changes.
In this part:
- CS9124 reproduced: a metro card whose summary forgets its payments
- CS9107: a derived class that keeps the old name
- One copy, read through the member that owns it
2 runnable examples (C#, Python) · about 3 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).
Captured parameters are writable: a quota that loses its limit, and the readonly fixes Pro: opens with a Pro, Premium or Ultimate pass
Nothing stops a method from assigning to a captured parameter, so the value a type was created with can be lost for good. The run below counts down a daily quota both ways, then the compiler stops the same mistake in a readonly struct.
In this part:
- A quota that counts down its own parameter, reproduced
- readonly struct and two rules the compiler enforces
3 runnable examples (C#, Python) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Validation, and choosing between primary, ordinary and record constructors Pro: opens with a Pro, Premium or Ultimate pass
A primary constructor has no body, so every check of its arguments lives in an initializer or in a member. The run below checks a booking number three ways and finds the way that lets an invalid booking exist.
In this part:
- Three places to check a PNR: one lets a bad booking exist
- A decision you can test against your own type
1 runnable example (C#) · about 7 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: platforms, fares and passengers
platform.cs checks three types you write. Platform and Fare become a class and a struct with primary
constructors; Passenger keeps an ordinary constructor, because it trims the name and refuses bad values before it
stores anything. The types you start from only throw, so all 8 checks fail:
// Practice: write Platform and Fare with primary constructors, and keep Passenger's ordinary constructor, which
// checks and trims its arguments. Then run this file until all eight checks pass.
(string Check, Func<string> Run, string Expected)[] checks =
[
("the platform number", () => new Platform(4, "New Delhi").Number.ToString(), "4"),
("an announcement", () => new Platform(4, "New Delhi").Announce("12951"), "Platform 4, New Delhi: 12951"),
("the chained constructor", () => new Platform(7).Announce("12002"), "Platform 7, New Delhi: 12002"),
("a fare in rupees", () => new Fare(45_50).Rupees.ToString(System.Globalization.CultureInfo.InvariantCulture), "45.5"),
("new Fare()", () => new Fare().Rupees.ToString(System.Globalization.CultureInfo.InvariantCulture), "0"),
("a name trimmed", () => new Passenger(" Asha Rao ", 34).Name, "Asha Rao"),
("a blank name", () => new Passenger(" ", 34).Name, "throws ArgumentException"),
("an age of -1", () => new Passenger("Ravi Iyer", -1).Name, "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;
// Rewrite with a primary constructor (int number, string station); Platform(int number) uses "New Delhi".
class Platform
{
public Platform(int number, string station) => throw new NotImplementedException();
public Platform(int number) => throw new NotImplementedException();
public int Number => throw new NotImplementedException();
// "Platform 4, New Delhi: 12951"
public string Announce(string train) => throw new NotImplementedException();
}
// Rewrite as a struct with a primary constructor (int paise); Rupees is paise / 100m.
struct Fare
{
public Fare(int paise) => throw new NotImplementedException();
public decimal Rupees => throw new NotImplementedException();
}
// Keep an ordinary constructor: it trims the name, refuses a blank one and an age outside 0 to 125.
class Passenger
{
public Passenger(string name, int age) => throw new NotImplementedException();
public string Name => throw new NotImplementedException();
public int Age => throw new NotImplementedException();
} Output (exit status 1)
the platform number: FAIL, throws NotImplementedException instead of 4 an announcement: FAIL, throws NotImplementedException instead of Platform 4, New Delhi: 12951 the chained constructor: FAIL, throws NotImplementedException instead of Platform 7, New Delhi: 12002 a fare in rupees: FAIL, throws NotImplementedException instead of 45.5 new Fare(): FAIL, throws NotImplementedException instead of 0 a name trimmed: FAIL, throws NotImplementedException instead of Asha Rao a blank name: FAIL, throws NotImplementedException instead of throws ArgumentException an age of -1: FAIL, throws NotImplementedException instead of throws ArgumentOutOfRangeException 0 of 8 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run platform.cs
Write the three types
Copy platform.cs. Declare class Platform(int number, string station) with Platform(int number) : this(number, "New Delhi"), and struct Fare(int paise) with Rupees => paise / 100m. In Passenger, call
ArgumentException.ThrowIfNullOrWhiteSpace(name) and check the age before storing name.Trim(). Run
dotnet run platform.cs until it prints 8 of 8 checks pass.
Then check yourself on primary constructors. The questions start with the idea you know least:
Practice that adapts to you
Practise this lesson
8 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 · What does a primary constructor give a C# class, and what doesn’t it?
It declares the constructor’s parameters after the class name, as in class ReminderService(TimeProvider clock, Outbox outbox), and puts them in scope in every member, so the class needs no fields and no constructor just to keep
its dependencies. It does not make them members or properties: callers cannot read them, and this.clock does not
compile; only a record turns its parameters into public properties. The compiler stores a parameter only when a
member reads it, in a hidden field that is not readonly. Every other constructor must call the primary one with
this(...), and a class loses its parameterless constructor.
-
Fresher to mid
A MetroCard class takes a balance in its primary constructor, declares Balance { get; private set; } = balance; and builds Summary() from balance. After two payments, Summary() still shows the opening balance while Balance is right. What is happening, and how do you fix it and keep it fixed?
-
Mid to senior
Your team wants to convert all 40 service and entity classes of a project to primary constructors in one pull request. Which classes would you convert, which would you leave alone, and what would you check after the change?
-
Fresher to mid
A Booking(string pnr) class with a primary constructor checks the PNR inside the getter of its Pnr property. Logs show bookings created with malformed PNRs that fail much later, far from where they came in. Why does the check run late, and where should it run?
Key takeaways
- A primary constructor writes a type’s parameters after its name; they are in scope in every member but stay parameters, not members, and only a record makes them properties.
- Every other constructor calls the primary one with
this(...); a class loses its parameterless constructor, and a struct keeps one, which sets every parameter to zero unless the struct declares its own. - The compiler keeps a parameter only when a member reads it, in a hidden field that is writable outside a readonly struct; a parameter read only by initializers is not kept at all.
Terms in this lesson
- captured parameter
- A primary constructor parameter that a member of the type reads after construction. The compiler keeps it in a hidden private field for as long as the object lives; that field is not readonly unless the type is a readonly struct.
- primary constructor
- A parameter list written right after a class or struct name, as in class Fare(string route, int paise). Its parameters are in scope in the whole body of the type, and every other constructor must call it with this(...). Records have one too, and only theirs become properties.
References
- Instance constructors (primary constructors) (Microsoft Learn)
- Tutorial: declare primary constructors for classes and structs (Microsoft Learn)
- What's new in C# 12 (primary constructors) (Microsoft Learn)
- What's new in C# 14 (partial constructors and primary constructors) (Microsoft Learn)
- Errors and warnings for constructor declarations (CS8862, CS9107, CS9111, CS9114, CS9124) (Microsoft Learn)
- Primary constructors: the C# 12 feature proposal (dotnet/csharplang on GitHub)
- Records (C# reference) (Microsoft Learn)
- Structure types (struct initialization and default values) (Microsoft Learn)
- TimeProvider class (Microsoft Learn)
- Type.GetFields method (Microsoft Learn)
- Dependency injection in .NET (constructor injection) (Microsoft Learn)
- Compiler options: errors and warnings (WarningsAsErrors) (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress