AlgoMaster Logo

Primary Constructors

Medium Priority20 min readUpdated June 6, 2026

C# 12 extended primary constructors, which had been a record-only feature since C# 9, to regular class and struct declarations. The syntax puts a parameter list right on the type header, and those parameters stay in scope across the whole class body. The semantics, though, are different from records in ways that catch people out, so this lesson works through what the compiler actually does and where the rough edges are.

The Syntax

A primary constructor is the parameter list that appears after the type name:

The (decimal taxRate) after PriceCalculator declares a constructor that takes one argument. Every member of the class can refer to taxRate directly, like it was a field. The whole class is one constructor short.

Compare it to the equivalent regular-class version:

Same behavior, more code. The primary constructor form collapses the field declaration, the constructor signature, and the assignment into one parameter list.

Parameters Are Not Properties (Important Difference From Records)

This is the part that trips up developers who learned primary constructors through records first. On a record, a positional parameter becomes a public init-only property:

On a regular class or struct, a primary constructor parameter is not a property. It's just a parameter that's in scope across the body. Nothing public gets generated.

There's no svc.defaultRate to read from the outside. The parameter is private to the class body. If you want callers to see the value, you have to expose it yourself:

Now DefaultRate is a public read-only property backed by the captured parameter. This split is deliberate. The language designers didn't want every primary-constructor parameter to leak into the public surface of the type the way it does with records, because classes have a lot more reason to keep their constructor arguments private.

TypePrimary ctor parameter becomes
record (class or struct)Public init-only property with the same name
classPrivate parameter in scope across the body, captured into a hidden field if needed
structSame as class: private, captured if needed

When the Compiler Synthesizes a Capture Field

A primary constructor parameter lives on the stack as a normal parameter while the constructor runs. The instant any instance member (not just the constructor body) references it, the compiler has to keep it alive past the constructor call. It does that by quietly creating a private field on the type and storing the value there.

This rewrite is invisible. You write:

The compiler emits something close to:

The field name uses a syntax that's illegal for normal source code, so you can't accidentally collide with it. Two consequences fall out of this:

  1. A parameter used only inside the primary constructor "body" (more on that in a moment) doesn't need a field at all. The compiler skips the capture.
  2. A parameter used in a method, property, or other member outside the constructor lifetime is captured into a field, exactly as if you'd written the field yourself.

Both prefix and logger survive past the constructor because Log reads them every time it runs. The compiler synthesizes two private fields, one per captured parameter.

A captured primary constructor parameter is the same size and access cost as a private field you'd write by hand. There's no extra memory or indirection, just the field. The "magic" is purely a compile-time rewrite.

Initialization Order: Parameters, Field Initializers, Body

A class with a primary constructor can still have field initializers and an explicit constructor body. The order they run in matters when one references another.

The exact sequence on instance creation:

  1. Arguments are bound to the primary constructor parameters.
  2. The base class constructor runs (called explicitly with : base(...) after the parameter list, or implicitly via the parameterless base constructor).
  3. Instance field initializers run, in declaration order. They can read primary constructor parameters.
  4. The primary constructor "body" runs, where a primary constructor doesn't have an explicit body, so this step is conceptually empty.

The "no body" point is easy to miss. A primary constructor declaration is just class Foo(int x) { ... }. The body inside the braces is the class body, not the constructor body. There's no { ... } attached to the primary constructor signature to put validation in.

Field initializers, on the other hand, can use the parameters:

The right-hand sides run for every new instance, after the parameter is bound. Owner becomes a public read-only property whose backing field is initialized from the parameter, Items starts as an empty list, and CreatedAt snapshots the current time.

The order is the same as a regular class with a constructor, but the primary constructor itself doesn't have a "body step" for you to drop code into. If you need code that runs on construction, you put it on a field initializer (public int Counter { get; } = Initialize(parameter);) or use one of the patterns later in this lesson.

Combining With Additional Constructors

A class can have a primary constructor and other constructors at the same time. The rule the compiler enforces is: every other constructor must chain to the primary constructor with : this(...). There's no way to construct an instance that skips the primary parameters.

The two extra constructors don't repeat any field-assignment work. They funnel into the primary constructor, which is the only place that knows how to set the fields. Try to write public Cart() { } without the : this(...) and you get CS9105: 'Cart' must include a primary constructor parameter list, the compiler's way of saying "you skipped the primary constructor, that's not allowed."

This is the same funnel pattern the constructor chaining lesson covered, just with the primary constructor taking the role of the "main" constructor every other overload routes through.

Calling base(...) From the Primary Constructor

When the class inherits from another class, the primary constructor can pass arguments to the base by writing : base(...) after the parameter list. There's no extra constructor declaration needed.

The : Product(name, price) on the derived class header does two things at once: it declares Product as the base class and supplies the arguments for Product's primary constructor. The derived primary constructor parameters name and price are passed straight through. The local discount parameter is kept for the derived class to use in its own initializer.

If you also need an additional constructor on the derived class, you still chain it to the derived primary constructor, not to the base:

The two-argument constructor chains to the three-argument primary, which in turn passes the first two args to the base. One funnel, two layers.

Primary Constructors on Structs

The same syntax works on a struct. The semantics match what we covered above with one wrinkle : structs are value types, so the captured parameter fields live inside the struct's layout, and the struct's size grows with each captured parameter.

