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.

Andrea ha sette anni di C#.
Tre applicazioni web in produzione.
La scorsa settimana il server è andato giù un venerdì sera.
Non c'era un messaggio di errore.
Non c'era un'eccezione visibile nei log.
C'era solo un'applicazione ferma, cinquanta utenti bloccati, e lui in call con il DBA cercando qualcosa che non stava nel database.
Il problema non era la competenza di Andrea.
Era il codice che passa tutti i test e poi tradisce in produzione: un insieme di assunzioni che nessuno ha scritto perché nessuno ha mai pensato di scriverle.
Assunzioni che reggono finché il sistema è piccolo.
Che cedono quando le richieste concorrenti raddoppiano, quando la tabella cresce, quando un'integrazione esterna comincia a rispondere più lentamente del previsto.
Devo dirti una cosa: non è colpa tua.
Il modo in cui si impara a costruire applicazioni web in C# insegna a costruire codice che funziona.
Non insegna a fare le domande che rendono visibili i problemi prima che arrivino.
Quello si impara dopo, sulla propria pelle, in produzione, nel momento peggiore.
Quello che segue sono le domande che dividono le applicazioni che reggono da quelle che cedono.
Non sono trucchi avanzati.
Sono il tipo di domanda che uno sviluppatore esperto si fa in automatico mentre scrive il codice, e che chi ha la stessa esperienza ma senza quella formazione specifica non sa ancora che dovrebbe farsi.
Imparare C# significa anche capire perché il sistema smette di rispondere
Sai già che i thread nel pool del server sono una risorsa finita.
Sai già che ogni richiesta HTTP concorrente ne occupa uno mentre viene processata.
Sai che se tutti i thread sono occupati, le nuove richieste aspettano.
Ed è esattamente per questo che un deadlock asincrono non si vede come errore: si vede come silenzio.
La maggior parte degli sviluppatori crede che usare operazioni asincrone nel codice sia sufficiente a evitare questo problema.
In realtà il meccanismo funziona solo se la catena è continua dall'operazione esterna fino al punto di ingresso della richiesta.
Un singolo punto che forza il risultato sincrono di un'operazione asincrona può bloccare l'intero pool sotto carico concorrente, senza produrre nessun messaggio d'errore.
In un'applicazione ASP.NET Core, il meccanismo asincrono esiste per liberare il thread durante l'attesa di operazioni esterne: chiamate al database, risposte da API di terze parti, letture da file.
Libera il thread, che può servire un'altra richiesta nel frattempo.
Questo funziona solo se l'attesa è fatta nel modo corretto.
Ci sono due modi di ottenere il risultato di un'operazione asincrona, e producono comportamenti radicalmente diversi.
Il primo dice al thread di bloccarsi finché l'operazione non completa: niente può usarlo nel frattempo, e sotto certi contesti di sincronizzazione questa attesa diventa permanente perché la continuazione non può riprendere finché il thread non si libera, ma il thread non si libera perché aspetta la continuazione.
Il secondo dice al thread di tornare disponibile e di essere richiamato solo quando c'è un risultato.
La differenza visiva nel codice è minima.
La differenza nel comportamento sotto cinquanta richieste concorrenti è la differenza tra un'applicazione che risponde e una che si blocca senza un messaggio d'errore.
Il problema non si manifesta mai in locale, dove le richieste simultanee sono tre invece di cinquanta.
Si manifesta in produzione, sotto carico reale.
La domanda da porsi non è "uso operazioni asincrone?" ma "la catena è continua dall'operazione esterna fino al punto di ingresso della richiesta, senza interruzioni nel mezzo?"
Ogni punto in cui si forza il risultato sincrono di un'operazione asincrona è un'interruzione potenziale.
Il numero di questi punti in una codebase di media dimensione è spesso più alto di quanto ci si aspetti, perché si accumulano nel tempo, in commit diversi, da sviluppatori diversi, ciascuno convinto che nel proprio caso fosse trascurabile.
Una ricerca nel codice richiede pochi minuti: la lista che emerge di solito è più lunga del previsto.
Nelle librerie, non nelle applicazioni, aggiungere una configurazione specifica dopo ogni attesa evita di catturare il contesto di sincronizzazione per la ripresa.
Nelle applicazioni ASP.NET Core non è necessario perché il framework non ne imposta uno.
C'è anche il rovescio: quando si vogliono eseguire più chiamate esterne in parallelo, il modo sbagliato è aspettare il risultato di ognuna prima di avviare la successiva.
Quello le serializza.
Il modo corretto è avviarle tutte contemporaneamente, poi aspettarle tutte insieme.
Su cento chiamate da 100ms ciascuna, la differenza tra i due approcci è tra dieci secondi e 100ms.
In un endpoint che aggrega dati da più sorgenti, quella differenza è visibile all'utente.
Un limite esiste: avviare migliaia di operazioni senza controllo può saturare risorse tanto quanto il problema originale.
Il numero ottimale dipende dal contesto specifico.
Programmazione C# avanzata e le copie che rallentano i percorsi

