Published on

Primary Constructors and Their Impact on Classes and Structs in .NET 8

10 min read
Authors
  • avatar
    Name
    Ivan Gechev
    Twitter

In this post, we'll take a look at a brand new feature released with C# 12 and .NET 8. It's called Primary Constructors - a thing we already have for Records but that is now extended to Classes and Structs.

Records and Their Primary Constructors

Primary constructors for record types have been around for a long time. Let's take a look at how they work:

Dimensions.cs
public record Dimensions(int Width, int Height);

With this simple line, we create a record type called Dimensions. For the Width and Height, the compiler will create two properties behind the scenes that we can later utilize.

This is a very simple, yet vital feature. With it, we can save time and effort when creating Records.

Primary Constructors for Classes and Structs

Before we see how primary constructors work for classes and structs, let's take a look at a simple, old-fashioned class:

BankAccount.cs
public class BankAccount
{
    private readonly string _accountNumber;

    public decimal Balance { get; set; }

    public BankAccount(string accountNumber, decimal balance)
    {
        _accountNumber = accountNumber;
        Balance = balance;
    }

    public void Deposit(decimal amount)
    {
        Balance += amount;
        Console.WriteLine($"Deposited {amount:C} to {_accountNumber}. New balance: {Balance:C}");
    }

    public void Withdraw(decimal amount)
    {
        if (amount <= Balance)
        {
            Balance -= amount;
            Console.WriteLine($"Withdrawn {amount:C} from {_accountNumber}. New balance: {Balance:C}");
        }
        else
        {
            Console.WriteLine($"Insufficient funds om {_accountNumber}.");
        }
    }
}

We have a class called BankAccount. Its constructor takes in two arguments - the account number and the balance. For the account number, we have a private readonly field and for the balance, we have a dedicated property. We also have two methods for depositing and withdrawing money.

Now, let's make our class to utilize a primary constructor:

BankAccount.cs
public class BankAccount(string accountNumber, decimal balance)
{
    public decimal Balance { get; set; } = balance;

    public void Deposit(decimal amount)
    {
        Balance += amount;
        Console.WriteLine($"Deposited {amount:C} to {accountNumber}. New balance: {Balance:C}");
    }

    public void Withdraw(decimal amount)
    {
        if (amount <= Balance)
        {
            Balance -= amount;
            Console.WriteLine($"Withdrawn {amount:C} from {accountNumber}. New balance: {Balance:C}");
        }
        else
        {
            Console.WriteLine($"Insufficient funds om {accountNumber}.");
        }
    }
}

Just as with record types, the constructor arguments are moved to the very first line, where we create and name our class. Everywhere inside our class, we have access to the accountNumber and balance values, so we assign the Balance property directly. As the compiler will generate a field for each constructor argument, we can replace all references to _accountNumber with accountNumber and our code will work as expected. The rest of our class remains unchanged. And just like that, we have reduced our code length by eight lines.

Key Takeaways From Primary Constructors for Classes and Structs

Now that we have examined what the primary constructor is all about when it comes to classes and structs, let's note some important takeaways.

Primary Constructors and Validation Logic

Let's imagine we must make sure that the balance is a positive number and the account number is 10 characters long when creating an instance of the BankAccount class:

BankAccount.cs
public class BankAccount(string accountNumber, decimal balance)
{
    private readonly string _accountNumber = VerifyAndAssignAccountNumber(accountNumber);

    public decimal Balance
    {
        get => balance;
        set
        {
            ArgumentOutOfRangeException.ThrowIfNegative(value);
            balance = value;
        }
    }

    public void Deposit(decimal amount)
    {
        Balance += amount;
        Console.WriteLine($"Deposited {amount:C} to {_accountNumber}. New balance: {Balance:C}");
    }

    public void Withdraw(decimal amount)
    {
        if (amount <= Balance)
        {
            Balance -= amount;
            Console.WriteLine($"Withdrawn {amount:C} from {_accountNumber}. New balance: {Balance:C}");
        }
        else
        {
            Console.WriteLine($"Insufficient funds om {_accountNumber}.");
        }
    }

    private static string VerifyAndAssignAccountNumber(string accountNumber)
    {
        if (accountNumber.Length != 10)
        {
            throw new ArgumentException("Account number is invalid", nameof(accountNumber));
        }

        return accountNumber;
    }
}

We expand the setter for the Balance property and validate the passed-in value, making sure it is not a negative number using ThrowIfNegative() method. For the account number, we have to create a field again and either do an in-line validation, but I've opted for a static validation method instead. Now, our code has bloated and is becoming more complicated than if we used an old-fashioned constructor.

What Happens Behind the Scenes

It's essential to remember that no properties will be generated for the constructor arguments. Unlike record types, where we get properties, when we use Primary Constructors with classes we only get fields. This is why we use Snake Case for classes and structs' arguments and Pascal Case for the record's.

The primary constructor doesn't change things when it comes to equality for classes and structs - we still have reference-based equality.

Code Formatting and Primary Constructors

We've all gotten used to formatting our code in a certain way. But let's look at the following example:

IBankAccountService.cs
public interface IBankAccountService
{
}

We have an empty IBankAccountService interface just for illustration purposes.

BankAccountService.cs
public class BankAccountService(List<BankAccount> bankAccounts) : IBankAccountService
{
    public List<BankAccount> BankAccounts { get; set; } = bankAccounts;
}

We also have a BankAccountService that implements the IBankAccountService interface and uses a primary constructor. With one argument this is somewhat tolerable on a single line, but what happens if we have multiple constructor arguments? How we split the lines:

BankAccountService.cs
public class BankAccountService(
    List<BankAccount> bankAccounts, 
    Accountant accountManager,
    Accountant accountSupervisor) : IBankAccountService
{
    public List<BankAccount> BankAccounts { get; set; } = bankAccounts;
    public Accountant AccountManager { get; set; } = accountManager;
    public Accountant AccountSupervisor { get; set; } = accountSupervisor;
}

Do we split them like this and leave the interface on the last line?

BankAccountService.cs
public class BankAccountService(
    List<BankAccount> bankAccounts,
    Accountant accountManager,
    Accountant accountSupervisor)
    : IBankAccountService
{
    public List<BankAccount> BankAccounts { get; set; } = bankAccounts;
    public Accountant AccountManager { get; set; } = accountManager;
    public Accountant AccountSupervisor { get; set; } = accountSupervisor;
}

Or do we have the interface on yet another new line? This might seem like a trivial problem but we still need a convention (at least on a company or team-level) on how to format our code. If it was up to me, I'd be inclined to go with the first option

Conclusion

The introduction of Primary Constructors in C# 12 and .NET 8 marks a significant enhancement, extending a feature previously exclusive to records to now include classes and structs. This addition streamlines code by simplifying the creation and initialization of objects, reducing verbosity and improving overall readability. However, it's crucial to recognize that while this feature offers notable benefits, its usage should align with the specific requirements of the application, considering factors like validation logic and code formatting conventions.