C# avanzato: 10 argomenti da junior a senior .NET
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.

Conosco decine di sviluppatori che scrivono C# da cinque o sei anni e che, se gli chiedessi di spiegare cosa succede davvero quando mettono un await dentro un ciclo, andrebbero in difficoltà. Non perchè siano incapaci: perchè il linguaggio li ha protetti così bene che non hanno mai avuto bisogno di guardare sotto il cofano. Funziona, i test passano, la feature va in produzione. Poi arriva il giorno in cui il sistema deve reggere diecimila richieste al secondo, oppure un deadlock blocca il thread pool in produzione, e improvvisamente "funzionava in locale" non basta più.

Il C# avanzato non è una collezione di trucchi esoterici da sfoggiare al colloquio. È l'insieme dei meccanismi che separano chi usa il linguaggio da chi lo capisce. La differenza tra un junior e un senior, nella mia esperienza di formatore e di chi ha fatto centinaia di code review, non sta nella conoscenza della sintassi LINQ o nel sapere cosa fa async. Sta nel sapere quando una scelta apparentemente innocua introduce un'allocazione inutile, un comportamento bloccante, o un bug di concorrenza che si manifesta solo sotto carico.

In questo articolo affronto i dieci argomenti di C# avanzato che fanno questa differenza. Per ciascuno ti do il concetto, il motivo per cui conta, e un micro-esempio concreto in .NET. Non troverai file interi di codice: troverai le poche righe che illustrano il punto, perchè il valore non è nel copiare codice ma nel capire il meccanismo. Se sei un developer .NET che vuole smettere di "scrivere codice che funziona" e iniziare a scrivere codice che regge, questa è la lista che avrei voluto avere io quando ho fatto il salto.

1. async/await e Task: cosa succede davvero (e gli errori che pagano caro)

L'async/await è probabilmente la feature più usata e meno capita di tutto il C# avanzato. La maggior parte dei developer la tratta come "magia che rende il codice non bloccante", e fino a un certo punto funziona. Il problema è che gli errori in quest'area non si vedono in locale: si vedono in produzione, sotto carico, sotto forma di thread pool starvation e deadlock.

async non significa parallelo

Il primo concetto che separa junior e senior: async riguarda l'efficienza nell'attesa di I/O, non l'esecuzione parallela. Quando fai await su una chiamata HTTP, il thread non resta bloccato ad aspettare: viene restituito al thread pool e può servire altre richieste. Il metodo riprende quando la risposta arriva. Non c'è nessun nuovo thread coinvolto. Confondere concorrenza I/O-bound e parallelismo CPU-bound è l'errore concettuale più frequente.

Il peccato mortale: bloccare su codice async

Chiamare .Result o .Wait() su un Task per "trasformarlo in sincrono" è la causa numero uno di deadlock in ASP.NET. In presenza di un SynchronizationContext il thread che aspetta il risultato è lo stesso che dovrebbe completare il task: si bloccano a vicenda.

// MALE: rischio deadlock e thread bloccato
var data = httpClient.GetStringAsync(url).Result;

// BENE: async fino in fondo
var data = await httpClient.GetStringAsync(url);

La regola è "async all the way": se un metodo chiama codice asincrono, deve essere asincrono a sua volta, fino al punto di ingresso (controller, handler, Main).

ConfigureAwait, ValueTask e parallelismo controllato

Nelle librerie usa ConfigureAwait(false) per non catturare il contesto e ridurre l'overhead. Quando un metodo spesso completa in modo sincrono (cache hit), valuta ValueTask per evitare l'allocazione di un Task. E quando vuoi davvero eseguire più operazioni I/O insieme, non usare un foreach con await dentro: avvia i task e poi await Task.WhenAll(...). Il primo caso serializza, il secondo parallelizza l'attesa.

2. Span<T> e Memory<T>: lavorare con la memoria senza allocare

Per anni in C# l'unico modo per "guardare una porzione" di un array o di una stringa era crearne una copia: Substring, Skip().Take(), slicing manuale. Ogni copia è un'allocazione sull'heap, e ogni allocazione è lavoro per il garbage collector. Span<T> ha cambiato le regole: rappresenta una finestra su memoria già esistente, senza copiarla.

Il concetto: una vista, non una copia

Uno Span<T> è una struttura che contiene un riferimento e una lunghezza. Affettarlo non alloca nulla. Questo è il motore dietro le ottimizzazioni di performance del .NET moderno: parsing, serializzazione e formattazione lavorano su span per evitare allocazioni intermedie.