Ogni operazione che sembra leggere produce in realtà un oggetto nuovo in memoria.
Ogni oggetto nuovo è memoria che deve essere recuperata.
Ed è esattamente per questo che il problema non si vede su una singola operazione: si vede quando quella stessa operazione viene ripetuta centomila volte al secondo.
La maggior parte degli sviluppatori crede che allocare oggetti temporanei sia un costo trascurabile.
In realtà nei percorsi caldi, il parsing di ogni header HTTP, la validazione di ogni campo in ogni messaggio da una coda, la lettura di ogni record da un flusso ad alta velocità, la somma di quelle allocazioni produce pressione sul gestore della memoria.
Quella pressione si traduce in pause nell'esecuzione che arrivano nei momenti che non si controllano.
Esiste un'alternativa che non crea copie: restituisce invece una vista sulla stessa memoria originale, aggiornando solo un puntatore di inizio e una lunghezza.
Il dato non si muove da nessuna parte.
Estrarre tre porzioni diverse di una stringa con questo approccio non produce tre oggetti: produce tre riferimenti alla stessa stringa, ciascuno con la propria finestra.
Il vincolo è preciso.
Questa struttura:
- vive sullo stack, non nell'heap
- non può essere memorizzata in un campo di classe
- non può essere catturata da una funzione anonima
- non può essere usata a cavallo di un'operazione asincrona
Per i casi in cui serve attraversare quei confini, esiste una variante che mantiene la stessa semantica senza copia ma senza i vincoli di posizione.
Non è uno strumento per il codice di business ordinario.
Lo si usa dove il costo delle allocazioni diventa un problema concreto e misurabile: non per intuizione, non per precauzione, ma dopo aver quantificato con uno strumento di analisi che quel percorso specifico è il punto critico.
Riconoscere una copia implicita in un esempio costruito è semplice.
Riconoscerla nel codice che hai scritto tu, su una codebase reale con variabili che hanno nomi di dominio e una catena di dipendenze che conosci a metà, è un altro tipo di lavoro.
Nel Corso C#, si parte dalla tua codebase, non da esercizi.
C# e la domanda che non stai facendo al database
Il database sa filtrare.
Sa ordinare, aggregare, unire.
Sa farlo su centinaia di milioni di righe in qualche centinaio di millisecondi se ha gli indici giusti.
Il server web che ospita l'API, invece, ha la RAM che hai configurato e non un byte di più.
Ed è esattamente per questo che portare i dati nel processo prima di filtrarli non è un'inefficienza stilistica: è un problema strutturale che si vede solo quando la tabella ha già cresciuto.
La maggior parte degli sviluppatori crede che usare un ORM garantisca che il filtro avvenga sul database.
In realtà dipende da un dettaglio non visibile a colpo d'occhio: il tipo della sequenza con cui si sta lavorando.
Finché si opera su un tipo che l'ORM sa tradurre in SQL, le operazioni vengono eseguite dal database.
Nel momento in cui quella sequenza diventa un tipo diverso, tutto ciò che segue avviene nel processo applicativo.
I dati attraversano la rete integralmente, occupano RAM, e quelli che non servono vengono scartati.
Il codice legge come se stesse filtrando sul database.
Non lo sta facendo.
Questo è esattamente quello che è successo ad Andrea.
Il venerdì sera, il problema non stava nel database.
Stava in un endpoint che chiamava un metodo di utilità che accettava il tipo sbagliato.
Il passaggio da IQueryable a IEnumerable era avvenuto prima del filtro: il database aveva restituito l'intera tabella, trecentomila righe, al processo.
Con dieci utenti simultanei e una tabella piccola, non si era mai visto.
Con cinquanta utenti e una tabella cresciuta in un anno di produzione, il server era andato in out-of-memory senza un messaggio comprensibile.
Il confronto tra i due tipi chiarisce l'entità del problema:
| IQueryable | IEnumerable | |
|---|---|---|
| Dove avviene il filtro | Sul database, con gli indici | Nel processo applicativo, in RAM |
| Dati trasferiti sulla rete | Solo quelli che soddisfano il filtro | L'intera tabella |
| Con tabelle piccole | Identico a occhio | Identico a occhio |
| Con tabelle cresciute | Stabile | Out-of-memory o lentezza grave |
Il passaggio avviene ogni volta che si usa un metodo che l'ORM non sa tradurre, o che si passa la sequenza a un'altra funzione nel modo sbagliato.
Con tabelle piccole, nessun problema visibile.
Quando la tabella ha cresciuto, il costo si vede.
Un aspetto separato ma ugualmente nascosto: la stessa query può essere eseguita due volte se la si enumera in due punti diversi del codice.
La query non viene eseguita quando la si definisce: viene eseguita quando si iterano i risultati.
Due iterazioni sono due round-trip al database.
Materializzare i risultati la prima volta, quando si sa che serviranno più di una volta, è evitare un round-trip gratuito.
Al di là di questi due problemi principali, ci sono tecniche che completano il quadro.
Raggruppamenti con valori aggregati: la domanda non è "so usare GroupBy?" ma "questo raggruppamento avviene sul database o nel processo?"
Appiattimento di strutture gerarchiche: un ciclo for annidato su una lista che contiene una lista produce codice che funziona ma non dice chiaramente cosa fa; esiste un modo di esprimere lo stesso intento in una singola operazione dichiarativa.
Riduzione a un singolo valore: quando il risultato è un numero calcolato su tutta la sequenza con logica specifica, esiste un'operazione che lo esprime direttamente, senza variabile di accumulazione e senza ciclo.
Il codice che conosci ha costi che non hai ancora misurato.
Quando prenoterai la call gratuita, la prima cosa che facciamo è guardare il tuo codice specifico, non un esercizio costruito per l'occasione.
Sviluppare in .NET significa lavorare con strumenti che possono mascherare questi costi fino a quando il sistema non raggiunge la dimensione in cui diventano visibili.
Il percorso di sviluppo web di Sviluppatore Migliore parte da qui: identifica le lacune concrete nel tuo codebase, poi lavora direttamente su quello.
Non slide.
Non pattern astratti.
Il codice che stai già consegnando in produzione.
Corso programmazione C# e il caso che lo switch classico non copre
Stai aggiungendo un nuovo caso a un tipo esistente.
Hai aggiornato tutte le implementazioni che riesci a trovare.
Hai rilasciato.
Tre settimane dopo, in produzione, arriva un errore che non riesci a spiegare: un percorso del codice che non gestisce quel caso perché non sapevi che ci fosse.
Ed è esattamente per questo che il bug del ramo mancante è uno dei più difficili da trovare: non esiste finché non esiste il dato che lo rivela.
La maggior parte degli sviluppatori crede che lo switch classico e la catena di condizioni siano equivalenti a un'espressione di selezione esaustiva, con in più la flessibilità di non dover coprire ogni caso.
In realtà quella flessibilità ha un costo preciso: il compilatore non può verificare l'esaustività.
Il ramo mancante emerge solo a runtime, solo nel caso specifico che manca, spesso solo in produzione.
Un'espressione di selezione su un tipo chiuso, se non copre ogni caso possibile, produce un errore prima della build.
Non a runtime: durante la compilazione.
Ogni volta che si aggiunge un nuovo caso al tipo, il compilatore segnala tutti i punti del codice che non lo gestiscono ancora.
Si esprime un'assunzione (questi sono tutti i casi possibili) e lo strumento di sviluppo la fa rispettare nel tempo.
Ogni modifica futura che aggiunge un caso deve passare da tutti i punti dove quella logica è applicata: non si può dimenticare un caso senza che emerga prima del rilascio.
I confini con il mondo esterno, dati da deserializzazione, risposte da API di terze parti, input utente, rimangono fuori da questa protezione: lì i controlli espliciti sono necessari indipendentemente da quello che dichiara il tipo.
Questo approccio protegge la logica interna, non l'ingresso dei dati.
Come imparare C# e capire quando due istanze identiche non sono uguali per il codice
Hai un dizionario.
Cerchi una chiave che sai di aver inserito.
Il dizionario non la trova.
Inserisci la chiave di nuovo.
Ora ce ne sono due, identiche visivamente, entrambe nel dizionario.
Il dizionario le tratta come diverse perché il codice non ha mai detto cosa significa "uguali" per quel tipo.
Ed è esattamente per questo che questo bug non appare nei test: i test confrontano raramente istanze separate con gli stessi dati.
La risposta predefinita per le classi è: due oggetti sono uguali solo se sono lo stesso oggetto in memoria.
Due istanze separate che contengono esattamente gli stessi dati non sono uguali, a meno che non si sia scritto esplicitamente il codice per dirlo.
Per un'entità con un identificatore, questa semantica è corretta: se l'ID cambia è un'entità diversa.
Ma per un tipo che rappresenta un valore, un indirizzo, un intervallo di date, un codice fiscale, la semantica corretta è l'opposta.
Due oggetti con gli stessi dati sono la stessa cosa.
Quando si usa una classe per un tipo di questo genere senza definire l'uguaglianza, il comportamento che si ottiene è sbagliato rispetto alla semantica del tipo.
Il risultato sono bug sottili che si manifestano lontano dalla causa: dizionari che non trovano chiavi, set con duplicati, confronti che restituiscono risultati sbagliati.
I record risolvono questo per costruzione: l'uguaglianza per valore è predefinita e corretta.
Forniscono anche un modo di produrre versioni modificate di un'istanza specificando solo i campi che cambiano, senza mutare quella originale.
Questa operazione elimina una categoria di bug che nasce da stato condiviso modificato in modo non intenzionale.
Per i tipi che invece hanno un'identità, un account, un ordine, una sessione, la semantica per riferimento della classe è quella giusta.
Esiste anche una variante che vive sullo stack invece che in memoria dinamica: appropriata per tipi molto piccoli e creati ad alta frequenza dove ridurre le allocazioni ha un impatto misurabile.
Questo tipo di problema passa quasi sempre indenne nelle code review, perché il codice sembra corretto.
Si trova in produzione, quando il comportamento diverge da quello atteso e il collegamento con la causa è già difficile da ricostruire.
Sapere dove cercare mentre si scrive è una delle cose su cui si lavora nel Corso C#.
Corso C# avanzato e il vincolo implicito che diventa un bug un anno dopo