Some details to highlight:

  • The struct is marked readonly, which combined with { get; } properties keeps it fully immutable. That's the usual shape for a primary constructor on a struct: small, immutable, value-shaped.
  • Each property's = amount and = currency initializer copies the parameter into the property's backing field, which is part of the struct's layout.
  • Calling Add on subtotal doesn't mutate it; it returns a new Money. Trying to call Add and assign back through a property would fail to compile because of the readonly modifier.

Every captured parameter on a struct adds to the struct's size. A struct with five captured parameters is bigger than one with two, and that affects how cheap it is to copy. Keep struct primary constructors short, three or four parameters at most, or the value-type benefits start eroding.

A regular (non-readonly) struct with a primary constructor is legal, but the captured-parameter fields become writable, which is rarely what you want for a value-shaped type. If you find yourself reaching for a mutable struct, that's usually a signal to use a class instead.

Common Pitfalls

A few traps come up often enough to be worth calling out.

Pitfall 1: Mutating a Captured Parameter

A primary constructor parameter is a regular parameter, not a readonly field. The compiler synthesizes a writable backing field, and you can assign to the parameter from inside an instance method:

That compiles. Each call to Increment modifies the captured value. In .NET 8 the compiler emits a warning (CS9124) if you mutate a captured primary constructor parameter, because it's almost always a bug, the reader probably thought the parameter was read-only like a constructor argument typically is.

If you actually want a counter, write the field explicitly so the intent is clear:

The treatment of the parameter as the source of truth, with a separate mutable field for state, removes the ambiguity. The pitfall is real because the parameter looks like a property name (no underscore, no _value convention), and accidentally assigning to it isn't an error.

Pitfall 2: Property and Parameter Name Collisions

If a property and a primary constructor parameter share a name, the parameter wins inside the class body. The property is only reachable through this:

Both lines work, but they go through different storage. If you later add validation or formatting to the Owner property's getter, owner won't pick it up because it's referencing the parameter (and its synthesized capture field), not the property. The conventional fix is to give the parameter a lowercase name and the property a Pascal-case name (as in the example), so they're distinct, and use this.Property if you want the property's logic to run.

Pitfall 3: No Place for Validation

Records and regular constructors give you a place to validate arguments before assignment. A primary constructor on a class doesn't have a body, so where do you put the checks?

The cleanest option is to validate inside a property initializer:

The conditional expression in the initializer either passes the value through or throws. That keeps everything in the primary constructor's footprint while still enforcing the rule.

Another option is a static helper method:

Both compile to roughly the same IL. The helper-method version reads better when there are several checks. If a primary constructor needs serious validation, you can do it, but the syntax is slightly more awkward than dropping if statements into a regular constructor body. That's one of the trade-offs.

When to Use a Primary Constructor

Primary constructors are best for small classes whose only job in the constructor is "store these arguments." That covers a lot of real-world C# code:

  • Services that receive dependencies through constructor injection. A class that takes an ILogger, a DbContext, and a IConfiguration from a DI container has no logic in its constructor, just three field assignments. The primary constructor form expresses that with no ceremony.
  • Wrapper types. A class that adapts one interface to another, or wraps a value with helpers, is usually a thin shell over its constructor arguments.
  • Strategy implementations. A DiscountService(IDiscountStrategy strategy) that delegates to the strategy is one method long, and the primary constructor is the shortest way to write it.
  • Configuration-shaped types where you do want behavior. Records cover the "pure data with value equality" case. A primary constructor on a regular class is the right pick when you want immutable-feeling data with behavior methods, but don't want records' equality semantics.

Stick with explicit constructors when:

  • The constructor needs more than two or three lines of logic (validation, derived field computation, side effects). The trick of cramming that into property initializers gets ugly fast.
  • You have several genuinely different constructor overloads that each do different setup work, not just default-value variants of one main constructor.
  • The class is large and the constructor parameters are far from where they're used. Putting (IRepository repo, ILogger log, IClock clock) on the header of a 600-line file makes it harder to scan, not easier.

A useful contrast with records: records exist for data (value equality, immutable, "two instances with the same fields are the same thing"). Primary constructors on classes exist for behavior (reference identity, methods that do work, "this is an object that holds onto its dependencies"). The first decision is record-or-class, based on data-or-behavior. If you pick class, then the primary constructor is a syntax choice within that decision.

The diagram captures the two decisions in order: data versus behavior first, then primary-constructor versus explicit-constructor inside the class branch.

A Realistic Example: Dependency Injection

Most C# code in 2024 onward uses dependency injection. The pattern is so common that "constructor that takes services and stashes them in fields" is by far the most-written constructor shape. Primary constructors collapse it.

CheckoutService is three lines of useful code (one parameter list, one method body) and zero lines of constructor boilerplate. The two dependencies are captured into hidden fields, the Quote method reads them, and that's the entire class. Compare against the pre-C# 12 version, which would have needed two field declarations and a three-line constructor to do the same work.

This is where primary constructors pay off the most in practice: classes that exist to wire dependencies and expose one or two behaviors. The DI container builds the dependencies and hands them to the constructor; the primary constructor stores them; the methods use them. No fields to declare, no constructor to write.

Quiz

Primary Constructors Quiz

10 quizzes