ReadOnlySpan<char> testo = "2026-06-02";
ReadOnlySpan<char> anno = testo.Slice(0, 4); // nessuna allocazione
int valore = int.Parse(anno);

Il vincolo che spaventa i junior: lo stack

Span<T> è un ref struct: vive solo sullo stack. Non puoi metterlo in un campo di classe, in una collezione, in una lambda che lo cattura, nè usarlo a cavallo di un await. Quando ti serve la stessa semantica ma con la possibilità di sopravvivere su heap (ad esempio attraverso codice async), usi Memory<T>, che è la controparte heap-friendly e da cui puoi ottenere uno span con .Span quando serve.

Non è uno strumento di tutti i giorni: lo usi nei percorsi caldi, nel parsing ad alto volume, nei buffer di rete. Ma sapere che esiste, e perchè esiste, è esattamente il tipo di consapevolezza che distingue chi ottimizza con cognizione da chi spara ToList() a caso.

3. LINQ avanzato: oltre Where e Select

Tutti sanno usare Where e Select. Il C# avanzato in ambito LINQ riguarda tre cose che la maggior parte ignora: la differenza tra esecuzione differita e immediata, il costo nascosto delle enumerazioni multiple, e la trappola del provider quando lavori con Entity Framework.

Esecuzione differita: il query non è ancora girato

Una query LINQ non viene eseguita quando la definisci, ma quando la enumeri. Questo è potente (componi la query in più passaggi) ma pericoloso: se enumeri la stessa IEnumerable due volte, la query gira due volte. Su una query database significa due round-trip.

var query = ordini.Where(o => o.Totale > 100); // non esegue nulla
var count = query.Count();   // prima enumerazione
foreach (var o in query) { } // seconda enumerazione: rigira tutto

Materializza con ToList() quando sai che userai i dati più volte. Lascia differito quando passi la query a un altro metodo che la comporrà ulteriormente.

IQueryable vs IEnumerable: dove gira il codice

Con Entity Framework, finchè lavori su IQueryable il codice viene tradotto in SQL ed eseguito dal database; nel momento in cui passi a IEnumerable (ad esempio con AsEnumerable()) il resto gira in memoria sul tuo processo. Un metodo personale dentro un Where su IQueryable che EF non sa tradurre o fa fallire la query o, peggio, scarica tutta la tabella in memoria. Sapere esattamente dove avviene il confine tra database e processo è una competenza da senior.

Tecniche da padroneggiare: GroupBy con proiezioni aggregate, SelectMany per appiattire gerarchie, le overload di Aggregate, e gli operatori a finestra introdotti di recente come Chunk. Per chi vuole strutturare bene il proprio codice .NET vale la pena vedere come questi si combinano con i principali design pattern in C#.

4. Pattern matching: il C# che non sembra C#

Il pattern matching è la feature che, articolo dopo articolo, ha trasformato il modo in cui si scrive logica condizionale in C#. Chi è rimasto fermo a if (x is Tipo) { var y = (Tipo)x; ... } sta scrivendo C# di dieci anni fa.

Dalla verifica di tipo allo switch espressione

Lo switch espressione, combinato con i pattern, sostituisce intere catene di if/else con codice dichiarativo e, soprattutto, esaustivo: il compilatore ti avvisa se non hai coperto tutti i casi.

decimal sconto = cliente switch
{
    { Anni: > 10, Premium: true } => 0.30m,
    { Premium: true }             => 0.15m,
    { Anni: >= 5 }                => 0.10m,
    _                             => 0m
};

Property pattern, relational pattern, list pattern

Il property pattern ({ Stato: "Attivo" }) interroga le proprietà; i relational pattern (> 10, <= 0) permettono confronti; i logical pattern (and, or, not) li combinano. I list pattern recenti consentono di destrutturare collezioni ([primo, .., ultimo]). Il valore non è la sintassi compatta: è che il codice esprime l'intento invece del meccanismo, ed è verificabile dal compilatore.

5. Record: immutabilità e uguaglianza per valore senza boilerplate

Prima dei record, scrivere un tipo immutabile con uguaglianza per valore significava scrivere a mano costruttore, proprietà readonly, override di Equals, GetHashCode, operatori, ToString. Decine di righe ripetitive e una fonte costante di bug. I record fanno tutto questo per te.

Uguaglianza per valore, non per riferimento

