DEV Community

Garry Taylor
Garry Taylor

Posted on

C# Records

Dotnet Records was introduced in 2020 with C#9 and highlighted a shift to Functional Programming within the language. However, in my experience, they have been under utilised and demoted to the league of DTO's, primitive obsession and anaemic classes. However, when we extending Records, the similarities to classes can mask the reasons for choosing Records.

What is a Record?

But first, lets identify what a Record is and why I think we are miss-using them.

Let's create an example, when building a Model to represent a Person, there are a number of Properties required. If the Model is rich, there may also be a number of relationships and navigational properties. Consider a Person, with a collection of Address items. Each Address has a Electric Meter owned by an Energy Provider.

Person
   FirstName: string
   Addresses: [Address]
Address
   ID: GUID
   MeterID: GUID
   InstallDate: Date
   ServiceDate: Date
   PostCode: String
   HouseNumber: String
Meter:
   ID: GUID
   ProviderID: GUID
Provider:
   ID: GUID
   Name: String
   ...
Enter fullscreen mode Exit fullscreen mode

Each Class has a collection of properties and relationships

Person -> Address[] -> Meter -> Provider
Enter fullscreen mode Exit fullscreen mode

Most C# developers are familiar with building these Models and they understand there is a lot of boilerplate code involved in creating these Classes. Mistakes within the Model can impact the overall projects for years to come.

Now let us take a look at a small example using traditional classes.

public class Person 
{
   public string FirstName { get; set; }
   public string Surname { get; set; }

  public Person(string firstName, string surname) 
  {
     FirstName = firstName;
     Surname = surname;
  }
}
Enter fullscreen mode Exit fullscreen mode

The code above has no purpose, other than storying some Data about a Person. This doesn't tell me anything about the business logic and there's no validation. These are blobs of Data used to pass around your code base.

public record Person(string FirstName, string LastName);
Enter fullscreen mode Exit fullscreen mode

Using Records, we can construct the same Model using a single line. Removing the boiler plate code and improve readability of what the Model holds.

Building out a complex Model becomes a simple process.

public record Person(string FirstName, string LastName, List<Address> Addresses);
public record Address(string ID, string MeterID, Date InstallDate, Date ServiceDate);
pubic record Meter(string ID, string ProviderID);
pubic record Provider(string ID);
Enter fullscreen mode Exit fullscreen mode

Here we can see the Model's relationships and it's Properties and reason about what it does. Simple wasn't it..!!???

Don't stop reading!! It's easy to think you understand Records now. But do you know how to validate, extend and where in memory your Model is stored? Records can be power houses when extended!
Enter fullscreen mode Exit fullscreen mode

Spot the Difference

There is more to Records than what meets the eye. First let's look at the core difference.

  • Records data can't be changed once instantiated
  • Records are compared using their values
  • Reduce boiler-plate code - auto generating Classes with rich functionality

Optional Parameters

Sometimes we don't know the value at initialisation but will know it at a later date. We therefore wish to use a Default value until this state is known.

record Person(string Surname, string FirstName, int Age = 21);
var me = new Person("Taylor", "Garry");
var you = new Person("Johnson", "Alan", 42);
Enter fullscreen mode Exit fullscreen mode

The code above will allow for a default Age of 21 (I wish). Adding the default will mean that this property becomes optional.

Build-in Display

Being able to visually see the content of a Class, including the values for sub types can be problematic. Especially for editors that don't make watching easy to use.
Luckily Records have a build in override for ToString that outputs the values.

record Person(string FirstName, string Surname);
var me = new Person("Garry", "Taylor");
Console.WriteLine(me);
Enter fullscreen mode Exit fullscreen mode

This will display

Person { FirstName = Garry, Surname = Taylor }
Enter fullscreen mode Exit fullscreen mode

Immutable

It's common in Functional programming to ensure data can not be changed. This ensures that side affects can't occur without being explicit.

var person = new Person("Garry", "Taylor", "example@gmail.com");
EmailService.SendEmail(person);
Enter fullscreen mode Exit fullscreen mode

