Accueil › Les fondations › Chapitre 9

Erreurs, null et robustesse

Le code qui marche est facile. Le code qui continue de marcher quand le réseau tombe, quand l'utilisateur envoie n'importe quoi et quand la base répond en 30 secondes demande trois outils : les exceptions, les types nullables, et la validation. Voici comment les utiliser sans transformer ton code en forteresse illisible.

Une exception, c'est quoi

L'image

Le signal d'alarme dans un train. Quand une méthode ne peut plus faire son travail, elle tire l'alarme. Le signal remonte les wagons (les appels) jusqu'à ce que quelqu'un sache réagir. Si personne ne réagit, le train s'arrête complètement (le programme plante) — et c'est parfois le comportement correct : mieux vaut s'arrêter qu'écrire des données fausses.

try
{
    var contenu = File.ReadAllText(chemin);
    return JsonSerializer.Deserialize<Config>(contenu);
}
catch (FileNotFoundException ex)          // du plus précis…
{
    _log.LogWarning(ex, "Fichier {Chemin} absent, configuration par défaut", chemin);
    return Config.Defaut;
}
catch (JsonException ex)
{
    throw new ConfigInvalideException($"Le fichier {chemin} est illisible", ex);  // ← on enveloppe
}
finally
{
    _compteur.Increment();                // exécuté quoi qu'il arrive
}
ExceptionSignifieCe que ça révèle
NullReferenceExceptionUsage d'une référence nulleUn bug de ton code : à corriger, pas à attraper
ArgumentNullException / ArgumentExceptionUn appelant a passé n'importe quoiBug de l'appelant : à lever, pas à attraper
InvalidOperationExceptionL'objet n'est pas dans l'état requis« Commande déjà validée », « base fermée »
KeyNotFoundExceptionClé absente d'un dictionnaireUtilise TryGetValue pour l'éviter
OperationCanceledExceptionAnnulation demandéeNormal, pas une erreur : à ne pas journaliser en erreur
HttpRequestException, SqlException, TimeoutExceptionLe monde extérieur a échouéAttrapables et souvent réessayables
OutOfMemoryException, StackOverflowExceptionLe processus est perduIrrécupérables : ne cherche pas à les gérer

Les cinq règles qui évitent 90 % des dégâts

À ne pas faire
// 1. Avaler silencieusement
try { Traiter(); } catch { }          // 💀

// 2. Perdre la pile d'appels
try { }
catch (Exception ex) { throw ex; }    // pile écrasée

// 3. Perdre la cause
catch (SqlException) {
    throw new Exception("erreur base"); // inner perdue
}

// 4. Attraper trop large, trop tôt
try { Traiter(); }
catch (Exception) { return null; }    // masque tout

// 5. Utiliser les exceptions comme un if
try { var n = int.Parse(saisie); }
catch { n = 0; }                      // très lent
À faire
// 1. Attraper seulement ce qu'on sait traiter
catch (HttpRequestException ex) { Reessayer(); }

// 2. Relancer proprement
catch (Exception ex) { _log.LogError(ex, "..."); throw; }

// 3. Conserver la cause
catch (SqlException ex) {
    throw new StockIndisponibleException("...", ex);
}

// 4. Filtrer finement
catch (SqlException ex) when (ex.Number == 1205)  // deadlock
{ Reessayer(); }

// 5. Tester au lieu de lever
if (!int.TryParse(saisie, out var n)) n = 0;
throw; et non throw ex;

throw ex; réinitialise la pile d'appels : ton message d'erreur pointera sur la ligne du catch au lieu du vrai coupable, cinq méthodes plus bas. Un seul caractère de différence, des heures de débogage perdues. Écris throw; tout court.

Où attraper ? À la frontière, une fois

Dans une application web, on n'attrape presque rien dans le code métier. On installe un gestionnaire global qui journalise et renvoie une réponse propre (voir plus bas). Le code métier lève des exceptions signifiantes et laisse passer le reste. Cela évite les centaines de try { } catch { return null; } qui transforment un bug clair en comportement inexplicable.

Exceptions personnalisées

public class StockInsuffisantException(string reference, int demande, int disponible)
    : Exception($"Stock insuffisant pour {reference} : {demande} demandés, {disponible} disponibles")
{
    public string Reference { get; } = reference;      // ← données exploitables par l'appelant
    public int Demande { get; } = demande;
    public int Disponible { get; } = disponible;
}

