Gestione eccezioni C#: pattern enterprise
Matteo Migliore

Matteo Migliore è un imprenditore e architetto software con oltre 25 anni di esperienza nello sviluppo di soluzioni basate su .NET e nell'evoluzione di architetture applicative per imprese e organizzazioni di alto profilo.

Ha guidato progetti enterprise, formato centinaia di sviluppatori e aiutato aziende di ogni dimensione a semplificare la complessità trasformando il software in guadagni per il business.

Ogni applicazione che crei prima o poi andrà in errore. Non è una questione di "se", ma di "quando".

Database irraggiungibili, file mancanti, timeout di rete, risposte imprevedibili da API di terze parti: sono la normalità nel mondo reale dello sviluppo software aziendale.

Eppure, quando si parla di gestione delle eccezioni in C#, la maggior parte dei programmatori si limita a circondare il codice con un blocco `try-catch` e a sperare che funzioni tutto.

La differenza vera tra un'applicazione amatoriale che regge finché le condizioni sono ideali e un sistema di livello Enterprise risiede esattamente in come vengono governate queste anomalie.

Le eccezioni non sono un fastidio da nascondere sotto il tappeto di un log inutile. Sono il linguaggio con cui il tuo sistema ti sta chiedendo aiuto.

Se decidi di ignorare le eccezioni, o peggio ancora, se le intercetti azzerando il loro messaggio silenziandole volontariamente, stai programmando alla cieca.

In questo articolo non ci limiteremo alla teoria accademica. Vedremo come trasformare il meccanismo basilare del try-catch in un'architettura solida, proteggendo il codice e gestendo errori imprevedibili in produzione.

Cosa sono le eccezioni in C# e perché è importante gestirle

Un'eccezione non gestita fa terminare bruscamente la tua applicazione. Se stai scrivendo un piccolo applicativo interno forse è accettabile, ma su scenari web ad alto traffico o sistemi mission-critical è semplicemente imperdonabile.

Immagina cosa succede quando in una catena di calcolo contabile un input utente genera una NullReferenceException. Il runtime tenta di propagare l'errore lungo il call stack, cercando affannosamente un blocco catch che se ne prenda carico.

Se non ne trova, l'esecuzione cessa. Il thread muore. I dati non confermati vanno perduti.

Il framework .NET fornisce un oggetto base fondamentale, la classe Exception. Quando questa classe, o una sua derivata, viene sollevata ("thrown"), porta con sé due informazioni critiche:

  • Il Message, che descrive l'errore in linguaggio tecnico per gli umani.
  • La StackTrace, un microscritto perfetto che ripercorre l'esatta gerarchia di esecuzione scesa fino in profondità alla riga esatta che ha fallito.

Ignorare questo potenziale informativo o peggio "inghiottirlo" (fare catch senza fare nulla) equivale a scollegare i monitor di un paziente in terapia intensiva perché il suono dell'allarme ti infastidisce.

Utilizzare try, catch e finally per gestire le eccezioni

Gestione flusso eccezioni in C#

Non sei qui per imparare la sintassi base. Ma sei qui per capire quando e come usare i tre grandi attori della gestione errori.

Usare ciecamente blocchi try catch dappertutto rende il tuo codice incredibilmente affaticante da leggere, introducendo complessità ciclomatica inutile. Se un metodo fallisce deliberatamente su un parametro nullo, lancia un'eccezione e lasciala circolare verso un livello superiore che abbia il contesto sufficiente per risolverla!

Il tuo blocco catch non dovrebbe quasi mai essere generico se non all'estremità più alta del confine (come nei middleware Web API o eventi unhandled dell'AppDomain).

try
{
    var stream = File.OpenRead("important_data.xml");
    return _parser.Process(stream);
}
catch (FileNotFoundException ex)
{
    _logger.LogWarning("Il file non è ancora disponibile. Riproveremo più tardi.");
    return null;
}
finally
{
    // Eseguito a prescindere dal successo e dal fallimento.
    _logger.LogDebug("Rilascio blocchi di I/O terminato.");
}