Consider if the SendEmail changes the person instance to update a Property. It's impossible for a Developer to understand that this method call will mutate the data. This is a side-affect as the change is unexpected. No developer would be able to reason or understand how and why. They would be forced to navigate to the EmailService to get an understand of what has changed and guess as to the why.

As Records can't be changes, the developer writing the Email Service would have a compile time error. This should indicate a problem with this pattern and hopefully point them in a new direction. However, if they with to continue with the mutation, they must return a new copy of the Record. The original record can not be changed.

var person = new Person("Garry", "Taylor", "example@gmail.com");
person = EmailService.SendEmail(person);
Enter fullscreen mode Exit fullscreen mode

A new instance of the Person is returned from SendEmail, in this case we override the passed in person instance.

Constructor

We can see that a lot of boiler plate code is scaffolded for us. In fact the C# Generator has created the Class with all the properties set for us. These properties are set as public get; init.
However, we may want more control, for example setting the property of the Age to internal. However, with more power, comes more responsibility. We now have to make our own properties and assign their values within the constructor.

record Person
{
    public Person(string firstName, string surname, int age)
    {
        FirstName = firstName;
        Surname = surname;
        Age = age;
    }

    public string FirstName {get; init; }
    public string Surname { get; init; }
    internal int Age { get; init; }

}

var me = new Person("Garry", "Taylor", 21);
PrintParams(me);
Enter fullscreen mode Exit fullscreen mode

Note the syntax of moving the positional properties from the Record Symbol to the Constructor. This mimic how Classes use to be created. There is no point duplicating the properties within the Record Symbol and within the Constructor.

Declaration and Constructors

Having to produce all the parameters yourself can be reduced by mixing both declaration and constructor positional parameters.

record Person(string FirstName, string Surname)
{
    public Person(string firstName, string surname, int age) : this(firstName, surname)
    {
        Age = age;
    }

    internal int Age { get; init; }
}

var me = new Person("Garry", "Taylor", 21);
PrintParams(me);
Enter fullscreen mode Exit fullscreen mode

Within the code above, we have mixed the uses. Here we only wish to make the Age property internal. In doing so we can remove this from the record declaration and promote this to the constructor.

Comparison and Equality

Comparing Objects within C# typically have equality if they are the same instance and point to the same memory address location. However, similar to String, Record are equal if the values of the properties are the same.

var persona = new Person("Garry", "Taylor");
var personb = new Person("Garry", "Taylor");
var answer = persona == personb; // TRUE
Enter fullscreen mode Exit fullscreen mode

So the following will not compare

var persona = new Person("Garry", "Taylor");
var personb = new Person("Paul", "Taylor");
var answer = persona == personb; // FALSE
Enter fullscreen mode Exit fullscreen mode

Working with Classes will return FALSE when comparing two objects. The following example shows a Person instantiated using a Class.

var person1 = new Person { Name = "Alice", Age = 30 };
var person2 = new Person { Name = "Alice", Age = 30 };
var answer = (person1 == person2); // FALSE
Enter fullscreen mode Exit fullscreen mode

It's important to understand this when working with complex models with properties and collections as our Model above.

If you need to understand if you're working with the same object, you can use the ReferenceEquals to check. The following shows that assigning person3 to person1 allocates using a reference type. Pointing person3 to person1.

NOTE: I can't see why you would want to do this, but I am including this just for knowledge.

public record Person(string Name, int Age);

var person1 = new Person("Alice", 30);
var person2 = new Person("Alice", 30);
var person3 = person1; // Same memory reference as person1

Console.WriteLine(Object.ReferenceEquals(person1, person2)); // False
Console.WriteLine(Object.ReferenceEquals(person1, person3)); // True
Enter fullscreen mode Exit fullscreen mode

Changing the Immutability

It's very difficult to build a system without changing data, so does this means Records are only used for DTO to services without mutation or side affect?

Although it's true that Record's properties can't be mutated (externally), we can create a new instance, copying all the data and making the change we passing into the constructor

var persona = new Person("Alice", 30);
var personb = new Person(persona.FirstName, persona.Age+1);
Enter fullscreen mode Exit fullscreen mode

