C# 12.0: primary constructors
C# 12.0 adds a new syntax: primary constructors. These enable us to define constructors using significantly less code for some very common construction patterns. In some scenarios primary constructors offer clear improvements, but they're not always the best choice (despite what Visual Studio's code analyzers seem to think). There are also some issues with the design of this feature that the C# team chose not to resolve in this version.
Primary constructors have been in the works for a while: they were originally supposed to go into C# 6.0, but time and resource constraints meant that Microsoft couldn't implement both interpolated strings ($"Strings with {expressions}"
) and primary constructors in that release. So primary constructors were dropped, but Microsoft has been wanting to reinstate them ever since. In fact, they started that process in C# 9.0 with record
types, which have always supported this syntax. C# 12.0 finally makes primary constructors an option on all class
and struct
types.
Typical primary constructor usage
Often, a constructor for a class
or struct
just sets some fields. Here's a typical example:
internal class UserManager
{
private readonly ILogger<UserManager> logger;
private readonly IConfiguration configuration;
private readonly IdentityDbContext dbContext;
public UserManager(
ILogger<UserManager> logger,
IConfiguration configuration,
IdentityDbContext dbContext)
{
this.logger = logger;
this.configuration = configuration;
this.dbContext = dbContext;
}
...
}
This is tedious to write, because the code states the name of each input four times over: once as a field, once as a constructor parameter, and twice in the statement that assigns the constructor argument into the field. We've also had to state the type twice for each input—once as a field, and once as a constructor parameter.
If we had used a primary constructor, we could write just this:
internal class UserManager(
ILogger<UserManager> logger,
IConfiguration configuration,
IdentityDbContext dbContext)
{
...
}
If you've written any record
types, this will look familiar: a constructor parameter list can appear directly after the type name. The behaviour is not quite the same though. With a record
, the C# compiler generates a public property corresponding to each constructor parameter. When you use this primary constructor syntax with a class
or struct
, no corresponding public members are created. There's no visible external difference between a type with a primary constructor and a conventional one.
The primary constructor parameters are all available to code inside the type. So if you had an existing type you could switch from the old syntax shown in the first example to the primary constructor syntax shown in the second example, and you might not need to make any other modifications. Code that was previously using the fields declared in the first example could now refer to the primary constructor parameters instead.
But why did I say that you might not need to make any other modifications?
That's no field
The example above uses a primary constructor to replace a load of field declarations and initializating assignment statements. So you might think that primary constructor parameters become fields. And if you looked at the generated code with a tool like ILDASM or ILSpy, you might well conclude that this is exactly what has happened. However, although the compiler does generate a field for each primary constructor argument in that particular example, that's an implementation detail (and it doesn't always happen).
Early versions of the primary constructor proposal did talk about fields. The feature was going to work much like in record
types, but with each primary constructor parameter generating a private field instead of a public property. However, during development of the feature, the C# team decided to change the definition. Primary constructor parameters are just parameter variables like they are on a normal constructor, but they happen to be in scope throughout the whole type. This resembles the way we can capture method arguments in nested methods:
public static int X(int id)
{
void UseCaptured() { id += 1;}
UseCaptured();
return id;
}
So logger
, configuration
and dbContext
are not fields in the 2nd example above. They are parameters that are in scope throughout the entire type. The compiler is free to use whatever implementation strategy it wants when capturing variables. (In practice, it emits a field for this example, and all the primary constructor examples I've shown so far work the same way, but a future implementation could change that.)
This "not a field" aspect of primary constructor parameters has consequences. If you were to write this.logger
in some member of UserManager
, it would work in the first example above, because that declares a field called logger
, but it won't compile in the example with a primary constructor. That's because this.logger
syntax explicitly states that we want to access an instance member, but logger
is a constructor parameter, and parameter variables are not instance members. (They might be implemented as fields, but as far as the C# specification is concerned, they're just parameter variables.)
Coding convention challenges
Making a distinction between a field and a type-scoped parameter variable might seem like splitting hairs when that parameter is backed by a field in practice. However, it creates a problem for developers who like their code to contain visual cues that distinguish instance members from locally scoped variables.
When trying to understand code, it is important to know which variables are local to the method, and which refer to fields. The latter are inherently more complex because they prevent an individual method from being understood in isolation. The behaviour of a method that reads a field is dependent on other methods that modified the field earlier; a method that modifies a field may change the behaviour of other methods that run later. Fields create temporal dependencies between methods, which requires more work to understand fully than use of local variables and normal method arguments.
This is why some developers like to use a convention that makes field usage stand out. Some codebases prefix fields with an underscore, meaning that whenever you see something like _count
in the code, you know you need to understand it in the context of other methods that might be using the same field. Some people find this insufficiently visible, and would write m_count
. Some people find these prefixes ugly but still want use of non-local variables to stand out, and therefore enforce a coding convention of writing this.count
.
Some developers dislike these visual intrusions enough that they prefer to keep local variables and fields entirely indistinguishable. (My personal opinion is that this makes code harder to maintain, but since there are people out there who prefer to use var
for all local variable declarations, I realise I'm never going to convince the world of this fact.) Those developers will encounter no friction in moving from the first to the second example above.
But what are we to do if we want a clear visible distinction between local and type-scoped variables in a type with a primary constructor? The this.logger
convention is not an option: it just doesn't work, because the specification defines these things as constructor parameters, not fields. (You could perhaps argue that these are not like normal parameters because they are in scope across the entire type, and are available for any instance member to use, and that therefore it would make sense to be allowed to write this.logger
. But this is not in fact allowed.)
If you use a naming convention to distinguish fields from parameters, you could just apply that same naming convention to primary constructor parameters. E.g., if you were to write this:
public class UserManager(
ILogger<UserManager> _logger,
IConfiguration _configuration,
IdentityDbContext _dbContext)
it would be clear whenever you use these that they are not locals. However, this is unsatisfactory because parameter names are a publicly visible feature of any public constructor or method. Generated documentation will include the underscore in the parameter name, and it will be visible in the popups that appear in IDEs to show documentation. The least bad option might just be to use normal parameter naming conventions, and live with the lack of visual distinction.
There is a workaround, but first, we should look at a related issue.
Not readonly
Method and constructor parameters in C# are modifiable—method bodies have always been free to assign new values into any parameter variable. If it is your intention never to modify the value of a parameter variable inside a method body, there is nothing you can do in C# today to tell the compiler. You can mark a field as readonly
, in which case the compiler will prevent you from writing code that changes it after initialization, but there's no equivalent for parameters.
This hasn't been a big problem before now. The impact of assigning a new value into a parameter variable has hitherto been limited to a single invocation. If it's a ref
parameter, this assignment will affect the caller, but it would not have a lasting effect across the whole type instance. But with primary constructor parameters that's no longer true—these things have a scope and lifetime that's the same as a type's fields (i.e., the whole instance for its whole lifetime). We might reasonably want the same safeguarding that readonly
offers for fields. But we can't have that today.
This is related to the issue I described in the preceding section, in that parameters have two sides to their identity. To the consumer of a class, parameters describe the arguments that must be supplied to a constructor or method. On the inside of a constructor or method body, parameters are variables. So they simultaneously describe part of the public face of a type, and also part of its implementation. That's why we end up with the naming dilemma described in the preceding section. It's also one reason we currently don't have a way to declare that we don't intend to modify a parameter variable.
The C# team did think about this, and it was an issue that many people raised during the preview of the primary constructors feature. Future language versions might address this, but since the team could not agree on a good design for solving this problem, they decided to wait until people had more real experience with primary constructors.
It's the dual-sided identity of parameters that makes this hard. If we add some sort of annotation to a parameter, today that typically tells the caller something about what the method (or constructor) needs. E.g., a ref
annotation indicates that an argument must be passed by reference. In fact, you can already use the readonly
keyword today in this way:
static void CantModify(ref readonly int x)
This declares that the argument is to be passed by reference, but that the method will not be permitted to modify the variable that the reference refers to. (It may be more common to use in
to signify this, but the docs explain that there are a few scenarios in which ref readonly
is necessary.) But although this means the method can't change the value of the variable that x
refers to, it is still free to change which variable x
refers to. (This would have no impact on the caller.) In other words, the readonly
modifier makes this illegal:
x = 42; // Will not compile because x is ref readonly
but even if we can't change the value of the thing it refers to, we are still allowed to change which thing x
refers to, :
x = ref SomeOtherIntFieldOrVariable; // This is allowed
So today, when readonly
appears against a parameter, it provides information to code calling the method. (Callers of CantModify
can be confident that it won't be able to modify the value of the variable to which they pass a reference.) They don't provide a way to define implementation constraints that have no impact on the caller (i.e., nothing akin to a readonly
field).
It's not too hard to imagine a syntax that could work:
// Does not work in C# 12.0
public class X(readonly int id)
...
However, this crosses a significant line: this would introduce modifiers that aren't part of the public face of the type. The readonly
here wouldn't be part of the public signature of the primary constructor. (Code that invokes methods or constructors won't care whether the code being invoked changes the value of a parameter variable, because that would be an implementation choice—there's nothing you can observe in the externally visible behaviour of a type that would let you know that it had done this. Assignments to a parameter only affect the caller for ref
or out
parameters.)
This would open a can of worms. It's a can of worms that the C# team has already attempted to open once, but they soon decided to put the lid back on. They sadly abandoned the parameter null checking feature that was at one point intended to go into C# 11.0. There were a few reasons for this, but it certainly confused some people by introducing a parameter syntax that only changed an implementation detail. (I followed the discussion around this feature with interest, and one of the most disappointing aspects of that was how often the objections raised against this feature were based on a misunderstanding of what it did. But although I was frustrated that the feature was essentially blocked by people who hadn't fully understood it, the fact that so many people did misunderstand it proved that the feature was confusing in practice, no matter how useful it might have been.)
The proposed null checking syntax had the excellent nuance that the positioning of the relevant annotation made it clear that it modified the parameter variable (the thing we have access to inside the method body) and not the other part of a parameter's dual identity, its role as part of the method signature. That's why the null checking syntax was going to look like void X(string v!!)
. By putting the modifier after the variable name, this made it clear that this was not a statement about the type of the parameter. (One of the things I found so frustrating about the feedback that led to this feature's demise is that many of the loudest voices complained that the !!
was, in their view, in the wrong place, thus demonstrating that they hadn't understood the design of the feature. But I digress.)
That suggests that if we want to be able to state that a primary constructor parameter should, as an implementation detail of the type, be read-only, the modifier belongs after the identifier, e.g.:
// This also doesn't work, and is unlikely to be a popular suggestion
public class X(int id readonly)
Although there's a kind of logic to it (and it would have aligned with the precedent set by the !!
syntax, if that had survived), I have to admit it looks pretty odd. And there's a significant reasonable objection to it: this is not how we normally indicate such things. Here's the non-primary-constructor way of expressing what we want:
public class X
{
private readonly int id;
public X(int id) { this.id = id; }
}
Since we write readonly int id
in the field declaration, we might reasonably expect to write exactly the same syntax in a primary constructor.
But as I said earlier, we're opening a can of worms here. If the argument is that primary constructor syntax should look the same as field syntax (even though these thing are parameters, not fields) then why stop at readonly
? Mightn't we want to write this:
// Also does not work in C# 12.0
public class X(protected readonly int id)
...
People suggested exactly that while primary constructors were in preview. But why stop there? Since primary constructors in record
types are able to generate properties, mightn't we also want access to that feature in a class
?
// Another thing that does not work in C# 12.0 (which is probably for the best)
public class X(public int Id { get; set; })
...
Another exit path for the worms in this particular can is the need to consider what any extensions to primary constructor parameter syntax might mean for normal methods. Suppose we stopped at my earlier example in which we could indicate that the parameter variable should be readonly. Arguably this syntax shouldn't just be for primary constructors. We could reasonably expect to do this:
// If a primary constructor can do this (not that it can in C# 12.0), then
// why can't I?
public static void (readonly int x)
This might well be where a future version of C# ends up, because plenty of people have already asked for exactly this feature for ordinary methods. It looks a whole lot more interesting with primary constructors, and that might be enough to justify this feature. But this also makes this a bigger feature—instead of just adding a new kind of constructor, this would also be changing how methods can work. It might yet be done, but it was a reason for punting this to a future version.
Another reason this decision was punted to future language versions is that we do in fact have an escape hatch.
Taking control
If you want to use a primary constructor but you also want to control the name and access modifier of the variable holding an argument's value, you can do that:
// This works
public class X(int id)
{
private readonly int _id = id;
}
Or if you don't like the _
prefix but you like to to signify the use of class-scoped variables with this.
you can do that:
// This works too
public class X(int id)
{
private readonly int id = id;
public override ToString() => this.id.ToString();
}
Or if you wanted a property, that's also an option:
// This also works
public class X(int id)
{
public int Id { get; private set; } Id = id;
}
There's an obvious question: do these end up with two copies of the value? Does the first example have fields for both _id
and id
? Does the property example give me two hidden fields, one for the constructor parameter and one for the property?
In these particular examples, the answer is no. It turns out that if you use a primary constructor argument only in property or field initializers, you won't have this problem. The compiler does not need to generate a hidden field to make these three examples work. (These initializers go into the generated constructor, where the id
parameter is available without needing to be captured.)
It's possible to end up with two fields though. This example initializes an _id
field with the id
parameter, but makes the mistake of also using that id
parameter in the ToString
method, forcing the compiler to generate a field to ensure that id
is still available by the time ToString
runs.
// This works
public class X(int id)
{
private readonly int _id = id;
public override ToString() => id.ToString(); // Oops.
}
If you use a primary constructor parameter in both ways like this, then you will end up with two fields. In fact the compiler warns you about this, so as long as you pay attention to compiler diagnostics, you are unlikely to fall into this trap.
So we do have complete control over the naming and protection if we want. And that was part of the rationale for not attempting to solve the readonly
primary constructor parameter problem today.
Primary constructors are always invoked, except when they aren't
Because primary constructor parameters are in scope throughout the type, it's important that the primary constructor runs. If it were possible to create an instance of a type without running its primary constructor (e.g., by using a different constructor) these parameter variables would be uninitialized. Normal method and constructor parameters are in scope only inside the method body, so code that accesses these parameter variables can only run after they have been initialized—there's no way for one of these parameter variables to be in scope but uninitialized. But primary constructor parameter variables are in scope for the whole type. To ensure that primary constructor parameters variables are always initialized by the time that any code using them runs, C# has a rule stating that if type with a primary constructor also defines additional constructors, those must call the primary constructor. E.g.:
public class X(int id)
{
private static int NextId;
public X() : this(NextId++) { }
}
I can construct X
without using the primary constructor directly (new X()
). But because that other constructor is obliged to invoke the primary constructor, id
will be initialized.
Except there's a problem: what if this had been a struct
?
public struct X(int id)
{
private static int NextId;
public X() : this(NextId++) { }
}
We can create an instance of a struct
without invoking a constructor:
X x = default;
You might think that perhaps the compiler should enforce a rule that forbids this code if X
has a primary constructor. Or perhaps it should notice that a primary constructor is present, and emit a call to it in this case. But these ideas founder on the fact that there are more indirect ways of achieving the same effect. Elements of a newly created X[]
array will all be initialized to the default value without any constructor being run. Should we forbid the use of a struct
with a primary constructor in an array? That would also rule out List<X>
because that uses an array internally. That would be extremely limiting, but in any case how would it work? Primary constructors are just a new syntax for things we could do before, so if you have some struct
type in a library you've got no way of knowing whether it even has a primary constructor. So there would be no way to enforce such a rule.
We are stuck with the fact that any struct
can be instantiated without any of its constructors running. (As it happens, there are obscure means by which even reference types can be instantiated without construction. That relates to deprecated serialization features that are gradually being removed, so perhaps we can ignore them, but that doesn't change anything about struct
types.)
So there's a problem. The primary constructor specification avoids stating that the constructor arguments become fields (leaving that as an implementation choice for the compiler) and instead defines them as parameter variables that are in scope throughout the whole type. This creates a weird new phenomenon: an uninitialized parameter!
Before primary constructors, it was simply not possible for a parameter variable to be in scope and yet be uninitialized. The C# specification defines exactly when instances of variables of all kind come into existence, and before C# 12.0, these rules have always meant that an instance of a parameter variable receives its value (the argument supplied by the caller) before execution of code with access to that variable begins.
C# 12.0 breaks that. When a struct
defines a primary constructor, that constructor's variables are all in scope, but because the primary constructor hasn't necessarily run, they might be uninitialized.
Before C# 12.0, the language specification had no rules around uninitialized parameter variables because it was impossible for such things to exist. Back in the early days of the primary constructor feature's development, when it was defined explicitly in terms of fields, there was no problem, because there is a well-defined behaviour for uninitialized fields. When a struct
is initialized as default
, all its fields are either zero or a zero-like value such as null
or false
. But once the spec changed to make primary constructor variables look more like captured variables, it got a bit more weird.
In practice, they still just get initialized to zero-like values in these cases (although for a while, the specification didn't actually address this, so technically, the behaviour was undefined even by the time that C# 12.0 was released). If your mental model is "these are just fields really" then it will behave as you expect, even if that's not actually how the spec works.
When primary constructors are a mixed bag
If a constructor does nothing more than set a bunch of fields that correspond directly to the arguments, and if you don't mind about the naming or non-readonly
issues described above, primary constructors are a clear win. And even if you have to use the escape hatch because you do want control over these factors, e.g.:
public class X(int id)
{
private readonly int _id = id;
...
it's still more concise than this:
public class X
{
private readonly int _id = id;
public X(int id)
{
_id = id;
}
...
However, if your constructor does anything else at all, primary constructors don't look like an unequivocal improvement. For example, in the Rx.NET codebase (which endjin currently maintains) Visual Studio wants to replace this:
public sealed class AnonymousObservable<T> : ObservableBase<T>
{
private readonly Func<IObserver<T>, IDisposable> _subscribe;
public AnonymousObservable(Func<IObserver<T>, IDisposable> subscribe)
{
_subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe));
}
with this:
public sealed class AnonymousObservable<T>(Func<IObserver<T>, IDisposable> subscribe) : ObservableBase<T>
{
private readonly Func<IObserver<T>, IDisposable> _subscribe =
subscribe ?? throw new ArgumentNullException(nameof(subscribe));
I'm not convinced that's better. Maybe I'd get used to this style in time, but think it has made it less obvious that the constructor validates its argument. There is no explicit constructor body, and I now have to look at the field initializer to understand that there is actually some construction-time behaviour here beyond just initializing fields.
(If the !!
syntax had shipped, I'd actually quite like the primary constructor version of this, but sadly that didn't happen.)
Of course, field initializers have been able to introduce this kind of construction-time behaviour for years, so you could argue that we've always had to look at field initializers to understand construction behaviour. But I've always regarded it as bad practice to put anything non-trivial into a field initializer. I don't like the way primary constructors effectively force me to do that.
The more a constructor does, the more uncomfortable primary constructors make me. Visual Studio wants to change this:
private sealed class Periodic<TState> : IDisposable
{
private readonly IStopwatch _stopwatch;
private readonly TimeSpan _period;
private readonly Func<TState, TState> _action;
private readonly object _cancel = new();
private volatile bool _done;
private TState _state;
private TimeSpan _next;
public Periodic(TState state, TimeSpan period, Func<TState, TState> action)
{
_stopwatch = ConcurrencyAbstractionLayer.Current.StartStopwatch();
_period = period;
_action = action;
_state = state;
_next = period;
}
to this:
private sealed class Periodic<TState>(TState state, TimeSpan period, Func<TState, TState> action) : IDisposable
{
private readonly IStopwatch _stopwatch = ConcurrencyAbstractionLayer.Current.StartStopwatch();
private readonly TimeSpan _period = period;
private readonly Func<TState, TState> _action = action;
private readonly object _cancel = new();
private volatile bool _done;
private TState _state = state;
private TimeSpan _next = period;
I think this has buried the lede. Starting a stopwatch is the critical feature of this type, and the fact that this happens in construction is central to the usage model. Relegating that to a field initializer seems to me like an inappropriate de-emphasis of this fact. It's true that you can still see the code, and it is right at the top of the class, so maybe I'd get used to this, but I really don't like non-trivial behaviour in field initializers.
Visual Studio actually offers a variation on this primary constructor refactoring. If I choose the 'and remove fields' variant of the refactoring, we get this:
private sealed class Periodic<TState>(TState state, TimeSpan period, Func<TState, TState> action) : IDisposable
{
private readonly IStopwatch _stopwatch = ConcurrencyAbstractionLayer.Current.StartStopwatch();
private readonly object _cancel = new();
private volatile bool _done;
I prefer some things about this. This has managed to remove a lot of clutter (and that is the main selling point of primary constructors). That in turn means that in this version, the crucial call to StartStopwatch
is slightly easier to see. But what I dislike about this is that the state in this class now uses a mixture of unprefixed
primary constructor parameters, and _underscorePrefixed
fields. I could avoid the inconsistency by not using the underscore prefix, but the Rx.NET codebase is pretty big, and I don't think such a change is an especially productive use of time. In any case, I like a visual distinction between method-scope and type-scope variables, so I don't really want to move away from the underscore prefix convention just to make primary constructor refactorings turn out better.
In practice, as we've looked at all the places Visual Studio suggests we use primary constructors in the Rx.NET codebase, we've found that we want to consider each case on its merits. Sometimes it's an improvement, but sometimes we decide we prefer the old syntax.
We have therefore disabled the IDE0290
compiler diagnostics that suggests using primary constructors. We've found that we disagree with a large number of its suggestions, so it just creates unhelpful noise.
And that sums up how I currently feel about primary constructors. There are some situations in which I like them, but many that I don't.
When I use primary constructors
Primary constructors have been available for use with an LTS version of C# for almost a year now, and I still haven't established clear, objective criteria for when I will or won't use them. The dream would be a diagnostic suppressor that automatically silences the IDE0290 suggestion for cases where I know I definitely don't want a primary constructor, but I'm a long way from that.
What I can say is that for types small enough to fit on the screen, with constructors that do nothing but initializing fields, I like primary constructors. For example, this type in Rx.NET:
private sealed class VirtualTimeStopwatch : IStopwatch
{
private readonly VirtualTimeSchedulerBase<TAbsolute, TRelative> _parent;
private readonly DateTimeOffset _start;
public VirtualTimeStopwatch(VirtualTimeSchedulerBase<TAbsolute, TRelative> parent, DateTimeOffset start)
{
_parent = parent;
_start = start;
}
public TimeSpan Elapsed => _parent.ClockToDateTimeOffset() - _start;
}
becomes this:
private sealed class VirtualTimeStopwatch(
VirtualTimeSchedulerBase<TAbsolute, TRelative> parent, DateTimeOffset start)
: IStopwatch
{
public TimeSpan Elapsed => parent.ClockToDateTimeOffset() - start;
}
That seems like a clear improvement. (Although even here there's a small unsatisfactory aspect: I've not worked out how I want to format code when a primary constructor has lots of arguments in a type that implements interfaces or has a base class. I hate code that is wide enough to require horizontal scrolling, because you can't see what it does by just looking at it on the screen. So I often end up splitting parameter lists over multiple lines. But when you do that in a type that also has generic type arguments, a base class, and an interface list, there's so much going on before you get to the opening {
that it tends to look a bit cluttered and confused no matter what you do. Primary constructors are meant to reduce clutter and improve clarity, so that's not great.)
For more or less everything else, I'm either currently on the fence, or I positively dislike primary constructors. I'm not yet able to define clearly the dividing line between "maybe" and "definitely not". However, if a constructor has anything other than field initializers in it, then for me that usually seems to push it into the "definitely not" category.
Types that have constructors only for dependency injection are an interesting subset. These were exactly where I had expected to want to use primary constructors, because you can end up with a lot of laborious repetition, such as in this case:
public class WorkflowEngine : IWorkflowEngine
{
private readonly ILeaseProvider leaseProvider;
private readonly ILogger<IWorkflowEngine> logger;
private readonly IWorkflowInstanceStore workflowInstanceStore;
private readonly IWorkflowStore workflowStore;
private readonly ICloudEventDataPublisher cloudEventPublisher;
private readonly string? cloudEventSource;
public WorkflowEngine(
IWorkflowStore workflowStore,
IWorkflowInstanceStore workflowInstanceStore,
ILeaseProvider leaseProvider,
string? cloudEventSource,
ICloudEventDataPublisher cloudEventPublisher,
ILogger<IWorkflowEngine> logger)
{
this.workflowStore = workflowStore ?? throw new ArgumentNullException(nameof(workflowStore));
this.workflowInstanceStore =
workflowInstanceStore ?? throw new ArgumentNullException(nameof(workflowInstanceStore));
this.leaseProvider = leaseProvider ?? throw new ArgumentNullException(nameof(leaseProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.cloudEventPublisher = cloudEventPublisher ?? throw new ArgumentNullException(nameof(cloudEventPublisher));
this.cloudEventSource = cloudEventSource;
}
However, because of all that argument validation, the primary constructor version is not as clean as I'd hoped:
public class WorkflowEngine(
IWorkflowStore workflowStore,
IWorkflowInstanceStore workflowInstanceStore,
ILeaseProvider leaseProvider,
string? cloudEventSource,
ICloudEventDataPublisher cloudEventPublisher,
ILogger<IWorkflowEngine> logger) : IWorkflowEngine
{
private readonly ILeaseProvider leaseProvider = leaseProvider ?? throw new ArgumentNullException(nameof(leaseProvider));
private readonly ILogger<IWorkflowEngine> logger = logger ?? throw new ArgumentNullException(nameof(logger));
private readonly IWorkflowInstanceStore workflowInstanceStore =
workflowInstanceStore ?? throw new ArgumentNullException(nameof(workflowInstanceStore));
private readonly IWorkflowStore workflowStore = workflowStore ?? throw new ArgumentNullException(nameof(workflowStore));
private readonly ICloudEventDataPublisher cloudEventPublisher = cloudEventPublisher ?? throw new ArgumentNullException(nameof(cloudEventPublisher));
It's with examples like this that I most keenly lament the demise of the !!
feature. If that had made it, the full potential of primary constructors to simplify this kind of code would have been realised:
// If only...
public class WorkflowEngine(
IWorkflowStore workflowStore!!,
IWorkflowInstanceStore workflowInstanceStore!!,
ILeaseProvider leaseProvider!!,
string? cloudEventSource,
ICloudEventDataPublisher cloudEventPublisher!!,
ILogger<IWorkflowEngine> logger!!) : IWorkflowEngine
{
But we don't get to write that version, so the one before it is as good as it gets. For my tastes, it's only a bit less cluttered than the original code. Also, I don't really like the way that argument validation has moved into field initializers, because it seems less obvious to me that this is a bunch of tests performed when the primary constructor runs. If you decide that you can live without argument validation (and for types internal to your application, maybe you don't need it, because you can trust your dependency injection system to do the right thing) then you can actually simplify it to the last version (with the !!
removed).
Interestingly, there's a problem if you use the newer style of argument validation that became possible with C# 10.0 (which I generally prefer over the ?? throw
approach):
public WorkflowEngine(
IWorkflowStore workflowStore,
IWorkflowInstanceStore workflowInstanceStore,
ILeaseProvider leaseProvider,
string cloudEventSource,
ICloudEventDataPublisher cloudEventPublisher,
ILogger<IWorkflowEngine> logger)
{
ArgumentNullException.ThrowIfNull(workflowStore);
ArgumentNullException.ThrowIfNull(workflowInstanceStore);
ArgumentNullException.ThrowIfNull(leaseProvider);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(cloudEventPublisher);
this.workflowStore = workflowStore;
this.workflowInstanceStore = workflowInstanceStore;
this.leaseProvider = leaseProvider;
this.logger = logger;
this.cloudEventPublisher = cloudEventPublisher;
this.cloudEventSource = cloudEventSource;
}
The problem here is that Visual Studio can't even offer to turn this into a primary constructor. (In principle, Microsoft could make their primary constructor code analyzer recognize that this is effectively equivalent to the earlier example, but currently it does not.)
So even in the injection dependency scenario where I was expecting to want to use primary constructors, I only do so in cases where I'm comfortable not validating constructor arguments. That might be OK for internal types, but the example I've shown here comes from a library published on NuGet, and public types really should validate arguments. So I ended up not wanting to use a primary constructor here.
Conclusion
Primary constructors can save us from repetition in types with constructors that just initialize a lot of fields. In 'sweet spot' scenarios, they can significantly reduce clutter and improve clarity. However, if you need to do anything at all in your constructor, even just validating the arguments, you move out of that sweet spot, reducing the benefits. Certain stylistic preferences do not fit well with primary constructors. This includes certain naming conventions or other style conventions intended to make the use of type-scoped variables visually distinctive. And if you prefer construction-time logic to be obviously part of construction (e.g., you don't want argument validation code to move into field initializers), primary constructors might not suit your tastes. However, if your stylistic preferences happen to align with the constraints that primary constructors impose, you may well find that they enable you to declutter your code effectively.