Un metodo generico funziona.
Il suo autore originale sapeva che il tipo doveva implementare una certa interfaccia.
Non lo ha scritto nella firma.
Un anno dopo, un collega usa quel metodo con un tipo diverso.
La build passa.
Il comportamento a runtime è sbagliato in modo non ovvio.
Ed è esattamente per questo che i vincoli sui generici non sono un dettaglio tecnico: sono documentazione che il compilatore fa rispettare.
I vincoli trasformano le assunzioni implicite in parte della firma del metodo.
Se il metodo richiede che il tipo implementi una certa interfaccia, che sia un tipo riferimento, che abbia un costruttore senza parametri, dichiararlo cambia due cose: chi chiama il metodo riceve un errore chiaro se non rispetta le aspettative, e chi legge il metodo capisce le aspettative dalla firma stessa invece di doverle inferire dall'implementazione.
Ogni vincolo che si omette è un'assunzione che esiste solo nella testa di chi ha scritto il codice.
In una codebase condivisa tra più sviluppatori che cambia nel tempo, quella assunzione non è da nessuna parte.
I vincoli si combinano: si può richiedere contemporaneamente che il tipo sia un tipo riferimento, implementi una certa interfaccia, e abbia un costruttore senza parametri.
Alcuni vincoli si escludono a vicenda.
Capire come si combinano permette di esprimere aspettative precise senza restrizioni inutili.
Hai mai visto un errore su un'assegnazione tra tipi generici che non riuscivi a spiegare?
Di solito è varianza.
Una collezione di elementi più specifici non è automaticamente assegnabile a una collezione dello stesso tipo ma con il tipo base: dipende da come l'interfaccia ha dichiarato la varianza del suo parametro di tipo.
Capire questo meccanismo trasforma un errore incomprensibile in una spiegazione precisa in trenta secondi invece di un'ora.
C# e quello che rimane in memoria quando chiudi la finestra
La memoria di un oggetto viene recuperata quando non esiste più nessun riferimento attivo a quell'oggetto.
L'oggetto può essere "chiuso" logicamente, uscito dallo scope, rimosso dalla vista: se da qualche altra parte nel codice esiste ancora un riferimento, rimane in memoria.
Ed è esattamente per questo che un memory leak da evento non appare come errore: appare come utilizzo di memoria che cresce nel tempo, lentamente, senza un momento preciso in cui il problema è iniziato.
Un evento mantiene internamente una lista dei riferimenti agli oggetti che vi si sono iscritti.
Finché l'evento esiste, quei riferimenti esistono.
Se un oggetto si iscrive a un evento di un altro oggetto che vive più a lungo di lui, e non cancella mai quella iscrizione, non potrà mai essere recuperato.
Ho visto questa situazione in un'applicazione di monitoraggio industriale: ogni finestra di dettaglio si iscriveva all'evento di aggiornamento dati di un servizio a livello applicazione.
La rimozione dell'iscrizione non era mai stata scritta.
Dopo otto ore di utilizzo, la memoria del processo era quattro volte quella iniziale.
Le finestre venivano chiuse visivamente, ma in memoria non venivano mai rilasciate.
L'applicazione richiedeva un riavvio a ogni cambio di turno, e nessuno sapeva perché.
Il problema non stava in nessun log: stava nell'assenza di una riga di codice che non era mai stata scritta.
La stessa dinamica si ripresenta in applicazioni web con connessioni persistenti: un gestore di messaggi su un hub, un listener su un canale di notifica, un handler su un sistema di eventi distribuiti.
Se l'iscrizione avviene all'apertura della connessione e la rimozione non è mai scritta, ogni connessione chiusa lascia un riferimento in vita.
Con centinaia di connessioni al giorno, il risultato è lo stesso.
Ogni iscrizione a un evento ha bisogno di una rimozione corrispondente nel punto in cui l'entità iscritta termina il suo ciclo di vita.
Il problema non è scrivere la rimozione: è avere un posto strutturato e obbligatorio dove metterla.
Le funzioni anonime che catturano variabili esterne producono un effetto simile per una ragione diversa.
Quando una funzione del genere viene creata, cattura un riferimento alla variabile esterna.
Se quella funzione sopravvive all'oggetto che l'ha creata, il riferimento rimane vivo.
Non si vede nessun punto di iscrizione esplicito nel codice.
Il riferimento esiste, occupa memoria, e non può essere recuperato finché la funzione è in vita.
Il corso C# e la gestione delle eccezioni che distingue il codice robusto da quello fragile
Hai trovato il punto che stava lanciando un'eccezione.
Hai aggiunto un blocco di cattura.
L'applicazione non crasha più.
Quattro settimane dopo ricevi una segnalazione: l'applicazione sta restituendo dati sbagliati e nessuno sa da quando.
Ed è esattamente per questo che catturare un'eccezione non è gestirla: è spostare il problema nel tempo e nello spazio, lontano dalla causa.
La maggior parte degli sviluppatori crede che un blocco di cattura senza rilancio sia una scelta conservativa e sicura.
In realtà nasconde il segnale che qualcosa è andato storto.
Il codice che chiama l'operazione fallita non sa che è fallita.
Prosegue come se fosse andata a buon fine, con uno stato interno che non corrisponde a quello che il codice assume.
Il risultato è un errore che emerge lontano nel tempo e nello spazio dal problema originale, senza nessuna traccia di cosa lo abbia causato.
La regola è diretta: cattura solo quello che sai trattare.
Per il resto, lascia che il problema salga.
Il middleware dell'applicazione sa come gestirlo in modo centralizzato: loggarlo correttamente, restituire la risposta HTTP giusta, avvisare i sistemi di monitoring.
Non è compito di ogni metodo gestire qualsiasi cosa possa andare storto.
È compito di ogni metodo non nascondere quello che non sa gestire.
La differenza tra i due approcci non riguarda solo lo stile:
| Cattura e nasconde | Cattura e rilancia correttamente | |
|---|---|---|
| Cosa vede il chiamante | Nulla: l'operazione sembra riuscita | L'eccezione risale al middleware |
| Stack trace | Troncato al punto del catch | Completo fino all'origine |
| Debugging successivo | "Dati sbagliati" senza causa tracciabile | Eccezione con stack trace preciso |
| Rischio a lungo termine | Bug silenzioso per settimane | Problema visibile immediatamente |
Quando si rilancia un'eccezione catturata, conservare lo stack trace originale non è una preferenza stilistica: è la differenza tra trovare il problema in dieci minuti e cercarlo per tre ore.
Rilanciarla nel modo sbagliato fa sembrare che l'errore sia nato nel punto del catch, non nel punto reale.
Un filtro sull'eccezione permette di decidere se entrare nel blocco di cattura senza risalire lo stack: se la condizione è falsa, il runtime cerca un gestore più in alto come se quel blocco non esistesse.
Questo permette di catturare solo i casi specifici che si sa trattare, lasciando salire tutto il resto.
Un aspetto separato: le eccezioni non sono un meccanismo di controllo del flusso.
Verificare un dato non valido con un confronto ha un costo ordini di grandezza inferiore rispetto a sollevare e catturare un'eccezione.
Le lezioni online di C# e il valore che sembra esserci ma non c'è
NullReferenceException non dice cosa era assente.
Dice dove hai provato a usarlo.
Il punto in cui viene lanciata non è il punto in cui è stato introdotto il problema: il valore assente è arrivato prima, magari da un livello di chiamata sopra, magari dalla deserializzazione di una richiesta.
Ed è esattamente per questo che il crash arriva sempre nel momento sbagliato: perché il problema è stato introdotto molto prima.
La maggior parte degli sviluppatori crede che la soluzione sia aggiungere controlli null ovunque.
In realtà il problema non è il controllo mancante: è che non si sa dove il valore assente è entrato nel sistema.
Abilitare le annotazioni di nullabilità nel progetto sposta l'analisi a prima dell'esecuzione: l'analisi statica traccia il flusso del codice e segnala ogni percorso in cui una variabile dichiarata non-nulla potrebbe comunque ricevere un valore assente.
Non modifica il comportamento a runtime: cambia quando emerge il problema.
Il punto che quasi nessuna risorsa chiarisce: è analisi statica, non una garanzia assoluta.
I dati che arrivano da sorgenti esterne, da deserializzazione, da query al database, da codice non annotato, possono contenere valori assenti a runtime indipendentemente da quello che dichiarano i tipi.
Le annotazioni proteggono la logica interna.
Non proteggono i confini con il mondo esterno: lì i controlli espliciti sono necessari in ogni caso.
Usare l'operatore che dice "fidati di me su questo valore" meccanicamente per silenziare avvisi significa ottenere il risultato opposto a quello per cui la funzionalità esiste.
Abilitarlo su una codebase esistente è uno dei pochi cambiamenti che produce risultati visibili rapidamente: ogni avviso che appare è un'assunzione implicita sulla nullabilità che non era mai stata esaminata.
Non tutte sono bug.
Ma alcune lo sono, e si trovano senza dover eseguire il codice.
Il modo pratico è procedere un file alla settimana: un ritmo sostenibile che produce risultati concreti senza interrompere il lavoro normale.
Questi pattern hanno una caratteristica in comune: il codice non segnala nulla.
Compila, gira, non lancia eccezioni.
Il problema emerge più tardi, in un contesto diverso, quando il collegamento tra causa ed effetto è già difficile da tracciare.
Lavorare su questo tipo di ragionamento preventivo, sul codice che gestisci adesso, è al centro del Corso C#.
Imparare C# e capire come misurare il prezzo di ogni operazione

