Primary constructors and their surprises
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.