Above, Alice had a birthday and is now a year older, Happy Birthday Alice. We cloned persona into personb and incremented her age.

However, this would be annoying with a large and complex Model. Having to clone nested properties would lead to issues. Deep Clones have be problematic for many languages.

To solve this issue there is a short cut process.

var persona = new Person("Alice", 30);
var personb = persona with { Age = persona.Age+1 };
Enter fullscreen mode Exit fullscreen mode

We can clone Alice and increment her age. If Person was a complex Model, all properties will be cloned. The only difference between persona and personb would be the Age property.

AST for With

If you go deep into programming you will discover the Abstract Syntax Tree that shows how a language should be constructed. The following shows how to use the with expression with a Record.

├── VariableDeclaration: personb
│   ├── Type: Person
│   ├── Identifier: personb
│   ├── EqualsValueClause
│   │   ├── WithExpression
│   │   │   ├── Target: persona
│   │   │   ├── Initializer
│   │   │   │   ├── PropertyAssignment
│   │   │   │   │   ├── Property: Age
│   │   │   │   │   ├── Value: 31
Enter fullscreen mode Exit fullscreen mode

The AST above highlight how the with expression can be used and that a spat/prams of property assignments can be used to inject into the cloned instance.

Inheritance within Records

It is possible to break Models down into their smaller parts and then use Inheritance to group them into richer models. For example, the code below includes a simple Person object with a Name and Age. These properties feel correct in their composition within the Person Record. Then we have an Employee Record that includes a job Title. This implements inheritances and satisfies the "is-a" relationship. The Employee Record "is-a" Person.

public record Person(string Name, int Age)
{
    public string GetBasicInfo() => $"{Name}, {Age} years old";
}

public record Employee(string Name, int Age, string JobTitle) : Person(Name, Age)
{
    public string GetJobInfo() => $"{GetBasicInfo()}, works as {JobTitle}";
}
Enter fullscreen mode Exit fullscreen mode

A record can't inherit from a class, and a class can't inherit from a record

Extending Records

If you where paying close attention within the previous section on inheritance, you may have noticed a call to GetBasicInfo(). It's possible to extend your Record and add functionality to them. Finally we are out of the simple DTO space! Lets push on this topic.

record Engine(int CC, int CilinderCount)
{
    public string Status = "Stopped";
    public void Start() => Status = "Running"; 
    public void Stop() => Status = "Stopped";
}

record Car(string VinNum, string Make, string Model, int CC, int CilinderCount) : Engine(CC, CilinderCount);

var hondaAccord = new Car("2024", "Honda", "Acord", 2000, 6);
hondaAccord.Start();
hondaAccord.Stop();
Enter fullscreen mode Exit fullscreen mode

Sorry to all the car people who are dyeing now. See past the blatant technical errors and see the magic of Records. This produces the following states.

Car { CC = 2000, CilinderCount = 6, Status = Stopped, VinNum = 2034, Make = Honda, Model = Acord }
Car { CC = 2000, CilinderCount = 6, Status = Running, VinNum = 2034, Make = Honda, Model = Acord }
Car { CC = 2000, CilinderCount = 6, Status = Stopped, VinNum = 2034, Make = Honda, Model = Acord }
Enter fullscreen mode Exit fullscreen mode

Fist note that we are able to create Methods that act on the Model and change it's state.

Wait, you said Records are immutable!
We they are, but from the outside looking in. A Record is free to change it's self.

This is powerful, it means that external forces (another developer using your code) can't make changes that render the model incorrect or error prone. For example, consider the following, where a Honda car is created, only to change this to a Volvo later on. This is somewhat impossible in the real world but we allow for this type of mutation in almost all off of our Models.

record Car(string Make, string Model);
var honda = new Car("Honda", "Accord");
PrintParams(honda);
honda.Make = "Volvo";
Enter fullscreen mode Exit fullscreen mode

This will not compile, as a Record property can not be changed externally. The creator is is control of the Model, how it's used and how events change it's state. External forces can not place the model is an incorrect state.

