Dark side of the primary constructors in C# 12

Marek Sirkovský
9 min readDec 17, 2023

--

Photo by Daniel McCullough on Unsplash

C# introduces innovative features that enhance the language’s expressiveness and productivity with each new iteration. One such feature that has gained attention in recent weeks is the primary constructor. Since primary constructors have sparked controversy, let’s see if they are a good step toward a brighter future for C#.

If you are unfamiliar with primary constructors (PCs), I recommend you look at the blog post by Henrique Siebert Domareski before continuing. In this post, I will discuss the various aspects of PCs instead of providing yet another introduction.

Where have the primary constructors come from?

The primary constructor is not a new concept at all. The idea of integrating constructor parameters directly into the class declaration has been present in various forms in earlier programming languages. For instance, languages in the ML family, such as Standard ML and OCaml, have features that resemble this concept, though they are not object-oriented in the traditional sense.

Scala, first released in 2003, popularized this style of constructor declaration in the context of a hybrid functional/object-oriented language. Kotlin, which came later and first appeared in 2011, adopted various features from Scala, including the approach to constructors.

It’s also important to say that different languages use different ways to implement primary constructors. The team working on the C# language has decided to introduce class parameters. Let’s explore how this concept of class parameters applies to both the language itself and the software industry.

Quick recap

public class User(int id, string name)
{
public string NameWithId => $"{name} - {id}";
}

The name and ID identifiers are just parameters, not fields or properties. This decision strengthens one of the loudest arguments against PC:

The constructor has different behavior for records and classes.

public class UserClass(int Id, string Name, string Email);

public record UserRecord(int Id, string Name, string Email);

var c = new UserClass(1, "John", "john@example.com");
var r = new UserRecord(1, "John", "john@example.com");
r.Id; // is a public property
c.Id; // compiler error

Although the syntax is alike, it’s apparent that the behavior of the two is entirely different.

Is this behavior of the new constructor wrong?

The C# team provides several arguments to support the current behavior of the PCs:

  • A record is a data carrier and a highly opinionated type. The phrase highly opinionated is crucial here. Regular classes are not only data carriers but much more general than records.
  • C# developers should consider the record’s primary constructors as a special case of non-record primary constructors rather than the reverse. In a perfect world, the C# team would have introduced primary constructors for classes before records.
  • The last strong argument for primary constructors is the claim that the current functionality is just a first step. For example, although static primary constructors or a constructor body are not currently supported, they could be implemented in the future.

Basically, the C# team wants to get feedback before committing to other PC features. It seems a pretty logical decision. However, these arguments still don’t answer the original question: Is this behavior of the new constructor wrong? In my opinion, yes, it is.

Let’s see what the common counter-arguments are.

More error-prone

C# doesn’t support read-only parameters. There is a proposal to add read-only parameters, but we do not have them available now. Microsoft has even changed its dependency injection guideline to use mutable parameters instead of read-only fields. I believe it is a step backward.

Moreover, it’s just the first part of the problem. The naming convention for class parameters is the same as for standard method parameters or variables. It’s easy to confuse class parameters with variables and accidentally change their value, especially without read-only modifiers.

public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
}

// The safer and explicit way of using dependency injection
public class MyControllerOld
{
private readonly ApplicationDbContext _context;

public MyController(ApplicationDbContext context)
{
_context = context;
}

[HttpGet]
public User[] Users()
{
// Underscore indicates a private field.
// Very often, it's a read-only private field.
var users = _context.Users.ToList();
return users;
}
}

// The primary constructor way of using dependency injection
public class MyControllerNew(ApplicationDbContext context)
{
[HttpGet]
public User[] Users()
{
// the identifier "context" can be a variable or parameter name,
// and it might even be changed.
var users = context.Users.ToList();
return users;
}
}

To be honest, I really like using field prefixes such as an underscore in C# or the “this.” annotation in TypeScript. They are pretty handy. You can argue that an IDE can indicate whether an identifier is a class parameter or a variable. However, we don’t have the same functionality in Azure DevOps or GitHub when reviewing pull requests.

These factors increase the likelihood of introducing bugs and make troubleshooting more difficult.

Initialization vs. capture

It’s also helpful to understand the difference between using the primary constructor for capture and for initialization. Let’s look at the lowered code since it seems to be one of the simplest ways to explain these two concepts.

Let’s start with the capture.

public class User(string email)
{
// Capture
public string Email => email;
}

// Lowered C#:
public class User
{
[CompilerGenerated]
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string <email>P;

public User(string email)
{
this.<email>P = email;
base..ctor();
}

public string Email
{
get
{
return this.<email>P;
}
}
}

As you can see, the C# compiler converts the primary constructor into one hidden(unspeakable) backing field, <email>P, and uses the field in the property Email.

Note: you can’t reference the “<email>P” field. It’s impossible in C# as it’s effectively hidden from you. You can’t even access these fields through reflection.

That was capture. What about initialization?

public class User(string email)
{
// Initialization
private string _email = email;
}

// Lowered C#:
public class User
{
private readonly string _email;

public User(string email)
{
this._email = email;
base..ctor();
}
}

As you can see, initialization is simpler. There are no new hidden fields, just a good old C# constructor.

Why is it essential for us to be aware of this?

Do you remember that I said that class is a general type, less opinionated than a record? Although still valid, the C# team holds some opinions about the proper use of the class parameters.

As a result of that, the following code produces the brand new warning:

