Durée de lecture : environ 4 minutes

Les records, ou « data classes », ont été introduits en C# 9. Leur syntaxe est spécifique et concise et vient avec certaines contraintes fortes. Bien qu’ils soient disponibles depuis fin 2020, j’ai l’impression qu’ils ne sont pas très populaires : je continue de croiser des DTO et des POCO écrits avec des classes exposant des setters et getters publics sur leurs propriétés. D’où l’objet de cet article : pourquoi utiliser des records en C# en 2025 ?

Vu de côté d'un disque vinyl en train d'être joué.
J’aime bien l’analogie ici car les disques vinyl sont en lecture seule. Photo par Adrian Korte via Unsplash.

Une syntaxe concise

Une des premières choses que l’on remarque lorsqu’on découvre les records est leur syntaxe extrêmement simple :

public record Price(decimal Value, Currency Currency);

En une seule ligne, on a un constructeur et 2 propriétés en lecture seule ! C’est équivalent à :

public class Price(decimal value, Currency currency)
{
    public decimal Value { get; init; } = value;
    public Currency Currency { get; init; } = currency;
}

Sauf que… pas vraiment. Les records ont d’autres comportements dédiés qui les rendent encore plus utiles.

L’immutabilité

Les records sont immutables. Cela signifie qu’une fois qu’une instance d’un record est créée, elle ne peut plus être modifiée. Par exemple, le code suivant ne compilera pas :

var price = new Price(15m, Currencies.EUR);
price.Value = 12m;

Chaque propriété ne peut pas être modifiée, elle est en lecture seule. L’immutabilité est quelque chose de super parce qu’elle permet d’éviter des effets de bord quand on passe des objets d’une méthode à une autre. Maintenant, admettons que vous vouliez vraiment copier les propriétés d’un objet sauf pour une ou deux propriétés. Dans ce cas, le mot-clé with vous sera d’un grand secours :

var price = new Price(15m, Currencies.EUR);
var newPrice = price with { Value = 12m };

Vous disposez ainsi d’une seconde instance qui a les mêmes propriétés que la première sauf pour celles que vous avez spécifiées dans la clause with ! La première instance n’est pas modifiée ce qui évite les effets de bord, mais vous obtenez tout de même une instance qui a les propriétés que vous souhaitez. C’est ce qu’on appelle la mutation non destructrice.

Value objects

Les records sont des types référence (seuls les record structs sont des types valeur) mais ce sont des value objects. Cela signifie que si 2 instances différentes ont exactement les mêmes propriétés, alors elles seront considérées égales. Par exemple :

[Test]
public void TestMethod()
{
    var firstPrice = new Price(15m, Currencies.EUR);
    var secondPrice = new Price(15m, Currencies.EUR);
    Check.That(firstPrice).IsEqualTo(secondPrice);
}

Ce test va passer. Pour arriver au même résultat avec des classes, il nous faudrait remplacer les méthodes Equals() et GetHashCode(). Cela rajouterait de la plomberie inutile dans 90% des cas. Mais pas avec les records !

Déconstructeur intégré

Mais il y a mieux ! Que faire si nous utilisons des records dans notre code mais qu’une library tiers utilise des bons vieux types natifs ? Avec une classe, il faudrait appeler chaque propriété individuellement et ça ferait le job. Avec les records, on peut les déconstruire directement :

var price = new Price(15m, Currencies.EUR);
var (value, currency) = price;

Et on est parés.

Oh, une dernière chose…

Comme si ce n’était pas suffisant, les développeurs de C# ont poussé l’amabilité à ajouter une méthode ToString() intégrée :

var price = new Price(15m, Currencies.EUR);
Console.WriteLine(price);

Dans la console :

Price { Value = 15, Currency = Currency { IsoCode = EUR, Name = Euro } }

Au debug :

Libellé dans Visual Studio qui reprend la même sortie que dans le bloc ci-dessus, à savoir : Price { Value = 15, Currency = Currency { IsoCode = EUR, Name = Euro } }

Extrêmement utile pour pas cher.

Toujours fan des classes ?

En fait, la question posée dans le titre devrait plutôt être « pourquoi ne PAS utiliser des records en C# ? » Bien sûr j’utilise toujours des classes quand il y a un peu de logique et quand je ne veux pas comparer d’objets entre eux. Mais je milite ouvertement pour l’utilisation de records lorsqu’il s’agit de DTOs ou de modèle métier. Ils sont là pour ça. Donc comment se fait-il qu’il y ait toujours des classes avec des getters et setters publics et un constructeur par défaut ?

Un contexte legacy n’explique pas tout. Les records existent depuis près de 5 ans, il faut se mettre à la page !

Au fait, voici à quoi ressemblerait le code d’une classe qui aurait le même comportement qu’un record simple :

public class PriceClass(decimal Value, Currency Currency)
{
    public decimal Value { get; init; } = Value;
    public Currency Currency { get; init; } = Currency;

    protected bool Equals(PriceClass other)
    {
        return Value == other.Value && Currency.Equals(other.Currency);
    }

    public override bool Equals(object? obj)
    {
        if (obj is null) return false;
        if (ReferenceEquals(this, obj)) return true;
        if (obj.GetType() != GetType()) return false;
        return Equals((PriceClass)obj);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(Value, Currency);
    }

    public void Deconstruct(out decimal value, out Currency currency)
    {
        value = Value;
        currency = Currency;
    }

    public override string ToString()
    {
        var stringBuilder = new StringBuilder();
        stringBuilder.Append(nameof(PriceClass));
        stringBuilder.Append(" { ");
        stringBuilder.Append($"Value = {Value}, Currency = {Currency}");
        stringBuilder.Append(" }");
        return stringBuilder.ToString();
    }
}

Software developer since 2000, I try to make the right things right. I usually work with .Net (C#) and Azure, mostly because the ecosystem is really friendly and helps me focus on the right things.
I try to avoid unnecessary (a.k.a. accidental) complexity and in general everything that gets in the way of solving the essential complexity induced by the business needs.
This is why I favor a test-first approach to focus on the problem space and ask questions to business experts before even coding the feature.

Catégories : Dev

Guillaume Téchené

Software developer since 2000, I try to make the right things right. I usually work with .Net (C#) and Azure, mostly because the ecosystem is really friendly and helps me focus on the right things. I try to avoid unnecessary (a.k.a. accidental) complexity and in general everything that gets in the way of solving the essential complexity induced by the business needs. This is why I favor a test-first approach to focus on the problem space and ask questions to business experts before even coding the feature.