C# 12 primary constructors for classes and structs: capture semantics, warnings, DI usage and review pitfalls.
On a record, primary constructor parameters become public init properties. On a class or struct (C# 12) they are just parameters in scope for the whole type body: no property is generated. If a member uses one, the compiler captures it into a hidden mutable field. That causes three review issues: captured parameters are mutable (no readonly), using a parameter both to initialise a field or property and directly in a member stores it twice (warning CS9124, which can lead to stale values), and passing a parameter to the base constructor and also capturing it gives CS9107. They are great for dependency injection of services. For values needing validation or a read-only guarantee, assign them to an explicit readonly field or property in an initialiser.
Primary constructors look the same on records and classes but behave very differently. On a record, record Money(decimal Amount, string Currency) generates public init properties, equality, deconstruction. On a class or struct (C# 12), class OrderService(IOrderRepository repo) just puts the parameters in scope for the whole type — no properties, no fields declared, and the compiler captures them into hidden fields only if members use them. I'd explain capture, why parameters aren't properties, CS9124 double storage, mutation, validation, DI usage and review pitfalls.
public record Money(decimal Amount, string Currency); // properties Amount, Currency are generated
var m = new Money(10, "AUD"); Console.WriteLine(m.Amount);
public class OrderService(IOrderRepository repo, ILogger<OrderService> logger) // NO properties generated
{
public Task<Order?> FindAsync(OrderId id, CancellationToken ct)
{
logger.LogDebug("Finding {OrderId}", id);
return repo.FindAsync(id, ct);
}
}
// new OrderService(repo, logger).repo -> doesn't exist
When a class member references a primary constructor parameter, the compiler generates a private field (with an unspeakable name) and rewrites references to use it. If parameters are only used in field/property initialisers, no capture field is generated — the value is used during construction only.
public class RateLimiter(int limit)
{
private readonly SemaphoreSlim _gate = new(limit); // used in initialiser only -> not captured
}
The C# team deliberately didn't auto-generate properties for classes: classes are often services or entities where you don't want every constructor input to become public API. Parameters are implementation details in scope. If you want a property, declare it explicitly:
public class Customer(string name)
{
public string Name { get; } = name; // explicit property initialised from the parameter
}
public class Customer(string name)
{
public string Name { get; } = name; // stored in the property's backing field
public string Greeting() => $"Hi {name}"; // captures 'name' AGAIN in a hidden field
}
Now there are two copies: Name's backing field and the captured parameter. If one changes (e.g. Name becomes settable and is updated), they diverge — Greeting() uses the old value. The compiler warns (CS9124: parameter is captured into the state of the enclosing type and its value is also used to initialise a field, property or event). Fix: use Name in members, not name.
Captured parameters are mutable (no readonly):
public class Counter(int start)
{
public int Next() => ++start; // legal - mutates the hidden field
}
That's surprising for DI services where you'd expect dependencies to be readonly; nothing stops repo = null!; inside a method. Teams that care declare explicit private readonly fields initialised from parameters, or rely on an analyser rule.
There's no constructor body to put guard clauses in. Options:
public sealed class Pager(int pageSize)
{
private readonly int _pageSize = pageSize is > 0 and <= 100
? pageSize
: throw new ArgumentOutOfRangeException(nameof(pageSize));
}
public sealed class EmailSender(SmtpOptions options)
{
private readonly SmtpOptions _options = options ?? throw new ArgumentNullException(nameof(options));
}
Or add an explicit constructor that chains to the primary constructor (public Pager() : this(20) { }) — but validation logic itself must live in initialisers. If validation is complex, use a normal constructor.
Primary constructors are great for DI services: less boilerplate, dependencies visible in the declaration. The DI container calls the primary constructor like any other. Caveats: captured dependencies are mutable fields; with multiple constructors, the primary constructor must be called by others via this(...).
obj.Param to exist)._-prefixed explicit fields for clarity.After converting services to primary constructors, a bug appeared: a service exposed public IClock Clock { get; } = clock; for tests and used clock internally. A test replaced Clock via reflection/mocking frameworks, but internal methods still used the captured clock — tests passed while production behaviour differed. CS9124 had been flagged as a warning and ignored. We made CS9124 an error and standardised on using either the parameter or an explicit readonly field, never both.
The interviewer wants to see that you know the semantic difference from records and the real pitfalls.
Green flags
Red flags
public class OrderService(IOrderRepository repo)
{
public Task<Order?> FindAsync(OrderId id, CancellationToken ct) => repo.FindAsync(id, ct);
}
// becomes roughly
public class OrderService
{
private IOrderRepository <repo>P; // captured, mutable
public OrderService(IOrderRepository repo) { <repo>P = repo; base..ctor(); }
public Task<Order?> FindAsync(OrderId id, CancellationToken ct) => <repo>P.FindAsync(id, ct);
}
Note the field isn't readonly. Field initialisers that use parameters run in the constructor before the base call, like normal initialisers.
Primary constructors on structs work similarly, and captured parameters become fields of the struct. default(TStruct) bypasses the constructor (fields zeroed). For readonly struct, captured parameters can't be mutated (fields are readonly).
Record primary constructors generate public init properties (get; set; for non-readonly record structs), plus equality, Deconstruct and PrintMembers over them. You can replace a generated property with an explicit one using the parameter: public string Currency { get; init; } = Currency.ToUpperInvariant(); (here the parameter, not the property, is referenced in the initialiser).
public class Base(string name) { }
public class Derived(string name, int age) : Base(name) { }
The base call is part of the declaration. Derived classes can't directly use the base's captured parameter (it's private to the base).
using Microsoft.Extensions.Logging;
public interface IOrderRepository { Task SaveAsync(Order order, CancellationToken ct); }
public sealed record Order(int Id, decimal Total);
// Good: DI with explicit readonly fields and validation in initialisers
public sealed class OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
private readonly IOrderRepository _repository = repository ?? throw new ArgumentNullException(nameof(repository));
private readonly ILogger<OrderService> _logger = logger;
public async Task PlaceAsync(Order order, CancellationToken ct)
{
await _repository.SaveAsync(order, ct);
_logger.LogInformation("Order {OrderId} saved", order.Id);
}
}
// Bug found in review: double storage (CS9124) gives stale values
public class AccountBefore(decimal balance)
{
public decimal Balance { get; private set; } = balance;
public void Deposit(decimal amount) => Balance += amount;
public override string ToString() => $"Balance: {balance}"; // warning CS9124: stale after Deposit
}
// Fixed: use only the property after initialisation
public class Account(decimal openingBalance)
{
public decimal Balance { get; private set; } =
openingBalance >= 0 ? openingBalance : throw new ArgumentOutOfRangeException(nameof(openingBalance));
public void Deposit(decimal amount) => Balance += amount;
public override string ToString() => $"Balance: {Balance}";
public Account() : this(0m) { } // other constructors must chain to the primary one
}
public static class Program
{
public static void Main()
{
var before = new AccountBefore(100m);
before.Deposit(50m);
Console.WriteLine($"{before.Balance} vs {before}"); // 150 vs Balance: 100
var after = new Account(100m);
after.Deposit(50m);
Console.WriteLine(after); // Balance: 150
}
}
What happens: accessing service.repo from outside.
Why it is wrong: only records generate properties.
Do instead: declare explicit properties when needed.
What happens: parameter used to initialise a property and also in members. Why it is wrong: two copies that can diverge. Do instead: use the property in members; make CS9124 an error.
What happens: assigning to a captured parameter inside methods.
Why it is wrong: surprising state changes; dependencies aren't readonly.
Do instead: explicit readonly fields when immutability matters.
What happens: value parameters used without checks. Why it is wrong: invalid objects can be created. Do instead: validate in initialisers or use a normal constructor.
What happens: a big options object used in a method keeps it alive. Why it is wrong: unnecessary memory retention. Do instead: extract what you need into fields in initialisers.
What happens: domain entities with invariants and EF requirements. Why it is wrong: limited validation and EF constructor binding constraints. Do instead: explicit constructors or factories.
What happens: assuming value equality on classes with primary constructors. Why it is wrong: classes keep reference equality. Do instead: use records for value semantics.
What happens: parameters used like fields across large classes. Why it is wrong: readers can't tell captured parameters from locals. Do instead: agree on conventions; keep primary constructors for small services.
Primary constructors are a boilerplate tool, not a design tool. They shine for DI-heavy services where the constructor only stores dependencies. They are weaker for types with invariants, because the language gives you no body and no readonly parameters.
| Type | Primary constructor? |
|---|---|
| Service with injected dependencies | yes, optionally copying to readonly fields |
| Record DTO or value object | yes (record semantics) |
| Domain entity with validation and invariants | usually a classic constructor or factory |
| Struct used in arrays | careful: default bypasses it |
A senior reviewer also sets team conventions once (for example "always copy to readonly fields" or "never assign parameters") and backs them with analyser severities, so the debate does not repeat in every pull request.
A constructor runs when an object is created and should leave it valid. If you declare none, the compiler adds a public parameterless one. Constructors can chain: : this(...) calls another constructor of the same class, : base(...) calls the base class's. For new Derived(), the order is: derived field initialisers, base field initialisers, base constructor body, derived constructor body. A static constructor runs once, before the type is first used, and is thread-safe. Primary constructors (C# 12) put parameters on the class declaration; they are captured as hidden fields, not properties, and are mutable. required members (C# 11) force callers to set them in an object initialiser. Calling a virtual method from a constructor runs the derived override before the derived constructor body has run, so it can see uninitialised state.
A dependency is the weakest relationship: class A uses class B, so a change to B may affect A, but A does not keep B as part of its state. It shows up as a method parameter, a local variable, a return type, a static call or a new B() inside a method. Association is stronger: A stores a reference to B. Direction matters: high-level policy (business rules) should not depend on low-level details (SQL, HTTP). The Dependency Inversion Principle says both should depend on abstractions owned by the high-level side. Dependency injection supplies those abstractions from outside (usually through constructors), so classes do not create or find their own dependencies, which makes them testable and replaceable.
A closure is a lambda or local function plus the variables it captures. The compiler moves those variables into fields of a hidden display class, so the method and the lambda share the same variable. That's why capture is by variable, not by value. In a for loop all lambdas share one loop variable, while foreach gets a fresh one per iteration since C# 5. Every lambda in a scope shares one display class, and capturing this keeps the whole object alive, which can leak memory through timers and caches. In hot paths I use static lambdas with explicit state.
How to tell it (STAR)
**Situation:** customers of our [prepaid card app] were getting top-up notifications with the old balance. **Task:** I was on support rotation and had to find why the message disagreed with the database. **Action:** I traced the message to a ToString method that read a primary constructor parameter which had been copied into a property and then captured again. The compiler had warned with CS9124 but we ignored warnings. I fixed the code and made those warnings errors across the solution. **Result:** the bug was fixed the same day, and [three] other latent cases were caught by the build immediately.
Last updated 30 Oct 2026.