C# Primary Constructors for Classes and Structs
You already write a constructor's parameter list on line one of a class. A primary constructor asks: why write it again on line three?
Back in lesson 017 you learned to write a constructor like this — a parameter list, and then a body that copies each parameter into a field:
public class Product
{
private readonly string _sku;
private readonly decimal _price;
public Product(string sku, decimal price)
{
_sku = sku;
_price = price;
}
}
Notice how much of that is pure ceremony: sku and price are named twice — once in the parameter list, once again as fields — and the constructor body does nothing except mechanically copy one into the other. You've already seen this exact shortcut once before, in lesson 053, when a positional record generated a constructor and matching properties from a single parameter list. A primary constructor is that same idea, brought to ordinary classes and structs.
In this lesson, you'll learn how primary constructors work on classes and structs, how their parameters behave inside the class body, when they shadow fields, and exactly how this differs from what records already do for you automatically.
What Is It?
The Simple Explanation
A primary constructor lets you attach a constructor's parameter list directly to the class (or struct) declaration itself, instead of writing a separate public ClassName(...) block inside the body. Those parameters are then simply available anywhere in the class body — you can use them directly, assign them to properties, or pass them to base classes, without ever writing a traditional constructor.
The Technical Definition
Primary constructors (C# 12) allow a parameter list to be declared as part of a class or struct declaration. The compiler treats these as the parameters of an implicitly generated constructor. Unlike a positional record, a primary constructor on an ordinary class or struct does not automatically generate properties, Equals/GetHashCode, or a formatted ToString — the parameters are simply captured and made available inside the class body, and you decide what to do with them.
Traditional constructor
- Parameter list lives inside the class body
- You write the body to assign fields explicitly
- Multiple constructors are easy to add side by side
Primary constructor
- Parameter list lives on the class declaration itself
- Parameters are directly usable in the class body — no manual copying needed for simple cases
- Best for classes with one clear, "always required" set of construction values
Why Does It Exist?
The Problem
Records got this convenience starting in C# 9 — a positional record like record Point(int X, int Y); needed no hand-written constructor at all. But if you wanted a class (mutable, reference-equality, no auto-generated ToString) with the same simple "here are my required values" shape, you were stuck writing the full boilerplate constructor by hand — parameter list, field declarations, and a body that just copies one into the other, field after field.
The Need
Developers needed the same "declare it once" convenience records already had, but without records' extra baggage — value-based equality and an auto-generated ToString aren't always wanted on an ordinary, mutable service class or a dependency-injected component that just needs a few things handed to it at construction time.
The Solution
C# 12 generalized the primary-constructor mechanism records already used, making it available on any class or struct — with none of the automatic property/equality generation. You get the parameter-capturing convenience on its own, as a standalone language feature:
public class Product(string sku, decimal price)
{
public string Sku { get; } = sku;
public decimal Price { get; } = price;
}
Shorter than the traditional version, with the same result: a class that requires sku and price to construct, and exposes them as read-only properties.
Big Picture
The parameter-capturing mechanism is shared — records simply layer more automatic generation on top of it.
How It Works
public class OrderProcessor(ILogger logger, decimal taxRate)
{
// logger and taxRate are now in scope for the whole class body
}
- No separate
public OrderProcessor(...)block is written — the class declaration line is the constructor's parameter list.
public class OrderProcessor(ILogger logger, decimal taxRate)
{
public decimal CalculateTotal(decimal subtotal)
{
logger.LogInformation("Calculating total for {Subtotal}", subtotal);
return subtotal + (subtotal * taxRate);
}
}
loggerandtaxRatecan be referenced anywhere in the class body, exactly as if they were fields — no explicit assignment step required.
public class Product(string sku, decimal price)
{
public string Sku { get; } = sku; // exposed publicly
public decimal Price { get; } = price; // exposed publicly
// "sku" and "price" themselves are NOT public members — only these properties are
}
- Unlike a positional record, a class's primary-constructor parameters are not automatically turned into public properties — you write that explicitly, only for the ones you actually want exposed.
public class Employee(string name, decimal baseSalary) { }
public class Manager(string name, decimal baseSalary, decimal bonus)
: Employee(name, baseSalary)
{
public decimal TotalCompensation => baseSalary + bonus;
}
- The base class's primary constructor is invoked right where the base type is named, similar in spirit to
base(...)in a traditional constructor.
Simple Example
Before — traditional constructor
public class TemperatureSensor
{
private readonly string _location;
private readonly double _calibrationOffset;
public TemperatureSensor(string location, double calibrationOffset)
{
_location = location;
_calibrationOffset = calibrationOffset;
}
public double Normalize(double rawReading) => rawReading + _calibrationOffset;
public string Describe() => $"Sensor at {_location}";
}
After — primary constructor
public class TemperatureSensor(string location, double calibrationOffset)
{
public double Normalize(double rawReading) => rawReading + calibrationOffset;
public string Describe() => $"Sensor at {location}";
}
Meaning: Same behavior, no fields, no assignment lines. location and calibrationOffset are simply in scope everywhere they're needed. Nothing here is exposed publicly — callers can construct a TemperatureSensor, but can't read its location or offset back out unless a property is added for that purpose.
Real-World Example
Primary constructors are especially at home in the kind of small, dependency-injected service classes that show up constantly in real applications — a class that just needs a handful of collaborators handed to it and does something with them:
public interface IPaymentGateway
{
bool Charge(string customerId, decimal amount);
}
public interface INotificationService
{
void Send(string customerId, string message);
}
// A payment service that needs a gateway and a notifier — nothing more.
public class PaymentService(IPaymentGateway gateway, INotificationService notifier)
{
public bool ProcessPayment(string customerId, decimal amount)
{
var success = gateway.Charge(customerId, amount);
notifier.Send(customerId, success
? $"Payment of {amount:C} succeeded."
: $"Payment of {amount:C} failed. Please try again.");
return success;
}
}
// Usage — exactly as before, dependencies just flow in through construction:
var service = new PaymentService(new StripeGateway(), new EmailNotifier());
service.ProcessPayment("CUST-42", 49.99m);
Compare this to the traditional version: you'd need two private readonly fields, a constructor body with two assignment lines, and only then would you get to the actual logic. The primary constructor removes exactly that ceremony, without changing anything about how PaymentService behaves or how it's constructed by callers — including a dependency injection container, which resolves primary-constructor parameters exactly the same way it resolves traditional ones.
Under the Hood
- The compiler generates one constructor matching the declared parameter list — same as a hand-written one would produce in IL.
- A primary constructor parameter is only "promoted" to an actual compiler-generated private field if it's used somewhere outside the constructor itself (in a method, a property initializer, or another member). If a parameter is used only inside the constructor logic itself (or not at all after simple assignment), no hidden field is created for it.
- This is different from a positional record, which always promotes every parameter into a full public
init-accessor property automatically, plus generatesEquals,GetHashCode,ToString, andDeconstructbased on those properties. - Because primary-constructor parameters aren't properties by default, they don't participate in equality comparisons or show up in a default
ToString()the way a record's positional parameters do — a plain class still uses reference equality and the baseobject.ToString()unless you write your own.
Common Confusion
1. "This is the same as a positional record" — not quite
A record Product(string Sku, decimal Price); gives you public init properties, value-based equality, and a readable ToString(), all generated automatically. A class Product(string sku, decimal price) gives you only the parameter-capturing behavior — no properties, no equality, no ToString() — unless you write them yourself. Choose a record when you want an immutable data-carrier; choose a class with a primary constructor when you want the convenience without the extra generated behavior.
2. Primary constructor parameters are not automatically public members
It's easy to assume that because a parameter is usable throughout the class, it must be visible from outside the class too. It isn't. new TemperatureSensor("Lab", 0.5).location won't compile — location is only in scope inside the class body, not exposed as a member. If you want callers to read it back, you must explicitly expose a property for it.
3. A parameter used in every method isn't automatically "captured once" and cached — it's still just a parameter
When a primary-constructor parameter is promoted to a hidden field, that promotion happens once, at construction. But if you write public string Sku { get; } = sku;, the property's value is fixed at construction time too, from whatever sku held then — reassigning a local variable that was passed in as sku by the caller, after construction, has no effect on the object; the value was already copied into the class the moment the object was built.
Common Mistakes
Mistake 1 — Reusing a primary constructor parameter's name for an auto-property
public class Product(string sku, decimal price)
{
public string Sku { get; set; } = sku; // fine
public string sku { get; set; } // CS0102: 'Product' already defines a member called 'sku'
}
Primary constructor parameters share the class's member namespace — pick a property name that doesn't collide (the conventional fix, as shown above, is simply a different case: sku the parameter, Sku the property).
Mistake 2 — Expecting equality or a readable ToString "for free," like a record gives you
public class Point(int x, int y) { public int X { get; } = x; public int Y { get; } = y; }
var a = new Point(1, 2);
var b = new Point(1, 2);
Console.WriteLine(a == b); // false — reference equality, not value equality
Console.WriteLine(a); // prints the type name, not "Point { X = 1, Y = 2 }"
If you want value-based equality and a readable ToString() alongside primary-constructor convenience, that's precisely what a record is for (lesson 053) — don't reach for a plain class expecting record behavior to come along for free.
Mistake 3 — Overusing primary constructors on classes with complex construction logic
Cramming validation, branching, or multiple overloads into a primary constructor's parameter list makes the class declaration line hard to read and offers nowhere natural to put that logic. For a class that needs to validate arguments, throw on invalid input, or offer several different ways to construct it, a traditional constructor (or several overloaded ones) is often still clearer than forcing everything through a single primary constructor.
When Should I Use It?
- Use a primary constructor for a class or struct with one clear, simple set of required construction values — services with a few injected dependencies are the classic case.
- Skip it when a type needs multiple constructors, argument validation, or any nontrivial logic at construction time — a traditional constructor body gives you a natural place to put that.
- Reach for a record instead, if what you actually want is an immutable data-carrier with generated equality and
ToString()— a primary constructor on a plain class deliberately doesn't give you those.
_field = parameter; assignments, a primary constructor removes exactly that ceremony. If your constructor needs to do something — validate, branch, throw — keep it traditional.
Mental Model
Remember:
· A primary constructor parameter is in scope everywhere in the class body, but is not a public member unless you expose it as one.
· A record's positional parameters get properties, equality, and
ToString() automatically. A plain class's primary constructor parameters get none of that — you opt in explicitly.· Best for simple, single-path construction. Complex construction logic still belongs in a traditional constructor.
Key Takeaway
- Primary constructors (C# 12) let you declare a constructor's parameter list directly on a
classorstruct, removing the boilerplate of a separate constructor body that just copies parameters into fields. - The parameters are usable anywhere in the class body, but are not automatically public members — you must explicitly expose a property if callers need to read a value back.
- Unlike a positional
record, a class's primary constructor generates no properties, equality, orToString()automatically — it's the parameter-capturing mechanism alone. - Best suited to simple types with one obvious set of required construction values — dependency-injected services are the most common real-world fit.
- For construction logic that validates, branches, or needs multiple overloads, a traditional constructor is still the clearer choice.
Check Your Understanding
You've seen how primary constructors remove boilerplate from simple classes, and how they differ from what records already do automatically. Let's check your understanding.
1. Given public class Logger(string source) { public void Log(string msg) => Console.WriteLine($"[{source}] {msg}"); }, can external code do new Logger("Api").source to read the source back?
Show answer
Correct: B
Why B is correct: A primary constructor parameter on an ordinary class is usable inside the class body, but it is not automatically promoted to a public member. To read it from outside, you'd need to explicitly add something like public string Source { get; } = source;.
Why A is incorrect: This is exactly what a positional record does, not a plain class — the two behave differently on purpose.
Why C is incorrect: C describes record behavior correctly, but the question is about the class shown, which is not a record.
Why D is incorrect: source is fully usable inside the class body (as seen in the Log method) — it just isn't visible from outside.
Reinforcement: Primary constructor parameters on a class are in-scope internally, but never public by default.
2. Two instances are created: new Point(1, 2) and new Point(1, 2), where Point is a plain class with a primary constructor and matching properties. What does instance1 == instance2 evaluate to, and why?
Show answer
Correct: B
Why B is correct: A primary constructor on a plain class only affects how the object is constructed — it generates no equality logic. Two separately-created instances remain distinct references, so == compares identity and returns false, exactly as it would for any ordinary class.
Why A is incorrect: Automatic value-based equality is a record feature, generated from the record's compiler-synthesized Equals/GetHashCode — not something primary constructors provide on their own.
Why C is incorrect: == works fine on any class (falling back to reference equality by default) — primary constructors don't restrict this.
Why D is incorrect: The property types have no bearing here; the behavior is determined by whether the type is a class (reference equality) or a record (value equality).
Reinforcement: If you need value equality alongside primary-constructor convenience, use a record, not a plain class.
3. You're writing a Manager class that must inherit from Employee and pass along a name and baseSalary that Employee's own primary constructor requires. Which syntax correctly does this?
Show answer
Correct: A
Why A is correct: The base type's primary constructor is invoked right where the base type is named in the class declaration — : Employee(name, baseSalary) — passing along the values the base class needs.
Why B is incorrect: There's no constructor body here to place a base(...) call inside — the base-constructor invocation happens on the declaration line itself, not in a body statement.
Why C is incorrect: This puts the parameter list on the wrong type — the parameter list belongs on Manager (the type actually being declared), not appended to the base type name.
Why D is incorrect: Primary constructors work with inheritance just fine — passing values to the base class's primary constructor is a normal, supported pattern.
Reinforcement: Passing values to a base primary constructor happens inline, right after the base type's name in the class declaration.
You now know how to cut constructor boilerplate with primary constructors — and, just as importantly, exactly where they stop and a record or a traditional constructor should take over instead.