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
...
Each Class has a collection of properties and relationships
Person -> Address[] -> Meter -> Provider
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;
}
}
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);
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);
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!
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);
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);
This will display
Person { FirstName = Garry, Surname = Taylor }
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);
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);
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);
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);
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
So the following will not compare
var persona = new Person("Garry", "Taylor");
var personb = new Person("Paul", "Taylor");
var answer = persona == personb; // FALSE
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
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
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);
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 };
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
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}";
}
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();
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 }
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";
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();
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}";
}
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);
}
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);
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"),
};
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(),
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();
}
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
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);
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
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
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
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);
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
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);
This will output
Submission#180+Person : Person { FirstName = Garry, MiddleName = None, LastName = None }
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.
Top comments (0)