Some C# classes start with several lines that do very little: declare a field, receive a dependency, then copy one into the other. Only after that do you reach the method you came to read.
Primary constructors can shorten that part. They arrived for ordinary classes and structs in C# 12, and you can use them in a project configured for C# 12 or later.
The syntax is easy to pick up. The part worth spending a few minutes on is what changes when you replace an existing constructor.
Start with a service we all recognize
Here is a small example. IPaymentGateway is our own application interface, not a provider SDK:
public interface IPaymentGateway
{
Task<string> GetStatusAsync(
string paymentId,
CancellationToken cancellationToken);
}
public sealed class PaymentService
{
private readonly IPaymentGateway gateway;
public PaymentService(IPaymentGateway gateway)
{
this.gateway = gateway;
}
public Task<string> GetStatusAsync(
string paymentId,
CancellationToken cancellationToken)
{
return gateway.GetStatusAsync(
paymentId, cancellationToken);
}
}
Nothing is wrong with this. The dependency is obvious, and the field is readonly.
With a primary constructor, the class can look like this:
public sealed class PaymentService(IPaymentGateway gateway)
{
public Task<string> GetStatusAsync(
string paymentId,
CancellationToken cancellationToken)
{
return gateway.GetStatusAsync(
paymentId, cancellationToken);
}
}
The input moves next to the class name. The separate constructor and field are gone. Creating the service still means passing a gateway, whether you do it yourself or let dependency injection do it.
For a class this small, the shorter version is easy to follow. There is one dependency and one thing the class does with it.
DI does not need a new registration
The registration stays familiar:
builder.Services.AddScoped<PaymentService>();
You still need a separate registration for your concrete IPaymentGateway implementation and its dependencies. A primary constructor does not choose a provider, create a missing service or change its lifetime.
The detail I would check before refactoring
Those two service examples are not identical in every way.
In the first, the gateway field is readonly. In the shorter version, gateway is a constructor parameter, and it can be reassigned inside the class. Calling it a dependency does not make it readonly.
If keeping that protection matters, keep the field:
public sealed class PaymentService(IPaymentGateway gateway)
{
private readonly IPaymentGateway paymentGateway =
gateway ?? throw new ArgumentNullException(nameof(gateway));
public Task<string> GetStatusAsync(
string paymentId,
CancellationToken cancellationToken)
{
return paymentGateway.GetStatusAsync(
paymentId, cancellationToken);
}
}
This also checks for null when the object is created. Use paymentGateway in the methods after that.
It is a little longer than the shortest version. That is fine. Saving three lines is less useful than keeping a rule clear.
It does not turn a class into a record
The brackets after the name can look familiar if you use records. But an ordinary class does not get public properties from those parameters.
public sealed class PaymentReference(string value)
{
public string Value { get; } = value;
}
public sealed record PaymentReferenceRecord(string Value);
The class explicitly declares Value. The positional record supplies its corresponding property and record behavior. Choose between them based on what the type should mean, not just which declaration is shorter.
Avoid keeping the same input twice
Here is a small mistake that is easy to make while editing:
// Avoid mixing these two ways of keeping the dependency.
public sealed class PaymentStatusReader(IPaymentGateway gateway)
{
private readonly IPaymentGateway paymentGateway = gateway;
public Task<string> ReadAsync(
string paymentId,
CancellationToken cancellationToken)
{
// This uses the parameter, not paymentGateway.
return gateway.GetStatusAsync(
paymentId, cancellationToken);
}
}
The field stores the reference, while using the parameter in a method makes the compiler keep that parameter as well. That means two stored references, not two gateway objects. The compiler warns about this pattern.
The fix is simple: use paymentGateway in the method, or remove the explicit field if you do not need it.
The same idea matters with inheritance. If a base class already stores a dependency and exposes it to derived classes, use that inherited member rather than also holding the constructor parameter in the derived class.
When I would keep the regular constructor
A constructor that only accepts a dependency is a good candidate for the shorter style. A constructor with several checks, assignments and decisions may be easier to read as a normal block.
I would also avoid converting a whole folder just to make the files look newer. Start with the class you are already changing. Read the result, check any readonly fields and compiler warnings, and see whether it actually became easier to follow.
If the class needs another constructor, that constructor must lead back to the primary one through a this(...) call. Once there are several ways to create the object, the regular layout may be clearer for the next person reading it.
My preference is simple: use primary constructors where they remove repetitive setup. Keep the regular constructor where it explains something useful. Both styles can sit comfortably in the same codebase.
Further reading
The examples are separate alternatives. Do not paste all three PaymentService declarations into the same namespace.