Il blocco finally è vitale quando operi con risorse che implementano IDisposable o quando maneggi connessioni di rete (se non utilizzi le clausole using).

La sua esecuzione garantita assicura che il sistema non lasci connessioni appese al database, non blocchi file in lettura e mantenga lo stato pulito per le successive iterazioni. Anche l'allocatore di memoria del Garbage Collector ti ringrazierà se chiuderai gli stream nei blocchi finally.

La cosa essenziale in architettura del software è creare un perimetro. Se il livello "Database" fallisce, dovrebbe emettere una specifica eccezione di Repository, non vomitare direttamente una SqlException allo strato UI. Ma di questo parliamo nel prossimo concetto: le eccezioni personalizzate.

Come creare eccezioni personalizzate in C#

Il runtime ci fornisce un vasto set di eccezioni di sistema: InvalidOperationException, ArgumentException, etc. Ma nessuna di queste esprime l'intenzione del tuo dominio di business.

Cosa succede quando un utente tenta di concludere un acquisto e la sua carta è in blacklist? Sollevare una generica Exception ti obbligherà a scendere a compromessi string comparing per capire "quale" tipo di eccezione è, un errore tremendo in architettura.

La mossa da Maestro passa per la definizione di Eccezioni Dominio-specifiche:

public class PaymentRejectedException : Exception
{
    public string TransactionId { get; }

    public PaymentRejectedException(string message, string transactionId) 
        : base(message) 
    {
        TransactionId = transactionId;
    }
}

In questo modo, stacchi nettamente il problema concettuale dal problema tecnico.

Questo cambio di paradigma significa iniziare a ragionare in ottica Domain-Driven Design (DDD). Quando le eccezioni comunicano concetti del tuo dominio (es: `CarrelloVuotoException`, `FondiInsufficientiException`) il codice diventa auto-documentante.

Il chiamante al livello Web API o interfaccia grafica (WPF, Blazor) sa esattamente come reagire solo quando intercetta la PaymentRejectedException.

Invece di mandare a video orribili stack trace (pericolose dal lato della sicurezza informatica), mostrerà una modale elegante all'utente indicandogli il preciso "TransactionId".

Integrità del dominio non significa però dover dichiarare un milione di classi di Exception nel proprio progetto. Le classi custom dovrebbero rappresentare i punti di rottura decisionali, non semplici validazioni di base mascherate sotto falso nome.

Gestire le eccezioni con il pattern try-catch-finally

Non esiste torto peggiore che uno sviluppatore C# possa fare a sé stesso (o al povero collega che debbuggerà di notte) dell'errore sintattico nella rilancio (re-throw) di un'eccezione.

Osserva queste tre righe, colpevoli di infinite notti insonni per molti programmatori:

catch (Exception ex)
{
    _logger.LogError(ex.Message);
    throw ex; // ATTENZIONE: IL CRIMINE PERFETTO
}

Scrivere throw ex; in C# istruisce il runtime a lanciare l'eccezione come se fosse nata esattamente in quella riga.

Tutta la storia pregressa. Tutto il call stack originale che indicava in quale meandro tra altri cinquanta servizi il null pointer si era verificato. Tutto spazzato via in un nulla di fatto.

L'origine dell'errore è morta per sempre e nel pannello degli errori del server vedrai solo la riga del catch. Una truffa intellettuale.

La regola d'oro, sacra in tutto .NET, è usare semplicemente la keyword senza parametri:

catch (Exception)
{
    // Fai quello che devi...
    throw; // Rilancia mantenendo integra la stack trace originale.
}

Le best practices per il logging delle eccezioni in C#

Architettura di Logging strutturato aziendale

Se hai implementato eccezioni robuste e preservato la stack trace, la terza colonna portante si declina interamente sul dove finiscono queste informazioni.

