Primary constructors and their surprises

ยท 1 min read

Primary constructors on classes save a lot of boilerplate for dependency injection. Their parameters aren't readonly fields, though, and that has consequences.

C# 12 added primary constructors to classes and structs, and they're a natural fit for services that receive dependencies:

public class OrderService(OrdersDb db, ILogger<OrderService> logger)
{
    public async Task CancelAsync(Guid id, CancellationToken ct)
    {
        var order = await db.Orders.FindAsync([id], ct);
        logger.LogInformation("Cancelling order {OrderId}", id);
        // ...
    }
}

No private fields, no constructor body, no assignments. It's a clear improvement. But primary constructor parameters on a class behave differently from what you might assume.

1. They aren't readonly

Parameters are captured as mutable state. Nothing stops this:

public class OrderService(OrdersDb db)
{
    public void Reset() => db = null!;   // compiles
}

You'd never write that on purpose, but an accidental reassignment can slip through review. If immutability matters, assign the parameter to a readonly field:

public class OrderService(OrdersDb db)
{
    private readonly OrdersDb _db = db;
}

Then use only _db in the class body.

2. Using both the parameter and a field stores it twice

If you initialize a field from the parameter and also use the parameter elsewhere, the compiler stores two copies:

public class OrderService(OrdersDb db)
{
    private readonly OrdersDb _db = db;

    public Task SaveAsync() => db.SaveChangesAsync();   // warning CS9124
}

The warning tells you the parameter is captured as well as used to initialize a member. Pick one and use it consistently.

3. They're not properties

On a record, positional parameters become public properties. On a class or struct, they don't: they're only visible inside the type. Copying a record declaration into a class and expecting service.Db to work won't compile.

4. Validation needs a field initializer

There's no constructor body to put checks in. Use a field or property initializer:

public class RetryPolicy(int maxAttempts)
{
    private readonly int _maxAttempts = maxAttempts > 0
        ? maxAttempts
        : throw new ArgumentOutOfRangeException(nameof(maxAttempts));
}

Or write a normal constructor. Primary constructors are a convenience, not a requirement.

Where they fit best

  • Dependency injection in services, handlers, controllers and hosted services, where the parameters are just dependencies to call.
  • Simple types with no validation and no need for immutability guarantees.

For domain types with invariants, an explicit constructor with readonly fields is often clearer.

Takeaway

Primary constructors remove a lot of DI boilerplate. Remember that their parameters are mutable captured variables, not readonly fields or properties. Assign to a readonly field when that matters, don't mix the field and the parameter, and use a normal constructor when you need validation.