When to use records in C#

When to use records in C#

When to use records in C#

For several years now, C# has supported Records as a new "type". Initially only for classes (which is why the class is optional for a record class), later also for structs. Records are specially designed for use as data containers aka DTOs (please dont use DTO in your class name!). They are immutable and have special semantics for equality.

Record Classes

Usually you create a record with a primary constructor that defines the properties. Records are immutable, i.e. all values included in the immunity cannot be changed.

public record class Person(string FirstName, string LastName);

The compiler creates a class with “Positional Properties” accordingly. They are immutable.

public record class Person
{
    public required string FirstName { get; init; }
    public required string LastName { get; init; }
}

However, a record can also be defined with properties that are mutable.

public record class Person
{
    public required string FirstName { get; init; }
    public required string LastName { get; init; }

    public required int Age { get; set; }
}

Record Structs

Structs are value types and are often used for small data structures. Records are particularly useful here, as they are immutable and support equality semantics, which is usually the case with structs. This is achieved by the additional readonly.

public readonly record struct Person(string FirstName, string LastName);

If you leave out the readonly, almost all the advantages of a struct are gone and it is ultimately a mutable struct.

public record struct Person(string FirstName, string LastName);

Immutability

The basic idea of records is data structures and their immutability. This means that the values cannot be changed after initialization. All properties that are part of the immutability are included in the value equality - mutable properties are not.

Usage

Records are great for many data scenarios, but not for all. For example, records cannot be used with the Entity Framework today because the Entity Framework does not fully support immutable entity types.

In ASP.NET Core it makes sense to use records for responses, but not as request class.

public record class PersonResponse(string FirstName, string LastName);

public class PersonRequest
{
    public string? FirstName { get; set; }
    public string? LastName { get; set; }

    // see https://benfoster.io/blog/minimal-api-validation-endpoint-filters/
    public class Validator : AbstractValidator<PersonRequest>
    {
        public Validator()
        {
            RuleFor(x => x.FirstName).NotEmpty();
            RuleFor(x => x.LastName).NotEmpty();
        }
    }
}

app.MapPost("/person", ([FromBody] RegisterCustomerRequest request, IValidator<RegisterCustomerRequest> validator) =>
{
    var validationResult = validator.Validate(request);

    if (validationResult.IsValid is false)
    {
        return Results.ValidationProblem(validationResult.ToDictionary(),
        statusCode: HttpStatusCodes.Status400BadRequest);
    }

    // do stuff here, like add to database

    PersonResponse response = new(request.FirstName, request.LastName);)
    return Results.Ok(response);
});

Rule of thumb

  • To be on the safe side, use a simple class. A class is supported in virtually all scenarios.
  • If you have a data structure that should be immutable, use a record (except in scenarios that are not technically supported).
  • Only use Struct... a) if the implementation is "small", b) if only primitive types are used and c) if your Struct is not boxed. "Small" used to be defined as 16 bytes, but when using record structs, this does not necessarily make a big difference anymore.

Benchmarks

As part of my Sustainable Code series, I also have benchmarks of classes and structs - and the differences are impressive.

BenchmarkDotNet v0.14.0, Windows 10 (10.0.19045.5131/22H2/2022Update)
AMD Ryzen 9 9950X, 1 CPU, 32 logical and 16 physical cores
.NET SDK 9.0.100
  [Host]   : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  .NET 7.0 : .NET 7.0.20 (7.0.2024.26716), X64 RyuJIT AVX2
  .NET 8.0 : .NET 8.0.11 (8.0.1124.51707), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  .NET 9.0 : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI


| Method       | Runtime  | Mean      | Error    | StdDev   | Ratio | RatioSD | Gen0   | Allocated |
|------------- |--------- |----------:|---------:|---------:|------:|--------:|-------:|----------:|
| SmallStruct  | .NET 7.0 |  26.13 ns | 0.073 ns | 0.069 ns |  1.04 |    0.01 |      - |         - |
| SmallStruct  | .NET 8.0 |  26.17 ns | 0.054 ns | 0.048 ns |  1.05 |    0.01 |      - |         - |
| SmallStruct  | .NET 9.0 |  25.03 ns | 0.239 ns | 0.212 ns |  1.00 |    0.01 |      - |         - |
|              |          |           |          |          |       |         |        |           |
| MediumStruct | .NET 7.0 |  26.32 ns | 0.129 ns | 0.120 ns |  1.03 |    0.01 |      - |         - |
| MediumStruct | .NET 8.0 |  27.34 ns | 0.163 ns | 0.153 ns |  1.06 |    0.01 |      - |         - |
| MediumStruct | .NET 9.0 |  25.68 ns | 0.315 ns | 0.279 ns |  1.00 |    0.01 |      - |         - |
|              |          |           |          |          |       |         |        |           |
| SmallClass   | .NET 7.0 | 129.75 ns | 1.158 ns | 1.083 ns |  1.00 |    0.02 | 0.1433 |    2400 B |
| SmallClass   | .NET 8.0 | 128.01 ns | 0.599 ns | 0.531 ns |  0.98 |    0.02 | 0.1433 |    2400 B |
| SmallClass   | .NET 9.0 | 130.45 ns | 2.612 ns | 3.008 ns |  1.00 |    0.03 | 0.1433 |    2400 B |
|              |          |           |          |          |       |         |        |           |
| MediumClass  | .NET 7.0 | 292.62 ns | 1.292 ns | 1.209 ns |  1.00 |    0.01 | 0.2389 |    4000 B |
| MediumClass  | .NET 8.0 | 287.63 ns | 1.091 ns | 0.968 ns |  0.98 |    0.01 | 0.2389 |    4000 B |
| MediumClass  | .NET 9.0 | 292.07 ns | 4.250 ns | 3.768 ns |  1.00 |    0.02 | 0.2389 |    4000 B |

See full code here 🌳 Sustainable Code - Records vs Structs 📊

You can see enormous performance advantages when structs are used correctly. However, if structs are used incorrectly - for example in the Boxing scenario, then all the advantages evaporate and the struct becomes a disadvantage: it becomes slower.

| Method              | Runtime  | Mean      | Error     | StdDev    | Ratio | RatioSD | Gen0   | Allocated |
| Class               | .NET 9.0 | 1.5723 ns | 0.0120 ns | 0.0100 ns |  1.00 |    0.01 | 0.0014 |      24 B |
| ClassWithInterface  | .NET 9.0 | 1.5783 ns | 0.0123 ns | 0.0115 ns |  1.00 |    0.01 | 0.0014 |      24 B |
| Struct              | .NET 9.0 | 0.0013 ns | 0.0018 ns | 0.0016 ns |     ? |       ? |      - |         - |
| StructWithInterface | .NET 9.0 | 1.5944 ns | 0.0167 ns | 0.0140 ns |  1.00 |    0.01 | 0.0014 |      24 B |

Conclusion

Records not only make the code cleaner, they are also very powerful due to their immutability and value equality - if you use them correctly, especially with structs.

Used incorrectly, they become a disadvantage.