Quali sono gli argomenti di C# avanzato che separano un junior da un senior?
I dieci argomenti chiave del C# avanzato sono: async/await e Task usati correttamente (niente .Result, async all the way), Span<T> e Memory<T> per lavorare sulla memoria senza allocare, LINQ avanzato (esecuzione differita, IQueryable vs IEnumerable), pattern matching, record per immutabilità e uguaglianza per valore, generics e vincoli, delegate ed eventi (e i memory leak che causano), gestione delle eccezioni mirata, nullable reference types, e performance e allocazioni misurate con BenchmarkDotNet.
Il modo più efficace per padroneggiarli non è memorizzarli, ma rileggere il proprio codice reale con queste lenti, un argomento per volta, applicandolo nel lavoro quotidiano.

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 tuttoMaterializza 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 valorewith: 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 nullUna 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.

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
È 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.