public class User(string email)
{
public string Email { get; set; } = email; // initialization
public override string ToString() => email; // capture
}

The compiler shows: “The parameter ‘string email’ is captured into the state of the enclosing type, and its value is also used to initialize a field, property, or event.”

The C# team thinks mixing up the initialization and capture is not a good idea since it might lead to potential bugs, as you can see in the following code:

public class User(string email)
{
public string Email { get; set; } = email; // initialization
public override string ToString() => email; // capture
}

var user = new User("email@gmail.com");
user.Email = "email@outlook.com";
Console.WriteLine(user.Email); // returns email@outlook.com
Console.WriteLine(user.ToString()); // returns email@gmail.com

Rather perplexing. The funny thing is that the following code also throws the same warning:

public class User(string email)
{
// Initialization
public string Email1 { get; } = email;
// Capture
public string Email2 => email;
}

Those familiar with the semantic difference between expression body definitions(Email1) vs. auto-implemented properties(Email2) can spot the problem quickly, but for others, this warning might be confusing.

Don’t get me wrong, adding the mentioned warning was the right decision, but also indicates hidden complexity.

It reminds me of React Hooks, a powerful feature but dangerous if not used correctly. The React core team has provided warnings for when you deviate from the designated path. But there is a main difference between React Hooks and primary constructors. React hooks have brought immense value, while the primary constructor is just a minor syntax sugar to reduce boilerplate code.

Keep C# as a language with mutability by default

Introducing primary constructors has also opened the question of immutability in C#. I think mainstream languages tend to incorporate immutable concepts these days. For example, records and frozen collections.

Actually, when records were introduced, I was happy because it looked like the immutability had become the first-class citizen in C#. However, some GitHub comments arguing in favor of the primary constructor suggest that C# is a mutable first language and will remain as such. I hoped for native discriminated unions but got the new mutable class-scoped identifiers. You can imagine that didn’t lighten up my day :).

End of idiomatic C#?

Now, let’s speculate a bit. Have you heard that Scala is considered a hybrid language? What is that?

According to Scala documentation:

Scala lets you write code in an object-oriented programming (OOP) style, a functional programming (FP) style, and even in a hybrid style, using both approaches in combination.

My question is: which one of these approaches would you choose? Which one is the correct one? That’s the problem with Scala. It doesn’t have one standard set of patterns. When a Scala developer joins a new team, they might encounter Scala code that differs significantly from what they were coding in their previous company.

I’m talking about this because I’m a bit worried that C# is starting to follow in Scala’s footsteps. Before the primary constructor, we all know what is the idiomatic version for constructor dependency injection:

Idiomatic version before .NET 8:

public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;

public HomeController(ILogger<HomeController> logger)
{
_logger = logger;
}
}

Now, we have multiple new options to accomplish the same in .NET 8.

// The shortest version
public class HomeController(ILogger<HomeController> logger) : Controller


// Not the shortest one, but a safer version.
// Unfortunately, logger and _logger are accessible in the whole class.
public class HomeController(ILogger<HomeController> logger) : Controller
{
private readonly ILogger<HomeController> _logger = logger;
}

// One crazy idea might be to deviate from the standard naming convention
// and have a name with an underscore.
// It's a parameter with the field naming convention.
public class HomeController(ILogger<HomeController> _logger) : Controller

I believe you can come up with several more, and I would understand why C# purists might opt for a safer alternative over a shorter one.

Tooling

Tooling is a less important aspect of the new constructor. You can skip this section if you don’t use R# or Rider.

I noticed that the tooling could be better. R# and Rider offer this suggestion:

Transform this code:

public class Person(string email)
{
private readonly string _email = email;
public string Email => _email;
public string Test = email;
}

To the following code:

public class Person(string email)
{
public string Email => email;
public string Test = email;
}

However, this modification will result in the warning that was previously mentioned.

Parameter ‘string email’ is captured into the state of the enclosing type and its value is also used to initialize a field, property, or event.

JetBrains products transformed the code from pure initialization to initialization and capture. It’s not a good suggestion. It makes me think that the community will need more time to determine the proper usage patterns for primary constructors.

And the main question: should we avoid using primary constructors?

No, not at all. The primary constructors will definitely find their sweet spot. Actually, aside from reducing boilerplate, using PCs may lead to cleaner code.

I feel that fields and read-only modifiers help me to keep my codebase safer and readable. If I had to use only primary constructors, I’d lose some sense of safety, and it would probably make me write smaller classes.

Many developers can think the same. They might be more sensitive to the size of the classes without clearly indicating that you are dealing with a field-like identifier and without a safe readonly keyword. It feels like a paradox: sacrificing some safety measures might lead to a more maintainable code. I’m unsure if it will happen, but it would be the ideal outcome for adopting primary constructors.

Final thoughts

Let’s finish with a quote from the book Code That Fits in Your Head by Mark Seemann:

Software engineering should be the deliberate process of preventing complexity from growing.

How does the phrase apply to primary constructors?

We’ve seen that primary constructors reduce the boilerplate, which brings its own complexity. Therefore, I would say they perform their job effectively.

On the other hand, the primary constructor introduces hidden complexities such as:

  • confusion between capture and initialization
  • the possibility of mutation where it has not been before
  • the mixing of identifiers for field-like state versus method parameters or variables.

Unfortunately, I don’t have any good advice for you. What type of complexity you would choose is up to you. I’ll definitely try to use the primary constructors to see where they fit and where they don’t.

--

--