📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do primary constructors on classes and structs (C# 12) differ from those on records? Explain parameter capture, why parameters are not properties, the double-storage warning CS9124, mutation of captured parameters, validation, use with dependency injection, and the pitfalls you would flag in review.

C# 12 primary constructors for classes and structs: capture semantics, warnings, DI usage and review pitfalls.

Mid-Level CodeReview Intermediate code-review csharp-12 dependency-injection primary-constructors records

🎯 Key points

  • On records primary constructor parameters become init properties; on classes and structs they are only parameters in scope
  • Parameters used in members are captured into hidden mutable fields
  • Using a parameter in an initialiser and in a member stores it twice and triggers CS9124
  • Passing a parameter to the base constructor and capturing it triggers CS9107
  • Captured parameters cannot be readonly; copy to readonly fields if the guarantee matters
  • Validate in initialisers with throw expressions; other constructors must chain with this(...)
  • They suit dependency injection in services
  • default(T) for structs bypasses the primary constructor

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Records vs classes

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

2. Parameter capture

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
}

3. Why parameters aren't properties

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
}

4. Double storage — warning CS9124

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.

5. Mutation of captured parameters

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.

6. Validation

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.

7. Use with dependency injection

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(...).

8. Pitfalls I flag in review

  • CS9124 double storage (parameter used in members and in an initialiser).
  • Capturing parameters intended as construction-only (e.g. a large config object) — they live as long as the object.
  • Mutating captured parameters.
  • Missing validation for value parameters (no guard clauses).
  • Primary constructors on entities with validation needs or EF Core requirements (EF needs a usable constructor; parameter names must match properties for constructor binding).
  • Confusing records and classes (expecting obj.Param to exist).
  • Naming: parameters are camelCase but used like fields in members — some teams prefer _-prefixed explicit fields for clarity.

9. A production example

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.

🔍 What the interviewer is checking

The interviewer wants to see that you know the semantic difference from records and the real pitfalls.

Green flags

  • States that class primary constructor parameters are not properties.
  • Explains capture and the CS9124 double-storage problem.
  • Knows captured parameters are mutable and how to get readonly behaviour.
  • Uses them sensibly for DI and validates in initialisers.

Red flags

  • Thinks they generate public properties on classes.
  • Has never seen CS9124.
  • Uses them for every type regardless of invariants.

📚 Learn in depth

1. Lowering

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.

2. Structs

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).

3. Records recap

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).

4. Inheritance

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).

5. Analyser support

  • CS9124 (double storage), CS9113 (parameter unread), IDE0290 ("use primary constructor" suggestion).
  • Some teams add custom rules to forbid assignment to captured parameters.

Try it yourself

  1. Decompile a primary-constructor class in SharpLab and find the captured field.
  2. Reproduce CS9124 and observe divergence after changing the property.
  3. Write validation in a field initialiser.
  4. Register a primary-constructor service in DI and resolve it.

💻 Code

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
    }
}

⚠ Common mistakes

1. Expecting properties from class primary constructors

What happens: accessing service.repo from outside. Why it is wrong: only records generate properties. Do instead: declare explicit properties when needed.

2. Double storage (CS9124)

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.

3. Mutating captured parameters

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.

4. Missing validation

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.

5. Capturing large construction-only inputs

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.

6. Using primary constructors for complex entities

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.

7. Mixing records and classes mentally

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.

8. Inconsistent naming

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions this(...) chaining. Why: Checks constructor chaining and primary constructor basics.
    How to answer

    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.

  2. When: Candidate mentions injecting services. Why: Primary constructors are mainly used for DI.
    How to answer

    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.

  3. When: Candidate says parameters are captured into a hidden field. Why: Capture of primary constructor parameters resembles closure capture.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Stale balance shown after top-ups because of double storage
Situation
A prepaid card app's [wallet service] modelled accounts with a C# 12 primary constructor class; CS9124 warnings were not treated as errors.
Signals
Customers saw an old balance in notification messages right after successful top-ups, while the database and API showed the right value.
Investigation
The notification text used ToString(), which read the captured primary constructor parameter, while Deposit updated the Balance property. The build log contained CS9124 for that file.
Resolution
Changed ToString to use the property, removed other double uses, and promoted CS9124 and CS9107 to errors in Directory.Build.props.
Prevention
Added unit tests for ToString after state changes and a team convention to copy primary constructor parameters into readonly fields.

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.

More in Modern C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me