// Utilisation : l'appelant peut réagir intelligemment
catch (StockInsuffisantException ex) when (ex.Disponible > 0)
{
    return ProposerCommandePartielle(ex.Reference, ex.Disponible);
}

N'en crée que si un appelant a besoin de distinguer ce cas. Sinon, une InvalidOperationException avec un bon message suffit.

Clauses de garde : échouer tôt, échouer clairement

public async Task<Facture> CreerAsync(Commande commande, string devise, CancellationToken ct)
{
    ArgumentNullException.ThrowIfNull(commande);                        // .NET 6+
    ArgumentException.ThrowIfNullOrWhiteSpace(devise);                  // .NET 8+
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(commande.Total);  // .NET 8+

    if (commande.Statut != Statut.Validee)
        throw new InvalidOperationException($"Commande {commande.Id} non validée");

    // ← à partir d'ici, tout est garanti valide : le corps reste plat et lisible
    return await ConstruireAsync(commande, devise, ct);
}
L'image

Le contrôle des billets se fait à l'entrée, pas au milieu du spectacle. Une méthode qui vérifie tout en haut puis déroule son travail sans imbrication est infiniment plus lisible qu'une pyramide de if imbriqués sur cinq niveaux.

Libérer les ressources : using

Le ramasse-miettes gère la mémoire, pas les fichiers, connexions et sockets. Ce qui implémente IDisposable doit être libéré.

// Déclaration using : libéré à la fin du bloc englobant. La forme moderne, à préférer.
using var flux = File.OpenRead("data.bin");
using var lecteur = new StreamReader(flux);
var texte = await lecteur.ReadToEndAsync();
// Dispose() appelé automatiquement ici, même en cas d'exception

// Version asynchrone
await using var connexion = new SqlConnection(chaine);
await connexion.OpenAsync(ct);

// Implémenter le contrat soi-même
public sealed class FichierTemporaire : IDisposable
{
    public string Chemin { get; } = Path.GetTempFileName();
    public void Dispose() => File.Delete(Chemin);
}
Les exceptions à la règle : HttpClient

HttpClient est IDisposable, mais l'entourer d'un using à chaque appel épuise les ports réseau (les sockets restent en TIME_WAIT). La bonne pratique est IHttpClientFactory, injecté (chapitre 11). De même, ne fais jamais Dispose d'un objet fourni par l'injection de dépendances : le conteneur s'en charge.

Les types nullables : le compilateur comme garde-fou

Avec <Nullable>enable</Nullable> (activé par défaut depuis .NET 6), le compilateur distingue « peut être nul » de « ne devrait jamais l'être » et t'avertit avant l'exécution.

public class Client
{
    public required string Nom { get; init; }     // jamais null, obligatoire à la création
    public string? Surnom { get; init; }          // peut être null, et c'est assumé
    public List<Commande> Commandes { get; } = [];// jamais null : initialisé
}

void Afficher(Client c)
{
    Console.WriteLine(c.Nom.ToUpper());           // ✅ aucun avertissement
    Console.WriteLine(c.Surnom.ToUpper());        // ⚠️ CS8602 : peut être null

    // Les quatre bonnes façons de traiter le cas :
    Console.WriteLine(c.Surnom?.ToUpper());               // null si null
    Console.WriteLine(c.Surnom?.ToUpper() ?? "(aucun)");  // valeur de repli
    if (c.Surnom is not null) Console.WriteLine(c.Surnom.ToUpper());
    if (!string.IsNullOrWhiteSpace(c.Surnom)) Console.WriteLine(c.Surnom.ToUpper());
}
ÉcritureSensAttention
string?Peut valoir nullLe compilateur exige une vérification avant usage
x?.MembreSi x est null, le résultat est nullN'enchaîne pas au-delà de 3 niveaux : illisible
x ?? yx, ou y si x est nullIdéal pour les valeurs de repli
x ??= yAffecte y seulement si x est nullUtile pour l'initialisation paresseuse
x!« Fais-moi confiance, ce n'est pas null »⚠️ Aucune vérification générée. À justifier en commentaire
requiredObligatoire à la constructionErreur de compilation si omis, pas d'exception à l'exécution
Le piège des frontières

