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 :
-
Sémantique d’égalité structurelle (par valeur)
- Deux instances de
recordsont é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
classclassiques, par défaut, ont une égalité de référence (deux variables sont égales si elles référencent le même objet).
- Deux instances de
-
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.
- Les records sont généralement déclarés avec des propriétés
-
Syntaxe de copie avec modification : opérateur
withwithpermet 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 :
-
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
initou propriétés en lecture seule.
-
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 etEqualsgé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,GetHashCodeet==/!=cohérents pour l’égalité structurelle. - Génère la déconstruction (
var (amount, currency) = money;). - Offre
withpour la copie modifiée.
- Génère
- Avec une
classimmuable “à 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
-
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,
Orderest immuable en surface (la propriétéLinesne peut pas être réaffectée), mais la liste interneLinesreste mutable (Add,Remove, etc.).
- Un record n’est réellement “profondément immuable” que si :
-
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.
- Si une propriété est déclarée
-
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 (
setpublic). - É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/GetHashCodepour l’égalité par valeur.
- Méthode
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 →
initimplicite). - Égalité structurelle générée automatiquement.
withfourni 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
recordsignale “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).
- Localisés dans une couche/namespace
-
Domaine (classes/records métier) :
- Localisés dans une couche/namespace
Domain. - Encapsulent la logique métier, les invariants, les règles.
- Localisés dans une couche/namespace
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
Linesne change pas), mais la liste reste modifiable.
→ Pour une immutabilité plus forte, préférerIReadOnlyList<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).