Due record con gli stessi valori sono uguali, anche se sono istanze diverse: questa è la differenza chiave rispetto a una classe, dove l'uguaglianza è per riferimento. È esattamente la semantica che vuoi per i Value Object del Domain-Driven Design, per i DTO e per i messaggi.

public record Indirizzo(string Via, string Citta, string Cap);

var a = new Indirizzo("Via Roma 1", "Milano", "20100");
var b = new Indirizzo("Via Roma 1", "Milano", "20100");
bool uguali = a == b; // true: confronto per valore

with: copia non distruttiva

L'espressione with crea una copia modificando solo i campi indicati, lasciando l'originale intatto. È il pattern dell'immutabilità: non muti lo stato, ne produci una nuova versione. Da conoscere anche la distinzione tra record class (riferimento) e record struct (valore), e quando preferire l'uno all'altro in base alla dimensione e alla semantica del dato.

6. Generics avanzati e vincoli: scrivere codice riusabile e sicuro

Tutti hanno usato List<T>. Pochi sanno progettare API generiche proprie con i vincoli giusti. I generics avanzati sono ciò che ti permette di scrivere codice riusabile senza rinunciare alla sicurezza dei tipi e senza pagare il costo del boxing.

I vincoli definiscono il contratto

La clausola where dice al compilatore (e a chi legge) cosa può fare il tipo generico. Senza vincoli, T è poco più di un object; con i vincoli giusti diventa uno strumento espressivo.

public T Crea<T>() where T : class, IEntita, new()
{
    var entita = new T();
    entita.Inizializza();
    return entita;
}

Covarianza, controvarianza e static abstract

I modificatori in e out sulle interfacce generiche (covarianza e controvarianza) spiegano perchè un IEnumerable<Gatto> è assegnabile a un IEnumerable<Animale>. È il tipo di dettaglio che non scrivi ogni giorno ma che, quando un'assegnazione "stranamente non compila", ti fa capire all'istante il motivo. Il C# moderno ha aggiunto anche i membri static abstract nelle interfacce, che abilitano la cosiddetta generic math: poter scrivere un metodo generico che somma T indipendentemente dal fatto che sia int, decimal o un tuo tipo numerico.

7. Delegate, eventi e funzioni: il cuore funzionale di C#

Delegate ed eventi sono nel linguaggio dal primo giorno, eppure restano un'area dove i confini concettuali sono sfumati per molti. Capire come si incastrano Func, Action, eventi e closure è parte integrante del C# avanzato.

Un delegate è un puntatore a metodo tipizzato

Func<int, int> e Action<string> sono delegate generici predefiniti: il primo ritorna un valore, il secondo no. Passare comportamento come parametro è ciò che rende possibile LINQ, le strategie configurabili, i callback. È anche la base di molti pattern: vale la pena vedere come si confronta un approccio a delegate con implementazioni classiche come il pattern Singleton in C#.

Eventi e il rischio dimenticato: i memory leak

Gli eventi sono delegate con incapsulamento: chi è esterno può solo iscriversi e disiscriversi, non invocare. Il bug classico da senior da evitare: se un oggetto si iscrive a un evento di un oggetto a vita più lunga e non si disiscrive, il subscriber non viene mai raccolto dal garbage collector. È una delle cause più subdole di memory leak in applicazioni di lunga durata, tipica in WPF e nei servizi. La closure che cattura una variabile aggiunge un ulteriore livello: capire cosa viene catturato e per quanto è competenza da senior.

8. Gestione delle eccezioni: oltre il try/catch generico

La gestione delle eccezioni è dove si vede subito la maturità di un developer. Il junior mette catch (Exception) ovunque e ingoia tutto; il senior sa cosa catturare, dove, e soprattutto cosa non catturare.

Catturare solo ciò che sai gestire

Un catch serve a recuperare da una condizione che sai trattare. Se non sai cosa fare di un'eccezione, lasciala salire: ingoiarla nasconde il problema e produce stati corrotti silenziosi. E quando rilanci, usa throw; e non throw ex;, perchè il secondo azzera lo stack trace e ti fa perdere il punto d'origine.

try { ElaboraOrdine(); }
catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 2627 })
{
    // gestisco solo la violazione di chiave duplicata
    LogDuplicato(ex);
}

Exception filter e il costo reale

La clausola when (exception filter) decide se entrare nel catch senza svolgere lo stack: utile per loggare o filtrare con precisione. Da ricordare anche che le eccezioni sono costose: non vanno usate per il controllo di flusso normale. Validare un input con un TryParse è giusto; usare un try/catch per sapere se una stringa è un numero è un anti-pattern di performance.