Records and Extension Methods

It's also possible to extend Records by adding Extension Methods. This is very useful when working with Domain Objects that have different functionality based on how and where they are instantiated. Unfortunately I am not going to cover that monster here, but please let me know if you would like me to review this.

Records are like many types within C# and are extendable using Extension Methods.

record Person(string FirstName, string LastName, int CallCount);

static string GetFullName(this Person person) 
{
    return $"{person.FirstName} {person.LastName}";
}

var me = new Person("Garry", "Taylor", 0);
var msg = me.GetFullName();
Enter fullscreen mode Exit fullscreen mode

The above uses Extension method syntax to add a method to get the Full Name. This is useful when you want to extend the basic functionality of the Record. However, note that you are still unable to mutate the Record's properties from within the Extension Method.

static string GetFullName(this Person person) 
{
   person.CallCount++;
   return $"{person.FirstName} {person.LastName}";
}
Enter fullscreen mode Exit fullscreen mode

The above will not work as we are unable to change a Record externally; and extension methods are still outside of the Type. This indicates that the creator and designer of the Model is responsible for ensuring State and to provide useful methods to alter the state in accordance with business rules and validation logic.

All Together

Putting all these tools together becomes very powerful. We can create and alter Models quickly without the boilerplate code. We can encapsulate business rules but we can also use Type inference to perform branching and logic within our code. This reduces complexity whist improving readability. Read over the following and consider the alternatives.

enum CaseStatus {
    NEW,
    SEA,
    SOL,
    RES,
    REF,
    LEN,
    COM
}

abstract record Case(int CaseId, int MediaId, CaseStatus Status);
sealed record SecuredCase(int caseId, int mediaId, CaseStatus status) : Case(caseId, mediaId, status);
sealed record MortgageCase(int caseId, int mediaId, CaseStatus status) : Case(caseId, mediaId, status);

var cases = new List<Case>() {
    new SecuredCase(1,1100001, CaseStatus.NEW),
    new MortgageCase(2,1100002, CaseStatus.SEA),
    new MortgageCase(3,1100003, CaseStatus.SEA),
    new MortgageCase(4,1100003, CaseStatus.COM),
};

IEnumerable<Case> GetCases(List<Case> cases) {
    foreach(var x in cases) {
        yield return x;
        System.Threading.Thread.Sleep(1000);
    }
}

var PrintCaseType = (string msg, Case caseToPrint) => {
    PrintParams(msg, caseToPrint);
    return caseToPrint;
};

Case CheckCaseType(Case caseType) => 
    caseType switch
    {
        SecuredCase => PrintCaseType("Found secured cases", caseType),
        MortgageCase { Status: CaseStatus.COM } => PrintCaseType("Found a COM mortgage case", caseType),
        MortgageCase => PrintCaseType("Found a mortgage case", caseType),
        _ => throw new InvalidOperationException("Unsupported financial product"),
    };

foreach( var item in GetCases(cases))
{
    CheckCaseType(item);
}
Enter fullscreen mode Exit fullscreen mode

Let us review the code above. In the following, we're able to create our Models and use Inheritance to create two financial product types. This company packages both Mortgages and Secured Loans.

abstract record Case(int CaseId, int MediaId, CaseStatus Status);
sealed record SecuredCase(int caseId, int mediaId, CaseStatus status) : Case(caseId, mediaId, status);
sealed record MortgageCase(int caseId, int mediaId, CaseStatus status) : Case(caseId, mediaId, status);
Enter fullscreen mode Exit fullscreen mode

In the following code we loops over a list of Cases and performs an action based on their Type and State. Pattern Matching and Records work perfectly to solve this common problem. Using lightweight Records we can reflect business logic and using Types and State, we can perform actions This is one of the most impressive ways to build complex rules I've ever used.

Case CheckCaseType(Case caseType) => 
    caseType switch
    {
        SecuredCase => PrintCaseType("Found secured cases", caseType),
        MortgageCase { Status: CaseStatus.COM } => PrintCaseType("Found a COM mortgage case", caseType),
        MortgageCase => PrintCaseType("Found a mortgage case", caseType),
        _ => throw new InvalidOperationException("Unsupported financial product"),
    };
