Where C# Primary Constructors Make Sense

December 08, 2023#Software Development
Article
Author image.

Steve Smith, Founder and Principal Architect

This article is part of the 2023 C# Christmas Calendar available here!

Primary Constructors

Last month, C# 12 introduced a new feature called Primary Constructors. Primary Constructors essentially allow you to add parameters to your class declaration, and those parameters are then in scope anywhere inside of the class. This feature is similar to how records have worked since their introduction. However, since classes are used differently than records, there are some new implications.

Syntax

Declaring class-level parameters in C# primary constructors looks like this:

public class Customer(string name)
{
    public string Name { get; } = name;
}

Scope?

If this is a new feature to you, one of the first questions you might ask is, what is the scope of name? Is it public/private/protected/internal? And if it’s really a private field, should I name it _name (if that’s my convention)?

Well, it’s none of those things. It’s a parameter. Just like a method parameter, or a parameter that’s passed into a constructor. The difference is, this parameter can be accessed - and mutated like any other parameter - from anywhere inside of the class. So you can do things like this:

public class Customer(string name)
{
  public string Name { get; }  = name; // compiler warning

  public string FullName => name;
  public void Format() => name = name.ToUpper();
}


// Console app
var customer = new Customer("steve");
customer.Format();
Console.WriteLine($"Name: {customer.Name}"); // steve
Console.WriteLine($"Fullname: {customer.FullName}"); // STEVE

Basically, once you call the Format method on Customer, any other reference to name will use the modified ToUpper version, because it’s mutable. Of course this means if you’re going to use it for dependency injection, it’s possible that any statement within the receiving class could reset the value or set it to null. Basically, except for initializing fields or properties, you cannot trust that the value of the parameter is what was passed in.

Validation

Generally, it’s a good idea to validate constructor arguments, so that your types aren’t instantiated in a bad state. Imagine if the DateTime type let you create it with a month value of 20 and a day value of 100. You’d have to constantly be checking to ensure the values of the DateTime were legal. But because it checks the inputs when you create it, you can be assured any DateTime value you access is an actual, possible DateTime, and not 35 Octember at 42 o’clock.

A typical place to do validation of inputs is in the constructor, using guard clauses. If you use the Ardalis.GuardClauses library, you can combine your guard check with the assignment to the local field, like this:

// constructor
public Customer(string name)
{
  Name = Guard.AgainstNullOrEmpty(name);
}

Now, you can do the same thing with the relatively new throw helpers on ArgumentException, but these are void methods, so you need multiple statements:

// constructor
public Customer(string name)
{
  ArgumentNullException.ThrowIfNullOrEmpty(name);
  Name = name;
}

Why does this matter? Well, if you’re no longer using constructor blocks and instead are using primary constructors, you can use the single line version like this:

public class Customer(string name)
{
  public string Name { get; private set; } = Guard.Against.NullOrEmpty(name);
}

By combining this with custom guard clauses, you can easily perform the necessary checks for any input as a single-line guarded assignment.

Dependency Injection

Constructor dependency injection remains the recommended way to create loosely-coupled services in dotnet apps today. And of course you can use primary constructors to accept dependent services just like a regular constructor. Thus code like this works fine:

public class DeleteContributorService(IRepository<Contributor> _repository,
  IMediator _mediator,
  ILogger<DeleteContributorService> _logger) : IDeleteContributorService
{
  public async Task<Result> DeleteContributor(int contributorId)
  {
    _logger.LogInformation("Deleting Contributor {contributorId}", contributorId);
    var aggregateToDelete = await _repository.GetByIdAsync(contributorId);
    if (aggregateToDelete == null) return Result.NotFound();

    await _repository.DeleteAsync(aggregateToDelete);
    var domainEvent = new ContributorDeletedEvent(contributorId);
    await _mediator.Publish(domainEvent);
    return Result.Success();
  }
}

