Records en C# : égalité par valeur et immutabilité

 

Les records, introduits en C# 9, offrent un moyen idiomatique de modéliser des données avec une égalité par valeur et une immutabilité naturelle, là où les classes traditionnelles restent centrées sur la référence et l’état mutable. Ce document présente également les cas où il est pertinent de les privilégier, notamment pour les DTO, les objets de valeur ou les contrats d’API.

Records : nature et sémantique

Les record (introduits en C# 9) sont des types conçus pour représenter des données (data-centric types) avec les caractéristiques suivantes :

  1. Sémantique d’égalité structurelle (par valeur)

    • Deux instances de record sont égales si toutes leurs composantes (propriétés incluses dans l’égalité) sont égales.
    • L’opérateur == est redéfini pour refléter cette égalité structurelle.
    • À l’inverse, les class classiques, par défaut, ont une égalité de référence (deux variables sont égales si elles référencent le même objet).
  2. Support natif de l’immutabilité

    • Les records sont généralement déclarés avec des propriétés get; init;, ce qui rend les propriétés assignables uniquement lors de l’initialisation.
    • Une fois l’instance construite, l’état est, par convention, considéré comme immuable.
  3. Syntaxe de copie avec modification : opérateur with

    • with permet de créer une nouvelle instance à partir d’une instance existante, en modifiant certaines composantes, sans muter l’original.

Exemple canonique :

public record Person(string FirstName, string LastName);

var p1 = new Person("Alice", "Dupont");
var p2 = p1 with { FirstName = "Bob" };
// p1 reste inchangé, p2 est une nouvelle instance

record class et record struct

Il existe deux familles de records :

  1. record class (par défaut)

    • Ce sont des types référence.
    • Déclaration implicite : public record Person(...) équivaut à public record class Person(...).
    • Immutabilité recommandée via init ou propriétés en lecture seule.
  2. record struct

    • Ce sont des types valeur.
    • On conserve la sémantique d’égalité structurelle et le support de with, mais la variable contient la valeur directement (copie par valeur).
    • Souvent utilisés pour des petits types valeur conceptuels (ex : coordonnées, montants, etc.).

Exemple :

public readonly record struct Point(int X, int Y);

Ici, le mot-clé readonly renforce l’aspect immuable en empêchant la mutation des champs après construction.

Immutabilité pratique avec init

Le mot-clé init permet de définir des propriétés “initialisables mais non mutables ultérieurement” :

public record Customer
{
    public string Name   { get; init; }
    public string Email  { get; init; }
}
  • Ces propriétés peuvent être assignées :
    • Dans le constructeur.
    • Dans un initialiseur d’objet au moment de la création.
  • Toute tentative de modification après l’initialisation sera rejetée par le compilateur.
var c = new Customer { Name = "Alice", Email = "a@example.com" };
// c.Name = "Bob";  // Erreur : propriété init-only

Les records combinent donc :

  • Propriétés init → immutabilité après construction.
  • == redéfini et Equals généré → égalité structurelle.
  • with → copie fonctionnelle (style “persistant / fonctionnel”).

Comparaison formelle : record immuable vs classe immuable

Classe immuable (pattern manuel) :

public sealed class Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    // Il faut redéfinir Equals/GetHashCode soi-même pour l'égalité par valeur
}

Record immuable :

public sealed record Money(decimal Amount, string Currency);

Différences principales :

  • Avec record, le compilateur :
    • Génère Equals, GetHashCode et ==/!= cohérents pour l’égalité structurelle.
    • Génère la déconstruction (var (amount, currency) = money;).
    • Offre with pour la copie modifiée.
  • Avec une class immuable “à la main”, il faut implémenter toute cette logique manuellement si l’on souhaite obtenir une égalité par valeur.

Limites et pièges à connaître

  1. Immutabilité superficielle vs profonde

    • Un record n’est réellement “profondément immuable” que si :
      • Ses propres propriétés sont immuables,
      • ET les objets référencés par ces propriétés sont eux-mêmes immuables.
    • Exemple problématique :

      public record Order
      {
          public int Id { get; init; }
          public List<string> Lines { get; init; } = new();
      }
      

      Ici, Order est immuable en surface (la propriété Lines ne peut pas être réaffectée), mais la liste interne Lines reste mutable (Add, Remove, etc.).

  2. Utilisation de set; classique dans un record

    • Si une propriété est déclarée public string Name { get; set; }, l’immutabilité est perdue et le type devient conceptuellement ambigu : il conserve l’égalité par valeur tout en possédant un état mutable.
  3. Confusion entre record et classe en termes d’égalité

    • record== compare les valeurs.
    • class (par défaut) → == compare la référence.
    • Mélanger les deux sans en être conscient peut mener à des bugs subtils.

exemple comparatif : classe mutable vs classe immuable vs record

1. Version classe mutable

public class Person
{
    public string FirstName { get; set; }   // mutable
    public string LastName  { get; set; }
    public int Age          { get; set; }

    public Person(string firstName, string lastName, int age)
    {
        FirstName = firstName;
        LastName  = lastName;
        Age       = age;
    }
}

// Usage
var p = new Person("Alice", "Dupont", 30);
p.Age = 31; // mutation directe

Caractéristiques :

  • État modifiable partout (set public).
  • Égalité par défaut : par référence.
  • Simple mais propice aux effets de bord.

2. Version classe immuable

public sealed class PersonImmutable
{
    public string FirstName { get; }
    public string LastName  { get; }
    public int Age          { get; }

    public PersonImmutable(string firstName, string lastName, int age)
    {
        FirstName = firstName;
        LastName  = lastName;
        Age       = age;
    }

    public PersonImmutable With(string? firstName = null, string? lastName = null, int? age = null)
        => new PersonImmutable(
            firstName ?? FirstName,
            lastName  ?? LastName,
            age       ?? Age
        );

