C# – Use records as a shortcut for defining DTOs

You can declare a record with a single line of code:

public record Coder(int Id, string Name, string Language);
Code language: C# (cs)

Note: This feature was added in .NET 5 / C# 9.

Records are basically classes (reference types) that work very well as simple data containers (i.e. DTOs). Here’s an example of using a record:

var coder1 = new Coder(1, "Bob", "C#");
var coder2 = new Coder(1, "Bob", "C#");

//Value-based equality checks
Console.WriteLine($"Coder1 and Coder2 are equal? {coder1 == coder2}");

//ToString() outputs a JSON-like format
Console.WriteLine(coder1);

//This line won't compile because properties are init-only
//coder1.Name = "Alice";
Code language: C# (cs)

This outputs the following:

Coder1 and Coder2 are equal? True
Coder { Id = 1, Name = Bob, Language = C# }Code language: plaintext (plaintext)

As shown, when you declare a record, it has the following auto-generated traits:

  • Value-based equality checks. When you check if two records are equal, it compares the property values, instead of doing a reference equality check. This is why coder1 == coder2 is true. Note: You can get this behavior yourself by implementing GetHashCode() and Equals().
  • Init-only properties. Because all properties are init-only, this means the record itself is immutable (can’t be changed after initialized).
  • ToString() outputs a JSON-like format. This is useful for logging / debugging.

All of this useful behavior is generated from a single line of code (declaring the record). You can add this behavior to a class manually, but it would be about 50 lines of code (which I’ll show below). It’s always better when you can write fewer lines of code to get the job done.

Manually written class equivalent of a record

Here is the single line of code to declare a record with three properties:

public record Coder(int Id, string Name, string Language);
Code language: C# (cs)

From this simple declaration, the compiler auto-generates a class with these traits: value-based equality checks, init-only properties, and useful ToString() output.

If you were to write a class manually with all of this behavior, it would look something like the following:

public class Coder : IEquatable<Coder>
{
    public Coder(int Id, string Name, string Language)
    {
        this.Id = Id;
        this.Name = Name;
        this.Language = Language;
    }
    public int Id { get; init; }
    public string Name { get; init; }
    public string Language { get; init; }
    public override string ToString()
    {
        return $"{nameof(Coder)} {{ {nameof(Id)} = {Id}, {nameof(Name)} = {Name}, {nameof(Language)} = {Language} }}";
    }
    public override int GetHashCode()
    {
        //tuple hashcode fn instead of manually XORing
        return (Id, Name, Language).GetHashCode();
    }
    public override bool Equals(object? obj)
    {
        return Equals(obj as Coder);
    }
    public bool Equals(Coder? other)
    {
        if (ReferenceEquals(other, null))
            return false;
        if (ReferenceEquals(this, other))
            return true;
        return Id.Equals(other.Id)
               && Name.Equals(other.Name)
               && Language.Equals(other.Language);
    }
    public static bool operator ==(Coder coder1, Coder coder2)
    {
        if (ReferenceEquals(coder1, coder2))
            return true;
        if (ReferenceEquals(coder1, null))
            return false;
        if (ReferenceEquals(coder2, null))
            return false;
        return coder1.Equals(coder2);
    }
    public static bool operator !=(Coder coder1, Coder coder2)
    {
        return !(coder1 == coder2);
    }
}
Code language: C# (cs)

This is about 50 lines of code. This is enormous and tedious, and writing this all out is error-prone (after all, devs are human). As you can see, using records saves you from having to type a lot of boilerplate code.

Creating a copy of a record with different values

You can use the with operator to create a copy of a record. Since records are immutable, you can’t change a record’s values, but you can create a copy with different values.

  • To create an exact copy with all the same values, use with {}:
var bob = new Coder(1, "Bob", "C#");

var copyOfBob = bob with { };

Console.WriteLine(copyOfBob);
Code language: C# (cs)

This outputs:

Coder { Id = 1, Name = Bob, Language = C# }Code language: plaintext (plaintext)
  • To create a copy with different values, use with { property = value } (specifying one or more properties):
var bob = new Coder(1, "Bob", "C#");

var robert = bob with { Name = "Robert" };

Console.WriteLine(robert);
Code language: C# (cs)

This outputs:

Coder { Id = 1, Name = Robert, Language = C# }Code language: plaintext (plaintext)

Adding attributes to record properties

To add an attribute to a record property, use [property: AttributeName] in the record declaration. Here’s an example of adding the JsonIgnore attribute to the Language property:

using System.Text.Json.Serialization;

public record Coder(int Id, string Name, [property: JsonIgnore] string Language);
Code language: C# (cs)

This attribute causes property to be ignored when you serialize the object to JSON:

using System.Text.Json;

var bob = new Coder(1, "Bob", "C#");

var json = JsonSerializer.Serialize(bob);

Console.WriteLine(json);
Code language: C# (cs)

This outputs (notice that Language isn’t there):

{"Id":1,"Name":"Bob"}Code language: JSON / JSON with Comments (json)

Example of using records with Dapper

To use records with Dapper, you have to declare the record with a parameterless constructor like this:

public record Movie(int Id, string Title, int YearOfRelease)
{
    public Movie() : this(default, default, default) { }
};
Code language: C# (cs)

Then use Dapper to execute a query, mapping the results to the record type:

using System.Data.SqlClient;
using Dapper;

using (var con = new SqlConnection(GetConnectionString()))
{
    foreach(var movie in con.Query<Movie>("SELECT * FROM Movies"))
    {
        Console.WriteLine(movie.Title);
    }
}
Code language: C# (cs)

This outputs the following movie titles:

Office Space
Mad Max: Fury Road
John Wick
The Matrix
The Big Short
The Revenant
InterstellarCode language: plaintext (plaintext)

Why is this strange syntax required?

Let’s see what happens when attempting use Dapper with a record declared without a parameterless constructor:

public record Movie(int Id, string Name, int YearOfRelease);
Code language: C# (cs)

Trying to use Dapper with this record results in the following runtime exception:

System.InvalidOperationException: A parameterless default constructor or one matching signature is required for Movie materialization

Dapper requires a parameterless constructor:

public record Movie(int Id, string Name, int YearOfRelease)
{
    public Movie() { }
};
Code language: C# (cs)

This doesn’t compile. It’s gets the following compile-time error:

CS8862 A constructor declared in a record with parameter list must have ‘this’ constructor initializer.

The record requires the parameterless constructor to call the implicit this(…) constructor, which requires passing in a value for all parameters:

public record Movie(int Id, string Name, int YearOfRelease)
{
    public Movie() : this(default, default, default) { }
};
Code language: C# (cs)

Now Dapper is able to properly map the query results.

Note: It’s possible that future versions of Dapper will support records without needing to add a parameterless constructor.

Leave a Comment