Les annotations nullables sont vérifiées à la compilation et effacées à l'exécution. Un JSON entrant, une réponse d'API, une ligne de base peuvent donc placer un null dans une propriété string non nullable sans qu'aucune erreur ne soit levée à ce moment-là — le NullReferenceException surgira trois couches plus loin. D'où l'importance de valider aux frontières (section suivante).

Les attributs qui affinent l'analyse
// « si je renvoie true, alors resultat n'est pas null »
public bool TryTrouver(int id, [NotNullWhen(true)] out Client? resultat) { /* ... */ }

if (TryTrouver(42, out var c))
    Console.WriteLine(c.Nom);       // ✅ aucun avertissement : le compilateur a compris

// « je ne renvoie jamais » → le code après est inatteignable
[DoesNotReturn] static void Echouer(string m) => throw new InvalidOperationException(m);

// autres : [MemberNotNull(nameof(_champ))] sur une méthode d'initialisation,
//          [NotNullIfNotNull(nameof(entree))] sur une méthode de transformation

Ces attributs sont ce qui fait que int.TryParse, Dictionary.TryGetValue et string.IsNullOrEmpty collaborent parfaitement avec l'analyse de nullité.

Valider les données entrantes

Règle absolue : toute donnée venant de l'extérieur est suspecte jusqu'à validation — formulaire, JSON, paramètre d'URL, fichier importé, réponse d'une API partenaire.

Minimal API — validation déclarative (ASP.NET Core 10+)
builder.Services.AddValidation();      // active la validation automatique des endpoints

public record CreerClient(
    [Required, StringLength(80, MinimumLength = 2)] string Nom,
    [Required, EmailAddress]                        string Email,
    [Range(18, 130)]                                int Age);

app.MapPost("/clients", (CreerClient dto) => TypedResults.Created($"/clients/1", dto));
// Si le corps est invalide : 400 + ProblemDetails détaillant chaque champ,
// AVANT même l'entrée dans ton code.
Validation dans le domaine : rendre l'état invalide impossible
// Le type lui-même garantit sa validité : plus besoin de revérifier ailleurs
public readonly record struct Email
{
    public string Valeur { get; }

    private Email(string v) => Valeur = v;

    public static Email Creer(string saisie)
    {
        if (string.IsNullOrWhiteSpace(saisie) || !saisie.Contains('@'))
            throw new ArgumentException($"Email invalide : {saisie}");
        return new Email(saisie.Trim().ToLowerInvariant());
    }

    public static bool TryCreer(string saisie, out Email resultat) { /* ... */ }
    public override string ToString() => Valeur;
}

// Signature qui se documente elle-même — impossible de passer un email non validé
public Task EnvoyerAsync(Email destinataire, string sujet) { /* ... */ }

Exception ou résultat ? Le vrai critère