Enter fullscreen mode Exit fullscreen mode

both Secured and Mortgage Records "is-a" Case and is held in a list (IEnumberable) and streamed into the Pattern Matching switch. This allows for unique branching based on the type and the values of the State (Properties of the Model).
For example, we can perform different actions for Secured and Mortgage cases. In addition, we can also perform branching based on the State.

MortgageCase { Status: CaseStatus.COM } => CompleteMortageCase(),
SecuredCase { Status: CaseStatus.NEW } => NewSecuredCase(),
Enter fullscreen mode Exit fullscreen mode

For example, above shows pattern matching for a completed Mortgage case, as well as a new Secured case. changing and reasoning about this logic is easy. Testing is simple and maintenance is improved.

Validation

User: Your SDK errors with a generic 'Invalid Argument' when I set my birthday to '13/35/11978'
Developer: My brain hurts!

Too often we build Models that map data from one API to another, for example a REST API or an SDK. In some cases we have built a "Domain Layer" to hold our business logic but in most cases these Types are pure DTO's with little business logic or validation.

Question: Give yourself time to think about this. Why would you allow you Model to hit a "bad state"?
Answer: In most cases, it's because "protecting" your model is too dam hard.

public class Address 
{
    public string HouseNo { get; set; }
    public string Postcode { get; set; }
}
public class Person
{
    public String FirstName { get; set; }
    public String Surname { get; set; }
    public List<Address> Addresses { get; set; } = new();
}
Enter fullscreen mode Exit fullscreen mode

In the code above we have a very simple Model. We have a Person who has many Addresses. Let us add some business rules.

  • Surname can not be null
  • Address must always contain 1 resent address (current residential address)
  • We must have 3 years of address history
  • Two address Dates must not overlap by more than 3 months
  • Postcode must validate UK Postcode rules and can not be empty or null
  • Addresses can not be deleted once added (the must be logically deleted)
  • Some other random business rules...

As all the properties are public, the caller of these Classes have free reign. The only way the rules above can be applied is within another "layer". More often than not, this ends up in the UI Layer!

Validation Guards

A Model should never be allowed to be in an invalid state. Enforcing this within the constructor of your Records along with immutability helps to enforce this rule.

make illegal states unrepresentable

record Person(string FirstName, string Surname) {
    public string Surname { get; init; } = Guard.Against.NullOrEmpty(Surname);
}

var me = new Person("Garry", "Taylor");
var you = new Person("Garry", ""); // Error
Enter fullscreen mode Exit fullscreen mode

The code above ensures that the Surname is never null or empty. This means that it's impossible (ish) to hold an invalid Person object. This can also be extended to manage dealing with Addresses. Encapsulating them behind the Person Model as a proxy.

Above we are using the Guard Nuget Package from nuget: Ardalis.GuardClauses.
This is a very useful package that is great for validating your Records.

make illegal states unrepresentable

But what about deeply nested and Rich Models? This is where Records become interesting. It turns out and by design, Lists are mutable. In that you are able add and delete from the collection. The following code is all valid.

record Address(string HouseNo, string Postcode);
record Person(string FirstName, string Surname, List<Address> Addresses) {
    public List<Address> Addresses { get; set; } = Addresses;
}

var me = new Person("Garry", "Taylor", new List<Address> {
    new Address("1", "PR1 1UT"),
    new Address("2", "PR2 2UT")
});

me.Addresses.RemoveAt(0);
Enter fullscreen mode Exit fullscreen mode

Builder Patterns and Validation

Another option for ensuring clean state is to use a Builder Pattern to construct the instance. This can then hold the rules around it's valid state.

record Person(string FirstName, string Surname) {
    public static Person Create(string FirstName, string Surname) =>
        new(FirstName, Guard.Against.NullOrEmpty(Surname));
}

var me =  Person.Create("Garry", "Taylor");
var you = Person.Create("Garry", ""); // ERROR
Enter fullscreen mode Exit fullscreen mode