Loggare l'eccezione formattando il suo Message con un semplice `Console.WriteLine` non porterà te né il tuo team da nessuna parte quando i clienti chiederanno la testa dell'assistenza il lunedì mattina in seguito a mille errori simultanei in base dati.

Nella moderna architettura cloud-native occorre abbracciare da subito framework come Serilog e pattern come log aggregati (Seq, Application Insights, ElasticSearch). La forza propulsiva è fare Logging Strutturato:

catch (UnauthorizedAccessException ex)
{
    // NO:
    // _logger.LogError($"Errore per utente {UserId}: " + ex.ToString());
    
    // SI: Parametri interpolati nei placeholder
    _logger.LogError(ex, "Mancanza di permessi per operazione {OperationName} dall'utente {UserId}", 
        operationName, 
        user.Id);
}

In questo modo, la log platform indicizzerà istantaneamente i campi "OperationName" e "UserId". Da quel momento sarai in grado di fare interrogazioni ad altissima fedeltà:

  • Potrai creare query analitiche in Application Insights o Kibana interrogando: "Fammi vedere tutte le UnauthorizedAccessException relative all'operazione 'DeleteCustomer'".
  • Costruirai un quadro metrico robusto, aggregando errori per utente, per server o per range di orario.

Il potere del telemetry strutturato si evince nei casi di anomalie di breve respiro, come i picchi di SqlException dovute a deadlock sul database.

Avendo i parametri della query estratti asincronamente, potrai misurare la frequenza del problema e trovare l'elemento causale bloccante senza dover accedere direttamente al DB in preda al panico e senza consumare banda extra per esportazioni enormi di log grezzo.

Gestire le eccezioni asincrone in C#

L'adozione sfrenata e confusa delle pratiche di asincronia in C# (async - await) genera mostruosi artefatti quando va combinata con la gestione degli errori.

Un metodo async Task cattura le proprie eccezioni all'interno del task restituito, garantendoti l'innesco corretto di un blocco try catch invocante nel momento in cui ne applichi await al task medesimo.

Il vero cancro silente sono i metodi delegati o scritti come async void.

Cercare di racchiudere un metodo async void in un try catch del chiamante è fatica sprecata: l'eccezione sguscia lateralmente sul synchronization context primario e affossa l'intero processo senza appello, perché il chiamante non può rimanere legato all'attesa del fallimento, essendo "void" e "fire and forget".

Abituati a far ritornare Task e affida i tuoi blocchi logici al framework piuttosto che a raggiri complessi e usa async void ESCLUSIVAMENTE per i gestori eventi della UI.

L'importanza della gestione delle eccezioni nel miglioramento delle performance

Per quanto l'abuso dei `try/catch` per dirigere il business flow sia una piaga ben nota, quello che molti dimenticano è l'enorme spesa computazionale generata dal lancio di eccezioni.

Ogni volta che throw entra in gioco avviene una catena di eventi gravosi sul server:

  1. Il runtime alloca un nuovo frammento in memoria per l'oggetto eccezione.
  2. Il framework cattura i frame della chiamata originaria, scattando "istantanee" del codice in esecuzione per poter generare la stringa di stack trace.
  3. L'esecuzione salta forzosamente da un blocco di memoria a un altro fino a trovare un gestore.

Tutto questo è un processo enormemente CPU-intensive rispetto a un elementare ramo if-else.

Ed è il motivo per cui preferiamo adottare controlli pregressi per quelle situazioni fortemente prevedibili (chiamato pattern Tester-Doer).

// INAPPROPRIATO: Costoso controllo affidato a fallimento continuo
try 
{
    int year = int.Parse(yearInputString);
}
catch(FormatException) { ... }

// APPROPRIATO: Controllo preventivo che evita le Exception per i casi validi ma fuori input.
if (int.TryParse(yearInputString, out int year))
{
    // Esecuzione felice e performante.
}

Il confine è tracciato chiaramente: l'eccezione è eccezionale, non un go-to moderno per pigrizia nel controllare i dati d'ingresso.