9. Nullable reference types: spegnere il NullReferenceException

Il NullReferenceException è stato definito da Tony Hoare, che lo inventò, "l'errore da un miliardo di dollari". I nullable reference types sono la risposta di C# a questo problema: portano l'analisi della nullabilità dentro il compilatore.

Il tipo dichiara se può essere null

Con la nullability abilitata, string significa "mai null" e string? significa "può essere null". Il compilatore traccia il flusso e ti avvisa quando dereferenzi qualcosa che potrebbe essere null senza averlo controllato.

string nome = null;       // warning: assegni null a non-nullable
string? cognome = null;   // ok, dichiarato nullable
int lunghezza = cognome.Length; // warning: possibile dereferenziazione null

Una promessa del compilatore, non una garanzia a runtime

I nullable reference types sono un'analisi statica a tempo di compilazione: non aggiungono controlli a runtime e i dati che arrivano da JSON, database o codice esterno possono comunque essere null nonostante il tipo dica il contrario. Per questo l'operatore ! (null-forgiving) va usato con parsimonia: stai dicendo al compilatore "fidati di me", e se ti sbagli torni esattamente al bug che volevi eliminare. Abilitare la nullability su una codebase esistente, file per file, è uno dei refactoring a più alto ritorno che conosca.

10. Performance e allocazioni: pensare come la macchina

L'ultimo argomento è quello che lega tutti gli altri. Un senior C# ha sempre in testa una domanda che il junior non si pone: questa riga alloca? E se sì, quanto spesso gira?

Heap, stack e pressione sul garbage collector

Ogni new di una classe, ogni boxing di un value type, ogni stringa concatenata in un ciclo finisce sull'heap e prima o poi diventa lavoro per il garbage collector. Le pause del GC sono il nemico silenzioso della latenza. Strumenti come StringBuilder al posto della concatenazione, struct per dati piccoli e a vita breve, e i pool di oggetti (ArrayPool<T>) servono esattamente a ridurre questa pressione.

Misurare, non indovinare

La regola d'oro: non ottimizzare a sensazione. Usa BenchmarkDotNet per misurare tempo e allocazioni prima e dopo una modifica. Un senior non dice "questo dovrebbe essere più veloce": mostra i numeri.

// concatenazione in ciclo: N stringhe allocate
var s = "";
foreach (var r in righe) s += r;

// StringBuilder: un solo buffer riusato
var sb = new StringBuilder();
foreach (var r in righe) sb.Append(r);

La performance non è premaure ottimizzazione di ogni riga: è sapere dove sono i percorsi caldi e applicare lì la conoscenza degli altri nove argomenti. Span per evitare copie, ValueTask per evitare task, record struct per evitare allocazioni: tutto torna qui.

Sviluppatore senior analizza codice C# avanzato con async await e gestione della memoria su due monitor

Come usare questa lista per fare il salto da junior a senior

Dieci argomenti sono tanti, e il rischio è studiarli come una lista della spesa da memorizzare. Non funziona così. Il modo giusto è prendere il tuo codice attuale e rileggerlo con queste lenti, una alla volta.

Parti dal codice che hai già scritto

Apri un progetto reale e cerca: dove blocco su codice async? Dove enumero due volte la stessa query? Dove ho un catch (Exception) che ingoia tutto? Dove concateno stringhe in un ciclo? Ogni risposta è un'occasione di apprendimento concreto, molto più efficace di un esercizio teorico. La differenza tra junior e senior si costruisce su codice reale, non su tutorial.

Approfondisci un argomento per volta

Scegli l'argomento dove sei più debole, dedicaci una settimana, applicalo nel lavoro quotidiano. La settimana dopo passa al successivo. In dieci settimane hai coperto tutto, ma soprattutto lo hai interiorizzato applicandolo. Per chi vuole un percorso guidato e non solitario, una formazione strutturata sul C# accorcia drasticamente i tempi: vedere i meccanismi spiegati da chi li usa in produzione, con esempi del mercato italiano, evita mesi di tentativi ed errori.

Verifica con le domande giuste

Saprai di aver fatto il salto quando, davanti a una scelta di design, non ti chiederai più solo "funziona?" ma "cosa alloca, cosa blocca, cosa rende il codice più chiaro tra sei mesi?". Quella domanda, fatta in automatico, è la mentalità del senior. Gli altri nove argomenti diventano gli strumenti per rispondere.

Domande frequenti

