Pourquoi une collection typée peut mériter sa propre classe ?

Introduction

Il est tentant d’utiliser directement List<T>, IEnumerable<T> ou Collection<T> partout dans son code. Après tout, ces types sont prêts à l’emploi et offrent toutes les opérations de base. Pourtant, exposer directement ces collections génériques dans ton API publique est souvent une erreur de conception qui compromet l’encapsulation, la maintenabilité et l’évolutivité de ton code.

Dans ce document, on explore pourquoi et quand créer une classe dédiée pour représenter une collection métier, et comment le faire proprement.

L’encapsulation protège les invariants métier

Problème avec les collections génériques exposées

Quand tu exposes directement une List<T> ou une Collection<T>, tu perds tout contrôle sur ce qui entre et sort de ta collection. N’importe qui peut ajouter, supprimer ou modifier des éléments sans respecter les règles métier.

Exemple de problématique :

public class Facture
{
    public List<LigneFacture> Lignes { get; set; } = new();
    public decimal TotalHT => Lignes.Sum(l => l.MontantHT);
}

public class LigneFacture
{
    public string Description { get; set; } = "";
    public decimal MontantHT { get; set; }
}

// Rien n'empêche ceci :
var facture = new Facture();
facture.Lignes.Add(new LigneFacture { Description = "Produit invalide", MontantHT = -120 }); // Montant négatif
facture.Lignes.Clear(); // La facture devient vide
facture.Lignes[0].MontantHT = 999999; // Modification arbitraire

Solution : une classe dédiée

public class LignesFacture
{
    private readonly List<LigneFacture> _lignes = new();

    public void Ajouter(LigneFacture ligne)
    {
        if (ligne.MontantHT <= 0)
            throw new ArgumentException("Le montant doit être positif.", nameof(ligne));

        _lignes.Add(ligne);
    }

    public decimal TotalHT => _lignes.Sum(l => l.MontantHT);

    public IReadOnlyCollection<LigneFacture> ToReadOnly() => _lignes.AsReadOnly();
}

public class Facture
{
    public LignesFacture Lignes { get; } = new();

    public decimal TotalHT => Lignes.TotalHT;
}

Maintenant, les règles métier sont garanties à tout moment.

La sémantique métier donne du sens

List<User> vs Equipe

Une List<User> ne dit rien sur l’intention métier. Est-ce une équipe ? Des participants ? Des utilisateurs archivés ?

Une classe dédiée exprime clairement le domaine :

public class Equipe
{
    private readonly List<User> _membres = new();
    
    public void AjouterMembre(User user)
    {
        if (_membres.Count >= 10)
            throw new InvalidOperationException("Une équipe ne peut avoir plus de 10 membres.");
        
        if (_membres.Any(m => m.Email == user.Email))
            throw new InvalidOperationException("Ce membre fait déjà partie de l'équipe.");
        
        _membres.Add(user);
    }

    public User? Chef => _membres.FirstOrDefault(m => m.EstChef);
    
    public IReadOnlyCollection<User> Membres => _membres.AsReadOnly();
}

Avantages :

  • Le code est auto-documenté.
  • Les règles métier sont centralisées.
  • Tu peux ajouter des comportements spécifiques (ex: Chef, validation de taille).

Facilite les changements futurs

Le coût du refactoring

Si tu utilises List<Produit> partout dans ton code et que demain tu dois :

  • Ajouter un tri par défaut,
  • Filtrer les produits inactifs,
  • Ajouter un cache,
  • Gérer des événements lors de l’ajout/suppression,

Tu devras modifier tous les endroits où cette liste est manipulée.

Avec une classe dédiée

public class Catalogue
{
    private readonly List<Produit> _produits = new();

    public void Ajouter(Produit produit)
    {
        _produits.Add(produit);
        // Facile d'ajouter un événement ici plus tard
        // ProduitAjoute?.Invoke(produit);
    }

    public IEnumerable<Produit> ProduitsActifs => 
        _produits.Where(p => p.EstActif).OrderBy(p => p.Nom);
}

Le changement est local et n’impacte pas le reste du code.

L’immutabilité, pour des collections plus sûres

Exposer une collection mutable (List<T>) invite aux bugs. Préfère des collections immuables ou en lecture seule.

public class Panier
{
    private readonly List<Article> _articles = new();

    public IReadOnlyCollection<Article> Articles => _articles.AsReadOnly();
    
    public void AjouterArticle(Article article) => _articles.Add(article);
}

Facilite les tests

Une classe dédiée permet de mocker/simuler facilement le comportement de la collection.

public interface IEquipe
{
    void AjouterMembre(User user);
    IReadOnlyCollection<User> Membres { get; }
}

// Dans les tests
var equipe = Mock.Of<IEquipe>(e => e.Membres == new[] { user1, user2 });

Avec une simple List<User>, tu ne peux pas injecter de comportement personnalisé.

Quand créer une classe de collection ?

Critère Collection générique Classe dédiée
Collection interne privée ✅ Oui ⚠️ Optionnel
Exposée dans l’API publique ❌ Non ✅ Oui
Règles métier sur les éléments ❌ Non ✅ Oui
Comportements spécifiques ❌ Non ✅ Oui
Évolution probable ⚠️ Risqué ✅ Oui
Simple DTO sans logique ✅ Acceptable ⚠️ Overkill

Bonnes pratiques

✅ À faire

  • Encapsuler : garde la collection interne privée.
  • Exposer en lecture seule : retourne IReadOnlyCollection<T> ou IEnumerable<T>.
  • Valider les ajouts/suppressions : applique les règles métier.
  • Nommer clairement : Equipe, Catalogue, Panier plutôt que UserList.
  • Ajouter des méthodes métier : TotalHT(), EstVide(), ContientProduit().

❌ À éviter

  • Exposer des setters publics sur des collections : public List<T> Items { get; set; }
  • Retourner directement la collection interne : risque de mutation externe.
  • Créer une classe de collection sans valeur ajoutée (juste un wrapper vide).

Conclusion

  • Créer une classe de collection permet d’encapsuler les règles métier, d’améliorer la lisibilité et de faciliter l’évolution du code.
  • Évite d’exposer directement List<T> ou Collection<T> dans ton API publique.
  • Privilégie l’immutabilité ou les collections en lecture seule pour limiter les mutations non contrôlées.
  • Pense DDD (Domain-Driven Design) : si ta collection a un sens métier, elle mérite une classe dédiée.

Règle d’or : Si ta collection a des règles métier ou un comportement spécifique, encapsule-la dans une classe dédiée.

Complément

Implémentations possibles :

  • Records : Pour des collections simples et immuables, considère les records (C# 9+) :
    public record Equipe(ImmutableList<User> Membres);
    
  • **ICollection** : Si tu veux rendre ta collection générique.
  • **IEnumerable** : Pour exposer uniquement l'itération sans permettre de modifications.