Ogni operazione nel percorso caldo ha un costo.
Ogni oggetto allocato è memoria che il sistema dovrà recuperare.
Ogni recupero avviene in pause che non si controllano.
Ed è esattamente per questo che le ottimizzazioni sbagliate peggiorano le cose: si complica codice che non era il problema, lasciando intatto quello che lo era.
Unire stringhe con l'operatore di concatenazione dentro un ciclo produce un nuovo oggetto per ogni iterazione.
In un percorso attraversato poche volte al secondo, invisibile.
In un endpoint attraversato centomila volte al secondo, si vede come latenza variabile e imprevedibile nei momenti peggiori.
Ridurre le allocazioni nei percorsi critici non significa evitare la creazione di oggetti ovunque.
Significa sapere dove le allocazioni hanno un costo misurabile e applicare gli strumenti specifici lì: un buffer di testo che si riusa invece di ricreare ogni volta, pool di risorse costose da inizializzare, tipi che vivono sullo stack invece che in memoria dinamica per oggetti piccoli e a vita breve.
La sequenza corretta è sempre: misura, identifica, intervieni.
Non il contrario.
Prima del profiler, non si sa con certezza dove si trova il costo: si indovina.
Dopo il profiler, lo si sa con precisione.
"Il secondo approccio alloca il 97% in meno" è un'affermazione verificabile.
"Dovrebbe essere più veloce" non lo è.
Non tutto il codice va ottimizzato.
La logica di business attraversata decine di volte al giorno ha requisiti di performance completamente diversi da un endpoint attraversato un milione di volte al secondo.
La chiarezza su quale codice è un percorso critico e quale non lo è è il prerequisito per qualsiasi intervento sensato.
Corso programmazione C#: come cambiare le domande che fai mentre scrivi il codice
La conoscenza di questi meccanismi ha valore solo se cambia il codice che scrivi adesso, nella prossima code review, nel prossimo endpoint aggiunto a un'applicazione esistente.
Quello che cambia quando queste domande diventano un riflesso non è stilistico.
Non riguarda la sintassi o la formattazione.
Riguarda quello che cerchi mentre scrivi: non solo "funziona?" ma "regge quando la tabella ha dieci volte i dati?"
Non solo "i test passano?" ma "dove andrà questo oggetto dopo che la richiesta HTTP termina?"
Non solo "il codice è leggibile?" ma "c'è un caso che questa logica non gestisce e che arriverà prima o poi in produzione?"
Questo tipo di cambiamento è lento fatto da soli per un motivo preciso: non sai quello che non sai.
Non sai quali domande non ti stai facendo.
Non c'è nessuno che legge il codice e dice "questo pattern, in questo contesto, produce questo tipo di problema quando l'applicazione scala".
Puoi lavorare per anni senza incontrare la situazione che renderebbe visibile il problema, e intanto continui a costruire con le stesse assunzioni implicite.
Il percorso di sviluppo web di Sviluppatore Migliore funziona così: prima una fase in cui si guarda il codice che gestisci per identificare dove sono le lacune concrete, poi revisione e ristrutturazione diretta di quel codice.
Non esercizi costruiti.
Non slide con pattern astratti.
Il codice che stai già consegnando in produzione.
I risultati si misurano: stabilità dell'applicazione sotto carico, qualità delle code review, tempo tra la scrittura del codice e la scoperta dei problemi.
Il percorso è su candidatura: non tutti i profili e tutti i contesti sono adatti.
Corso programmazione C#: i dubbi di chi vuole trovare i problemi prima che arrivino in produzione