Sono concetti diversi che vengono spesso confusi. async/await riguarda l'efficienza nell'attesa di operazioni I/O-bound (chiamate HTTP, query al database, lettura file): quando fai await il thread non resta bloccato ad aspettare, ma viene restituito al thread pool per servire altre richieste, e il metodo riprende quando l'operazione è completata, senza coinvolgere nuovi thread. Il parallelismo, invece, riguarda l'esecuzione contemporanea di lavoro CPU-bound su più thread (tipicamente con Task.Run, Parallel.ForEach o PLINQ). Usare async per lavoro CPU-intensivo non porta benefici e spesso peggiora le cose. La regola pratica: async per I/O, parallelismo per calcolo. Confondere i due è l'errore concettuale più comune tra chi non padroneggia il C# avanzato.

In presenza di un SynchronizationContext (tipico in applicazioni ASP.NET classiche, WPF e WinForms), il thread che chiama .Result o .Wait() resta bloccato in attesa del completamento del Task. Ma la continuazione del metodo async, dopo l'await, ha bisogno proprio di quel contesto per riprendere: il thread bloccato non può eseguirla e il task non si completa mai. Si bloccano a vicenda. La soluzione corretta è il principio async all the way: se un metodo chiama codice asincrono deve essere a sua volta asincrono, fino al punto di ingresso dell'applicazione. Nelle librerie, usare ConfigureAwait(false) riduce il rischio perchè evita di catturare il contesto.

Span conviene quando devi lavorare su porzioni di dati esistenti senza allocare copie sull'heap: parsing ad alto volume, manipolazione di buffer di rete, elaborazione di stringhe nei percorsi caldi del codice. Affettare uno Span non alloca nulla, a differenza di Substring o Skip().Take() che creano nuovi oggetti. Non è uno strumento di tutti i giorni: nella logica di business normale non porta vantaggi misurabili e aggiunge complessità per via dei suoi vincoli (è un ref struct, vive solo sullo stack, non può essere usato a cavallo di un await nè catturato in una lambda). Quando ti serve la stessa semantica ma con la possibilità di sopravvivere su heap, usi Memory. La regola: misura prima con BenchmarkDotNet, e introduci Span solo dove le allocazioni sono un problema reale.

È una delle distinzioni più importanti del C# avanzato quando lavori con Entity Framework. Finchè una query è di tipo IQueryable, gli operatori LINQ che applichi vengono tradotti in SQL ed eseguiti dal database: filtri, proiezioni e ordinamenti girano lato server, e arrivano in memoria solo i dati richiesti. Nel momento in cui la query diventa IEnumerable (ad esempio chiamando AsEnumerable() o ToList()), tutto il codice successivo gira in memoria nel tuo processo. Il rischio concreto: usare un metodo personalizzato dentro un Where su IQueryable che EF non sa tradurre può far fallire la query oppure, peggio, scaricare l'intera tabella in memoria prima di filtrare. Sapere esattamente dove cade il confine tra database e processo è una competenza da senior developer.

No, e capirlo è fondamentale. I nullable reference types sono un'analisi statica che avviene a tempo di compilazione: il compilatore traccia il flusso e ti avvisa con un warning quando dereferenzi qualcosa che potrebbe essere null senza averlo controllato. Ma non aggiungono alcun controllo a runtime. Questo significa che i dati provenienti da fonti esterne (deserializzazione JSON, query al database, codice di librerie non annotate, reflection) possono comunque essere null nonostante il tipo dichiari il contrario. L'operatore null-forgiving (!) va usato con parsimonia perchè dice al compilatore di fidarsi senza verificare. I nullable reference types riducono drasticamente i NullReferenceException spostando il problema dal runtime alla compilazione, ma non lo eliminano del tutto: restano fondamentali i controlli sui confini del sistema.

I record convengono quando ti serve un tipo con uguaglianza per valore e immutabilità senza scrivere boilerplate. Due record con gli stessi valori sono considerati uguali (a == b restituisce true), mentre due istanze di una class sono uguali solo se sono lo stesso riferimento in memoria. Questa è esattamente la semantica che vuoi per i Value Object del Domain-Driven Design, per i DTO, per i messaggi e per qualsiasi dato che rappresenti un valore e non un'identità. I record generano automaticamente Equals, GetHashCode, ToString e supportano l'espressione with per creare copie modificando solo alcuni campi, senza mutare l'originale. Usa una class quando il tuo oggetto ha un'identità propria che persiste anche se cambiano i suoi dati (tipicamente le entità di dominio con un Id), e un record quando ciò che conta è il valore.

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.