    public override bool Equals(object? obj)
        => obj is PersonImmutable other
           && FirstName == other.FirstName
           && LastName  == other.LastName
           && Age       == other.Age;

    public override int GetHashCode() => HashCode.Combine(FirstName, LastName, Age);
}

Caractéristiques :

  • Immuable (que des get;).
  • Il faut implémenter soi-même :
    • Méthode With (copie modifiée),
    • Equals / GetHashCode pour l’égalité par valeur.

3. Version record immuable (recommandée pour les “data”)

public sealed record PersonRecord(string FirstName, string LastName, int Age);

Usage :

var p1 = new PersonRecord("Alice", "Dupont", 30);
var p2 = p1 with { Age = 31 };  // nouvelle instance
bool eq = p1 == p2;             // False, car Age diffère

Caractéristiques :

  • Propriétés immuables (positionnel → init implicite).
  • Égalité structurelle générée automatiquement.
  • with fourni par le langage (copie + modification).

4. Résumé “à retenir”

  • Classe mutable : simple, flexible, mais source de bugs (état partagé qui change).
  • Classe immuable : sûre mais verbeuse (égalité, With à la main).
  • Record immuable : forme idiomatique pour les types de données :
    • immuabilité naturelle,
    • égalité par valeur,
    • syntaxe très concise.

Quand privilégier un record immuable ?

Il est pertinent de choisir un record immuable lorsque :

  • Le type représente essentiellement des données, pas un “objet métier riche” avec beaucoup de comportements internes.
  • L’objectif est généralement de disposer :
    • d’un modèle de données clair, simple à sérialiser (JSON, etc.),
    • d’une égalité basée sur les valeurs,
    • d’une bonne compatibilité avec un style plus fonctionnel (copies avec with).

Exemples typiques :

  • DTO, messages d’événements, commandes/queries (CQRS), objets de configuration, objets de valeur (value objects en DDD).

1. Pourquoi des records pour les DTO d’API ?

Les DTO (Data Transfer Objects) représentent les données qui transitent à travers l’API (requêtes et réponses). Les record sont bien adaptés à ce rôle, car ils offrent :

  • Immutabilité (par convention) : les données reçues/envoyées ne changent pas après désérialisation.
  • Égalité par valeur : utile pour les tests, comparaisons et logs.
  • Syntaxe compacte : moins de code pour exprimer un contrat de données.
  • Intention claire : un record signale “type de données”, pas “objet métier riche”.

2. Exemple : DTO d’entrée et de sortie en record

public sealed record CreateUserRequest(
    string Email,
    string Password,
    string FirstName,
    string LastName
);

public sealed record UserResponse(
    Guid Id,
    string Email,
    string FirstName,
    string LastName
);

Dans un contrôleur ASP.NET Core :

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IUserService _userService;

    public UsersController(IUserService userService)
    {
        _userService = userService;
    }

    [HttpPost]
    public ActionResult<UserResponse> CreateUser(CreateUserRequest request)
    {
        var user = _userService.CreateUser(request);

        var response = new UserResponse(
            user.Id,
            user.Email,
            user.FirstName,
            user.LastName
        );

        return CreatedAtAction(nameof(GetUser), new { id = user.Id }, response);
    }

    [HttpGet("{id:guid}")]
    public ActionResult<UserResponse> GetUser(Guid id)
    {
        var user = _userService.GetUser(id);
        if (user is null) return NotFound();

        return new UserResponse(user.Id, user.Email, user.FirstName, user.LastName);
    }
}

3. Records DTO vs modèles de domaine

Bonne pratique : distinguer clairement DTO d’API et modèles de domaine.

  • DTO d’API (records) :

    • Localisés dans une couche/namespace Contracts, Api.Models, etc.
    • Représentent la forme des données exposées sur l’API (JSON).
  • Domaine (classes/records métier) :

    • Localisés dans une couche/namespace Domain.
    • Encapsulent la logique métier, les invariants, les règles.

Exemple de mapping explicite :

public static class UserMappings
{
    public static User ToDomain(this CreateUserRequest dto) => new User(dto.Email, dto.Password, dto.FirstName, dto.LastName);

    public static UserResponse ToResponse(this User user) => new UserResponse(user.Id, user.Email, user.FirstName, user.LastName);
}

Cette séparation permet :

  • De faire évoluer l’API (versioning) sans casser le domaine.
  • De garder les entités métier libres de toute préoccupation de sérialisation HTTP.

4. Validation avec des records DTO

Les records se combinent bien avec la validation ASP.NET Core :

public sealed record CreateUserRequest(
    [Required, EmailAddress] string Email,
    [Required, MinLength(8)] string Password,
    [Required] string FirstName,
    [Required] string LastName
);
  • La validation de modèle vérifie automatiquement ces attributs lors du binding.
  • On peut compléter par de la validation métier plus riche dans la couche domaine ou via un framework (FluentValidation, etc.).

5. Pièges à éviter avec les records DTO

  • Collections mutables internes :
    Un record comme :

    public sealed record OrderResponse(
        Guid Id,
        List<OrderLineResponse> Lines
    );
    

    est immuable en surface (la référence Lines ne change pas), mais la liste reste modifiable.
    → Pour une immutabilité plus forte, préférer IReadOnlyList<T> ou les collections immuables (ImmutableList<T>).

  • Réutiliser les records DTO comme entités EF Core :

    • Possible, mais souvent source de contraintes (constructeurs, proxys, setters, etc.).
    • Mieux : entités EF dédiées + records DTO séparés.
  • Mettre de la logique métier dans les records DTO :

    • Les DTO doivent rester des contrats de données.
    • La logique métier doit vivre dans le domaine (services, agrégats, value objects).