In this code snippet, there are three class parameters being passed into the primary constructor. At runtime the DI container will provide these arguments just as you would expect. Because this class has only one method and no state, it’s an example of what I would call a trivial implementation, in which it might be acceptable to use primary constructor arguments directly outside of initialization.

However, this could become a slippery slope, so even here I’m not sure I love it.

Imagine that - naturally - another use case is required and this service seems like the place to add some more logic. The new class might look like this:

public class DeleteContributorService(IRepository<Contributor> _repository,
  IMediator _mediator,
  ILogger<DeleteContributorService> _logger) : IDeleteContributorService
{
  public async Task<Result> DeleteContributor(int contributorId)
  {
    _logger.LogInformation("Deleting Contributor {contributorId}", contributorId);
    var aggregateToDelete = await _repository.GetByIdAsync(contributorId);
    if (aggregateToDelete == null) return Result.NotFound();

    await _repository.DeleteAsync(aggregateToDelete);
    var domainEvent = new ContributorDeletedEvent(contributorId);
    await _mediator.Publish(domainEvent);
    return Result.Success();
  }

  public async Task<Result> RenameContributor(int contributorId, string newName)
  {
    _logger.LogInformation("Renaming Contributor {contributorId}", contributorId);
    var contributor = await _repository.GetByIdAsync(contributorId);
    if (contributor == null) return Result.NotFound();

    contributor.UpdateName(newName);

    if(true)
    {
      _mediator = null; // compiler warning but legal
    }

    await _repository.SaveChangesAsync();
    return Result.Success();
  }
}

So, there are a few things going on here.

First, if you call the Rename method first and then call the Delete method, you’re going to get a NullReferenceException because (for whatever reason) the Rename method set _mediator to null.

Second, because the class is getting longer, it’s no longer obvious where the class parameters are coming from. They’re named like private fields (_prefix), but they aren’t really. If you try to access them using this._repository it won’t work - it’s a parameter not a field.

The biggest problem with primary constructors and class parameters is that they’re mutable, and there’s no way to alter that behavior. You can’t mark them as readonly. You can’t mark them as init only (so they could only be referenced once, to initialize a field or property). There’s just not enough language-level support for constraining their behavior.

DTOs

Of course, if you don’t need any constraints, and all you need is some data to pass around, then primary constructors can work fine. You’re still going to want to assign them to mutable properties, though, since DTOs don’t need encapsulation:

// a DTO
public class CustomerRequest(int id, string name)
{
  public int Id { get; set; } = id;
  public string Name { get; set; } = name;
}

But at that point you’re probably better off just using a record:

public record CustomerRequest(int Id, string Name);

And this would give you record equality, a better default ToString implementation, and other record features.

Recommendations

Given the current implementation of C# primary constructors, here is my recommendation:

Only use primary constructor arguments for initialization of fields and properties. Avoid accessing them elsewhere (except maybe in trivial class implementations).

How should we name primary constructor parameters

Given that they’re parameters, and we’re only going to use them to initialize fields and properties (just like regular constructor parameters), they should be named just like they would have been on a regular constructor. That is, parameterName using camelCase.

There should be no difference between these two:

// class constructor DI 
public class FooService
{
  private readonly ISendEmail _emailSender;

  public FooService(ISendEmail emailSender)
  {
    _emailSender = emailSender;
  }
}

// primary constructor initialization
public class FooService(ISendEmail emailSender)
{
  private readonly ISendEmail _emailSender = emailSender;
}

Note that the second version is much shorter. There is a benefit to using primary constructors. But avoid over-using them and avoid referencing them anywhere in your classes except for initialization of class members (which is the essentially the only thing you should have been doing with constructor arguments prior to this feature).

Unless or until additional constraints are available for primary constructor parameters, they represent a footgun that developers can misuse anywhere in their classes where they’re not careful. Be sure to turn on TreatWarningsAsErrors so that you’ll catch any instances where you’re referencing class level parameters in addition to initializing them. In particular, look for CS9124 warnings.


Copyright © 2024 NimblePros - All Rights Reserved