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.
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.
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.
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.
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.
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.
The parameter-capturing mechanism is shared — records simply layer more automatic generation on top of it.
public class OrderProcessor(ILogger logger, decimal taxRate)
{
// logger and taxRate are now in scope for the whole class body
}
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);
}
}
logger and taxRate can 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
}
public class Employee(string name, decimal baseSalary) { }
public class Manager(string name, decimal baseSalary, decimal bonus)
: Employee(name, baseSalary)
{
public decimal TotalCompensation => baseSalary + bonus;
}
base(...) in a 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}";
}
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.
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.
init-accessor property automatically, plus generates Equals, GetHashCode, ToString, and Deconstruct based on those properties.ToString() the way a record's positional parameters do — a plain class still uses reference equality and the base object.ToString() unless you write your own.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.
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.
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.
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).
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.
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.
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.
ToString() automatically. A plain class's primary constructor parameters get none of that — you opt in explicitly.class or struct, removing the boilerplate of a separate constructor body that just copies parameters into fields.record, a class's primary constructor generates no properties, equality, or ToString() automatically — it's the parameter-capturing mechanism alone.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?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.