Non tutti questi problemi dipendono dal volume di traffico.
Il memory leak da iscrizione a evento si manifesta in qualsiasi applicazione a lunga durata, indipendentemente dal numero di utenti.
L'eccezione catturata e nascosta produce danni in qualsiasi contesto.
La query che porta dati in eccesso diventa problematica quando la tabella supera una certa dimensione, anche con pochi utenti.
Il thread pool si esaurisce sotto richieste concorrenti, non sotto basso carico.
Ogni meccanismo ha la sua soglia specifica, e quella soglia non è sempre il volume di traffico.
Il punto di partenza, nel proprio codebase, è il problema che si ha già.
Se l'applicazione perde memoria nel tempo, si inizia dalle iscrizioni a eventi senza rimozione e dalle funzioni anonime che catturano variabili.
Se smette di rispondere sotto carico, si inizia dalla catena delle operazioni asincrone.
Se ci sono query lente che non si riescono a spiegare, si inizia dal confine tra operazioni sul database e operazioni in memoria.
Se non ci sono problemi visibili, rileggere il codice con queste domande in testa è il modo per trovarli prima che emergano.
Il contesto ideale per imparare tutto questo è lavorare già a tempo pieno su un'applicazione web reale.
Il codice reale ha le sfumature e le interdipendenze che gli esercizi simulano solo parzialmente.
L'ostacolo non è il tempo: è avere le domande giuste nel momento giusto, mentre si sta ancora scrivendo il codice, invece di tre mesi dopo quando l'applicazione mostra i problemi in produzione.
Senza un riferimento esterno che le porta, quelle domande arrivano solo dopo aver visto il problema.
Per questo il percorso di sviluppo web di Sviluppatore Migliore non è un corso con slide e quiz.
La prima cosa che si fa è guardare il codice che gestisci adesso: si identificano le lacune concrete nella tua applicazione, si pianifica il lavoro in base a quello che produce il massimo ritorno nel tuo contesto specifico, e si lavora su quello.
I risultati si misurano: stabilità dell'applicazione sotto carico, qualità delle code review, tempo tra la scrittura e la scoperta dei problemi.
Il percorso è su candidatura perché non tutti i profili e tutti i contesti sono adatti.
Una call gratuita di 30 minuti serve a capire se fa per te.
In quella mezz'ora si guarda insieme il tuo contesto specifico: dove sono i punti solidi, dove sono le lacune concrete, se il tipo di lavoro che si fa nel percorso ha senso rispetto alla tua situazione.
Se non ha senso, viene detto chiaramente.
I risultati che si misurano prima sono quelli sulle code review: dopo le prime settimane, iniziano a emergere problemi che prima passavano inosservati.
I risultati sulla stabilità dell'applicazione richiedono più tempo perché dipendono dal carico reale e dalla frequenza con cui certi percorsi vengono attraversati.
Non ci sono numeri universali: ogni codebase ha le sue lacune prioritarie.
Andrea ha risolto il problema del venerdì sera.
Non stava nel database.
Stava in un punto della catena asincrona dove il thread veniva bloccato invece di essere liberato.
Otto righe di codice.
Due ore di debugging che, con questa domanda in testa mentre scriveva il codice, non sarebbero mai diventate un incident in produzione.
Adesso sa dove cercare prima.
Conosce le domande che il suo vecchio percorso formativo non gli ha insegnato, quelle che insegniamo nel Corso C#.
La codebase che gestisci adesso ha quasi certamente alcune di queste assunzioni implicite.
Non è una critica: è il risultato di come si impara a costruire applicazioni nella maggior parte dei percorsi formativi, che insegnano le funzionalità del linguaggio e del framework senza insegnare le domande da fare mentre le si usa.
Le conseguenze non si vedono finché l'applicazione non raggiunge la dimensione in cui le assunzioni smettono di reggere.
La distanza tra "il codice sembra a posto" e "il problema è visibile" può essere mesi o anni.
In quel periodo continui a consegnare con gli stessi pattern, a fare le stesse code review che non rilevano i problemi perché nessuno sa dove cercarli.
Poi l'applicazione cresce, e le assunzioni diventano visibili tutte insieme, in un momento che di solito non è comodo.
Il percorso è su candidatura: si lavora direttamente su ogni codebase e non tutti i contesti sono adatti.
Quando prenoterai la call, la prima cosa che faremo è guardare il codice che gestisci tu, adesso.
Se ha senso procedere, iniziamo.
Se non ha senso, te lo dico chiaramente.
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.
