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
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
}
| Exception | Signifie | Ce que ça révèle |
|---|---|---|
NullReferenceException | Usage d'une référence nulle | Un bug de ton code : à corriger, pas à attraper |
ArgumentNullException / ArgumentException | Un appelant a passé n'importe quoi | Bug de l'appelant : à lever, pas à attraper |
InvalidOperationException | L'objet n'est pas dans l'état requis | « Commande déjà validée », « base fermée » |
KeyNotFoundException | Clé absente d'un dictionnaire | Utilise TryGetValue pour l'éviter |
OperationCanceledException | Annulation demandée | Normal, pas une erreur : à ne pas journaliser en erreur |
HttpRequestException, SqlException, TimeoutException | Le monde extérieur a échoué | Attrapables et souvent réessayables |
OutOfMemoryException, StackOverflowException | Le processus est perdu | Irrécupérables : ne cherche pas à les gérer |
Les cinq règles qui évitent 90 % des dégâts
// 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// 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.
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);
}
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);
}
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());
}
| Écriture | Sens | Attention |
|---|---|---|
string? | Peut valoir null | Le compilateur exige une vérification avant usage |
x?.Membre | Si x est null, le résultat est null | N'enchaîne pas au-delà de 3 niveaux : illisible |
x ?? y | x, ou y si x est null | Idéal pour les valeurs de repli |
x ??= y | Affecte y seulement si x est null | Utile pour l'initialisation paresseuse |
x! | « Fais-moi confiance, ce n'est pas null » | ⚠️ Aucune vérification générée. À justifier en commentaire |
required | Obligatoire à la construction | Erreur de compilation si omis, pas d'exception à l'exécution |
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).
// « 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 transformationCes 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.
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.
// 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
| Situation | Approche | Pourquoi |
|---|---|---|
| Bug de programmation (paramètre nul, état impossible) | Exception | Il faut que ça casse fort et vite, pour être corrigé |
| Panne extérieure (réseau, base) | Exception | Rare, imprévisible, remonte naturellement |
| Cas métier attendu (« email déjà pris », « stock épuisé ») | Résultat | Ce n'est pas exceptionnel : c'est un cas normal du flux |
| Conversion susceptible d'échouer | TryXxx | Motif standard de la bibliothèque .NET |
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()
};
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;
}
}
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 ?
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 ?
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 ?
4. Que se passe-t-il avec using var client = new HttpClient(); dans une méthode appelée à
chaque requête ?
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 ?
Fiches de révision
throw; — jamais throw ex; qui écrase la pile.x! ?ArgumentNullException.ThrowIfNull(x).TryXxx.using, à quoi ça sert ?Dispose() automatiquement, même en cas d'exception. Pour fichiers, connexions, sockets.ProblemDetails avec un traceId. Jamais la pile d'appels.- N'attrape que ce que tu sais traiter ; un gestionnaire global s'occupe du reste.
throw;pour relancer,new X("...", ex)pour enrichir sans perdre la cause.- Clauses de garde en haut de méthode : le corps reste plat.
usingpour toutIDisposable, sauf ce que gère l'injection de dépendances.- Les types nullables sont une aide de compilation : valide quand même aux frontières.
- Cas métier attendu → résultat ; bug ou panne → exception.