← Open in the full interactive course (progress tracking, search & more)

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

Primary constructor

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

WHERE PRIMARY CONSTRUCTORS FIT
class Product(...)
Primary constructor only — parameters captured, nothing generated automatically
record Product(...)
Same syntax, PLUS auto properties, value equality, ToString, Deconstruct
Traditional constructor
Full manual control — separate parameter list and body

The parameter-capturing mechanism is shared — records simply layer more automatic generation on top of it.

How It Works

PRIMARY CONSTRUCTORS — STEP BY STEP
1. DECLARE THE PARAMETERS ON THE CLASS ITSELF
public class OrderProcessor(ILogger logger, decimal taxRate)
{
    // logger and taxRate are now in scope for the whole class body
}
2. USE THE PARAMETERS DIRECTLY IN METHODS
public class OrderProcessor(ILogger logger, decimal taxRate)
{
    public decimal CalculateTotal(decimal subtotal)
    {
        logger.LogInformation("Calculating total for {Subtotal}", subtotal);
        return subtotal + (subtotal * taxRate);
    }
}
3. EXPOSE A PARAMETER AS A PROPERTY — ONLY IF YOU CHOOSE TO
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
}
4. PASS PARAMETERS TO A BASE CLASS
public class Employee(string name, decimal baseSalary) { }

public class Manager(string name, decimal baseSalary, decimal bonus)
    : Employee(name, baseSalary)
{
    public decimal TotalCompensation => baseSalary + bonus;
}

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

WHAT THE COMPILER ACTUALLY GENERATES
PARAMETERS BECOME PART OF THE CLASS'S CLOSURE, NOT AUTOMATIC FIELDS

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?

Rule of thumb: If your constructor's entire body would just be a list of _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

Primary constructor = "The parameter list moves up to the class declaration — and the parameters are just... there, ready to use."

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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.