The example above will hit a run time exception and will guard against an invalid state.

Builder vs Constructor Validation

It's possible to place validation within a Constructor, however in doing so, you will also be required to create the properties manually.

record Person
{
    public Person(string firstName, string surname, int age)
    {
        Guard.Against.NullOrEmpty(surname);
        Guard.Against.StringTooLong(surname, 20);

        FirstName = firstName;
        Surname = surname;
        Age = age;
    }

    public string FirstName {get; init; }
    public string Surname { get; init; }
    internal int Age { get; init; }
}

var me = new Person("Garry", "Taylor", 21);
me = new Person("Garry", "", 21); // Error
me = new Person("Garry", "I am a really long surname", 21); // Error
Enter fullscreen mode Exit fullscreen mode

Mixing for Validation

It's possible to only declare the properties we wish to valid and therefore reduce the boiler plate code.

record Person(string FirstName, int Age)
{
    public Person(string firstName, string surname, int age) : this(firstName, age)
    {
        Guard.Against.NullOrEmpty(surname);
        Guard.Against.StringTooLong(surname, 20);

        Surname = surname;
    }

    public string Surname { get; init; }
}

var me = new Person("Garry", "Taylor", 21);
var bob = new Person("bob", "", 22); // Error
var paul = new Person("Paul", "I am a too long to fit into the surname", 23); // Error
Enter fullscreen mode Exit fullscreen mode

The code above uses the Record to generate the FirstName and Age as these properties have no validation. The Surname is creating within the custom constructor and validated before assigning to the property.

Optional Types within Records

We can then throw everything together and create a powerful Record that validates the Model but also keeps the callers safe. For example, by wrapping data into Optional types, we can protect the caller from miss-using the middle name. We are notifying them that this value maybe null and that they should guard against this. We are also protecting the surname, with a Optional Type and with a Guard.

#nullable enable
public record Person(
    string FirstName,
    Option<string> MiddleName,
    Option<string> LastName)
{
    public static Person Create(string firstName, string? middleName, string lastName)
    {
        Guard.Against.NullOrEmpty(lastName);
        Guard.Against.StringTooLong(lastName, 20);

        return new Person(
            firstName,
            middleName is not null ? Option.Some(middleName) : Optional.Option.None<string>(),
            Option.Some<string>(lastName));
    }
}

var me = Person.Create("Garry", "Peter", "Taylor");
var you = Person.Create("Garry", null, "Taylor");

PrintParams(me, me.MiddleName.HasValue, me.LastName.HasValue);
PrintParams(you, you.MiddleName.HasValue, you.LastName.HasValue);

var middleName = me.MiddleName.Match(
        some: m => $"{m}, ",
        none: () => ""
    );

var fullname = $"{me.FirstName} {middleName}{me.LastName.ValueOr("unknown")}";
PrintParams(fullname);
Enter fullscreen mode Exit fullscreen mode

This produces the following output.

Person : Person { FirstName = Garry, MiddleName = Some(Peter), LastName = Some(Taylor) }
System.Boolean : True
System.Boolean : True
Person : Person { FirstName = Garry, MiddleName = None, LastName = Some(Taylor) }
System.Boolean : False
System.Boolean : True
System.String : Garry Peter, Taylor
Enter fullscreen mode Exit fullscreen mode

The Danger of With

Do you feel safe now? Unfortunately you're far from it. Consider the following useful snippet.

var deadDuck = you with { LastName = Option.None<string>() };
PrintParams(deadDuck);
Enter fullscreen mode Exit fullscreen mode

This will output

Submission#180+Person : Person { FirstName = Garry, MiddleName = None, LastName = None }
Enter fullscreen mode Exit fullscreen mode

Congratulations, you have broken our Model! You have bypassed the Guard clause and now the Lastname is null. However, our code will still work as developers where required to guard against it using the ValueOr method. If the Developer isn't careful, our application could now miss-behave without throwing an error.

References

The above was put together using the following references and personal experience.

ZoranHorvat - Record
Optional

Top comments (0)