Ogni volta che fai affidamento a `.TryParse` invece di un `try { Parse } catch`, incrementi la throughput della tua applicazione del triplo. Questa è micro-ottimizzazione architetturale che fa la differenza nei sistemi high-load in cui milioni di eventi attraversano le code in pochi minuti.

Come evitare eccezioni non gestite in C#

In .NET 10, così come nelle versioni precedenti del framework, catturare globalmente le anomalie è un compito essenziale. In ASP.NET Core è sufficiente configurare un costrutto di tipo ExceptionHandler direttamente all'interno della pipeline HTTP.

Molti programmatori tendono a disseminare decine di blocchi try-catch all'interno di Controller o Minimal API. Lo fanno spesso per pura ansia e abitudine difensiva. A quel livello, tuttavia, c'è oggettivamente pochissimo margine decisionale utile: l'interfaccia non è il luogo adatto per riparare cadute del database.

L'implementazione di un "salvagente" globale tramite middleware ribalta completamente le logiche progettuali, con due grandi benefici:

  • Il componente centralizzato intercetta dinamicamente gli errori finali imprevisti inviando in automatico e in assoluta sicurezza una risposta HTTP neutra di fallimento verso il browser del cliente (codice status 500).
  • Si elimina in un solo colpo la dannosa fuoriuscita di dettagli sistemici in produzione.

Soprattutto, si assicura all'intero team di sviluppo un abbattimento massivo del codice ripetuto nello strato espositivo, allineando pienamente il progetto web ai pattern di architettura difensiva.

Esempio pratico: Gestire le eccezioni in un'applicazione C#

Non si tratta più di "far girare il codice". Quella fase amatoriale è da superare celermente e senza voltarsi. Si tratta di ingegnerizzare le applicazioni immaginando un ambiente perennemente ostile, in cui il codice deve proteggersi per tempo.

Governare le eccezioni in questo senso non è più un rito di compilazione. Diventa un pezzo dell'architettura in C#: una componente che fa da scudo all'inconsistenza dei dati ed espone insight incredibili sullo stato di salute della logica di business.

Se sei al punto di desiderare il salto di qualità definitivo e smetterla di riavviare web application e servizi API ad ogni errore bloccante, noi insegniamo queste e altre best-practices direttamente dal vivo.

Domande frequenti

Un'eccezione è un evento anomalo che si verifica durante l'esecuzione del programma interrompendone il normale flusso. Va gestita per evitare crash improvvisi dell'applicazione.

Il blocco try contiene il codice che potrebbe generare l'eccezione. Il blocco catch cattura e gestisce l'errore se si verifica. Il blocco finally (opzionale) viene sempre eseguito alla fine, utile per rilasciare risorse.

Le eccezioni personalizzate sono classi che ereditano da Exception. Si usano quando si ha necessità di gestire errori specifici della logica di business o per aggiungere informazioni dettagliate sull'errore non previste dal framework .NET base.

L'istruzione throw; re-lancia l'eccezione preservando l'intera stack trace originale, permettendo un debug completo. Usando throw ex;, l'eccezione viene considerata nuova da quel punto, azzerando la cronologia dello stack trace originale (è una pessima pratica).

Lascia i tuoi dati nel form qui sotto

Matteo Migliore

Matteo Migliore è un imprenditore e architetto software con oltre 25 anni di esperienza nello sviluppo di soluzioni basate su .NET e nell'evoluzione di architetture applicative per imprese e organizzazioni di alto profilo.

Nel corso della sua carriera ha collaborato con realtà come Cotonella, Il Sole 24 Ore, FIAT e NATO, guidando team nello sviluppo di piattaforme scalabili e modernizzando ecosistemi legacy complessi.

Ha formato centinaia di sviluppatori e affiancato aziende di ogni dimensione nel trasformare il software in un vantaggio competitivo, riducendo il debito tecnico e portando risultati concreti in tempi misurabili.

Stai leggendo perché vuoi smettere di rattoppare software fragile.Scopri il metodo per progettare sistemi che reggono nel tempo.