SituationApprochePourquoi
Bug de programmation (paramètre nul, état impossible)ExceptionIl faut que ça casse fort et vite, pour être corrigé
Panne extérieure (réseau, base)ExceptionRare, imprévisible, remonte naturellement
Cas métier attendu (« email déjà pris », « stock épuisé »)RésultatCe n'est pas exceptionnel : c'est un cas normal du flux
Conversion susceptible d'échouerTryXxxMotif standard de la bibliothèque .NET
Le motif « résultat », très lisible avec le pattern matching (C# 9+)
public abstract record Resultat<T>
{
    public record Succes(T Valeur) : Resultat<T>;
    public record Echec(string Code, string Message) : Resultat<T>;
}

public async Task<Resultat<Client>> InscrireAsync(CreerClient dto)
{
    if (await _db.Clients.AnyAsync(c => c.Email == dto.Email))
        return new Resultat<Client>.Echec("EMAIL_PRIS", "Cet email est déjà utilisé");

    var client = new Client { Nom = dto.Nom, Email = dto.Email };
    _db.Add(client);
    await _db.SaveChangesAsync();
    return new Resultat<Client>.Succes(client);
}

// À l'appel : tous les cas sont traités, le compilateur le vérifie
return await InscrireAsync(dto) switch
{
    Resultat<Client>.Succes s => Results.Created($"/clients/{s.Valeur.Id}", s.Valeur),
    Resultat<Client>.Echec e   => Results.Conflict(new { e.Code, e.Message }),
    _ => Results.Problem()
};
Le coût réel d'une exception, et les unions C# 15

Lever une exception coûte de l'ordre de quelques microsecondes (capture de pile, parcours des gestionnaires) — soit mille à dix mille fois plus qu'un simple return. Négligeable pour un cas rare, désastreux dans une boucle de validation de 100 000 lignes. C'est la seule vraie raison technique de préférer un résultat : la sémantique (« est-ce exceptionnel ? ») reste le critère principal.

C# 15 rend ce motif natif avec les unions et les hiérarchies closed : le compilateur vérifie alors l'exhaustivité du switch sans branche _ (voir chapitre 22).

Le filet de sécurité global (ASP.NET Core)

// Program.cs — un seul endroit pour transformer une exception en réponse HTTP propre
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GestionnaireErreurs>();

var app = builder.Build();
app.UseExceptionHandler();     // à placer très tôt dans le pipeline

// ------------------------------------------------------------
public sealed class GestionnaireErreurs(ILogger<GestionnaireErreurs> log) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
    {
        var (statut, titre) = ex switch
        {
            StockInsuffisantException => (StatusCodes.Status409Conflict, "Stock insuffisant"),
            KeyNotFoundException      => (StatusCodes.Status404NotFound, "Introuvable"),
            ArgumentException         => (StatusCodes.Status400BadRequest, "Requête invalide"),
            _                         => (StatusCodes.Status500InternalServerError, "Erreur interne")
        };

        // On journalise TOUT côté serveur, avec l'identifiant de trace…
        log.LogError(ex, "Échec {Trace} sur {Chemin}", ctx.TraceIdentifier, ctx.Request.Path);

        // …mais on ne divulgue jamais la pile d'appels au client
        await Results.Problem(
            title: titre,
            statusCode: statut,
            extensions: new Dictionary<string, object?> { ["traceId"] = ctx.TraceIdentifier })
            .ExecuteAsync(ctx);
        return true;
    }
}
Le traceId, ton meilleur ami en production

Le client reçoit « Erreur interne, référence 0HN7... ». Toi, tu retrouves dans les logs l'exception complète associée à cette référence. L'utilisateur a une information utile, l'attaquant n'apprend rien de ton infrastructure.

Vérifie que c'est passé

🎯 Quiz — 5 questions

1. Différence entre throw; et throw ex; dans un catch ?

Utilise toujours throw; pour relancer. Pour ajouter du contexte : throw new MonException("...", ex), en conservant l'exception d'origine.

2. Une propriété déclarée public string Nom { get; set; } (non nullable) peut-elle contenir null à l'exécution ?

Une désérialisation JSON, EF Core ou un x! mal placé peuvent y mettre un null. Les annotations sont une aide de conception, pas une garantie d'exécution : d'où la validation aux frontières.

3. « Cet email est déjà utilisé » : exception ou résultat ?

Ce n'est pas exceptionnel. Un résultat explicite rend le cas visible dans la signature, force l'appelant à le traiter, et coûte mille fois moins cher.

4. Que se passe-t-il avec using var client = new HttpClient(); dans une méthode appelée à chaque requête ?

Chaque Dispose laisse une socket en TIME_WAIT pendant quelques minutes. Sous charge, plus aucun port disponible : SocketException: Address already in use.

5. Où placer le try/catch dans une API web ?

Attraper partout transforme les bugs en comportements silencieux. Le code métier lève ; la frontière HTTP journalise et traduit en réponse.

Fiches de révision

Relancer une exception ?
throw; — jamais throw ex; qui écrase la pile.
Que veut dire x! ?
« Je garantis que ce n'est pas null. » Aucune vérification générée : à justifier.
Clause de garde ?
Valider en haut de la méthode et sortir tout de suite : ArgumentNullException.ThrowIfNull(x).
Exception ou résultat ?
Bug ou panne → exception. Cas métier attendu → résultat. Conversion → TryXxx.
using, à quoi ça sert ?
Appeler Dispose() automatiquement, même en cas d'exception. Pour fichiers, connexions, sockets.
Que renvoyer au client en cas d'erreur 500 ?
Un ProblemDetails avec un traceId. Jamais la pile d'appels.
À retenir