Entity Framework Core: Ottimizzazione delle Prestazioni e Best Practice nel 2026
Ottimizzazione delle prestazioni di EF Core 10 con AsNoTracking, split query, operazioni batch, il nuovo operatore LeftJoin e filtri di query con nome. Guida pratica con esempi C# per applicazioni .NET 10 in produzione.

Entity Framework Core 10, rilasciato con .NET 10 LTS a novembre 2025, introduce gli operatori LeftJoin, la ricerca vettoriale, i filtri di query con nome e miglioramenti significativi nella traduzione SQL. Questa guida approfondisce le best practice di EF Core che incidono direttamente sulla velocità delle query, sull'allocazione di memoria e sulla scalabilità in ambienti di produzione.
EF Core 10 rappresenta una release Long-Term Support, con supporto garantito fino a novembre 2028. Il suo funzionamento richiede SDK e runtime .NET 10. Le applicazioni ancora basate su .NET 8 dovrebbero continuare a utilizzare EF Core 8 (LTS) fino al completamento della migrazione.
Tracking delle Query: Quando Disabilitarlo e Perché
Ogni chiamata a DbSet<T> aggancia le entità restituite al change tracker per impostazione predefinita. Il tracker mantiene uno snapshot dei valori originali delle proprietà, calcola le differenze durante SaveChanges e risolve i conflitti di identità nelle navigazioni. Questo overhead risulta superfluo quando i dati vengono indirizzati direttamente verso una risposta API o un view model di sola lettura.
public async Task<List<ProductDto>> GetActiveByCategoryAsync(
int categoryId, CancellationToken ct)
{
return await _context.Products
.AsNoTracking() // skip change tracker entirely
.Where(p => p.CategoryId == categoryId && p.IsActive)
.OrderBy(p => p.Name)
.Select(p => new ProductDto // project to DTO at the database level
{
Id = p.Id,
Name = p.Name,
Price = p.Price
})
.ToListAsync(ct);
}AsNoTracking elimina il costo per-entità legato alla creazione dello snapshot e alla risoluzione dell'identità. Combinato con Select, solo le colonne necessarie transitano sulla rete. Su una tabella con 50.000 righe, questo pattern riduce tipicamente le allocazioni di memoria del 40-60% rispetto a una query con tracking completo sull'entità intera.
Per i contesti che non modificano mai i dati, il default si imposta a livello di registrazione:
builder.Services.AddDbContext<CatalogContext>(options =>
options.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));Split Query per Evitare l'Esplosione Cartesiana
Il caricamento di un'entità con più navigazioni di collezione tramite Include genera un singolo statement SQL con JOIN. Quando due o più collezioni vengono caricate simultaneamente, il result set cresce come prodotto cartesiano delle collezioni, duplicando i dati della riga padre per ogni combinazione.
public async Task<Order?> GetWithDetailsAsync(int orderId, CancellationToken ct)
{
return await _context.Orders
.AsSplitQuery() // one SQL per Include
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.Include(o => o.Payments)
.Include(o => o.ShippingEvents)
.FirstOrDefaultAsync(o => o.Id == orderId, ct);
}AsSplitQuery suddivide il caricamento in statement SQL separati per ogni navigazione. Il compromesso consiste in più round-trip al posto di uno solo, ma ogni result set rimane contenuto ed evita la duplicazione delle righe. EF Core 10 corregge inoltre un'incoerenza di lunga data nell'ordinamento delle split query, garantendo che l'ordinamento delle sottoquery coincida con quello della query principale.
Le query singole restano preferibili quando si carica una sola navigazione di collezione o quando la latenza dei round-trip risulta elevata (chiamate cross-region al database). Prima di prendere una decisione definitiva, conviene eseguire benchmark su entrambe le modalità per lo specifico pattern di accesso.
Operazioni Batch con ExecuteUpdate e ExecuteDelete
Il flusso tradizionale di EF Core prevede il caricamento delle entità, la modifica delle proprietà e la chiamata a SaveChanges. Per operazioni massive che coinvolgono migliaia di righe, questo approccio genera migliaia di istanze tracciate e singoli statement UPDATE. EF Core 7 ha introdotto ExecuteUpdateAsync e ExecuteDeleteAsync per condensare l'operazione in un unico comando SQL. EF Core 10 semplifica ulteriormente l'API accettando una semplice lambda al posto di un expression tree.
public async Task ApplySeasonalDiscountAsync(
int categoryId, decimal discountPercent, CancellationToken ct)
{
var affected = await _context.Products
.Where(p => p.CategoryId == categoryId && p.IsActive)
.ExecuteUpdateAsync(s =>
{
s.SetProperty(p => p.Price, p => p.Price * (1 - discountPercent / 100));
s.SetProperty(p => p.LastModified, DateTime.UtcNow);
}, ct);
// affected = number of rows updated
}Il risultato è un singolo UPDATE ... SET ... WHERE a livello SQL. Nessuna entità viene caricata in memoria. Per un aggiornamento di 10.000 righe, il tempo di esecuzione scende da secondi (approccio con tracking) a millisecondi.
Lo stesso pattern si applica alle eliminazioni:
public async Task PurgeExpiredSessionsAsync(CancellationToken ct)
{
await _context.Sessions
.Where(s => s.ExpiresAt < DateTime.UtcNow)
.ExecuteDeleteAsync(ct);
}Pronto a superare i tuoi colloqui su .NET?
Pratica con i nostri simulatori interattivi, flashcards e test tecnici.
L'Operatore LeftJoin in EF Core 10
Prima di EF Core 10, eseguire un LEFT JOIN richiedeva la combinazione di GroupJoin, SelectMany e DefaultIfEmpty in un pattern specifico che la maggior parte degli sviluppatori doveva consultare ogni volta. EF Core 10 aggiunge LeftJoin come operatore LINQ di prima classe.
public async Task<List<EmployeeReportDto>> GetEmployeeDepartmentReportAsync(
CancellationToken ct)
{
return await _context.Employees
.LeftJoin(
_context.Departments,
employee => employee.DepartmentId,
department => department.Id,
(employee, department) => new EmployeeReportDto
{
FullName = employee.FirstName + " " + employee.LastName,
Department = department.Name ?? "Unassigned",
HiredAt = employee.HiredAt
})
.OrderBy(r => r.FullName)
.ToListAsync(ct);
}Il codice SQL generato utilizza una clausola LEFT JOIN standard. L'operatore RightJoin risulta anch'esso disponibile. Entrambi gli operatori eliminano la catena verbosa dei tre metodi precedentemente necessaria.
Filtri di Query con Nome per Filtraggio Multi-Concern
I global query filter sono stati limitati a un singolo filtro per tipo di entità sin da EF Core 2.0. Le applicazioni che implementavano sia il soft-delete sia la multi-tenancy dovevano combinare le condizioni in un'unica espressione, senza possibilità di disabilitarle selettivamente. EF Core 10 introduce i filtri di query con nome.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Invoice>()
.HasQueryFilter("SoftDelete", i => !i.IsDeleted)
.HasQueryFilter("Tenant", i => i.TenantId == _tenantId);
}Gli endpoint di amministrazione possono disabilitare il filtro soft-delete mantenendo attivo l'isolamento del tenant:
public async Task<List<Invoice>> GetAllIncludingDeletedAsync(CancellationToken ct)
{
return await _context.Invoices
.IgnoreQueryFilters(["SoftDelete"]) // tenant filter still applied
.ToListAsync(ct);
}Questo approccio elimina la necessità di metodi di estensione IQueryable personalizzati che aggiungevano manualmente le condizioni di filtro.
Resilienza delle Connessioni e Configurazione del Pooling
I guasti transitori del database (interruzioni di rete, failover di Azure SQL, esaurimento del pool di connessioni) generano eccezioni che spesso causano il crash delle pipeline di richiesta. EF Core fornisce una logica di retry integrata, ma i valori predefiniti richiedono una configurazione esplicita.
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null); // retry on all transient errors
sqlOptions.CommandTimeout(30); // 30-second command timeout
}));Anche i parametri di pooling nella stringa di connessione rivestono un ruolo importante:
Server=db.example.com;Database=AppDb;Min Pool Size=5;Max Pool Size=100;Connection Timeout=15;Min Pool Size mantiene connessioni pronte per picchi di traffico improvvisi. Max Pool Size limita il numero totale di connessioni aperte per prevenire il sovraccarico del database. Il valore predefinito di 100 risulta adeguato per la maggior parte delle applicazioni web, ma i servizi ad alto throughput potrebbero necessitare di un tuning basato sul volume effettivo di query concorrenti.
I retry automatici non funzionano all'interno di transazioni avviate dall'utente. L'intera transazione va racchiusa in una strategia di retry manuale utilizzando context.Database.CreateExecutionStrategy().ExecuteAsync(...) per gestire i guasti transitori che coinvolgono più operazioni.
Strategia di Indicizzazione e Analisi delle Query
Le migrazioni di EF Core consentono di definire gli indici in modo dichiarativo, ma la scelta delle colonne da indicizzare richiede la comprensione dei pattern di accesso alle query.
modelBuilder.Entity<Order>(entity =>
{
// composite index for frequent query pattern
entity.HasIndex(o => new { o.CustomerId, o.Status, o.CreatedAt })
.HasDatabaseName("IX_Order_Customer_Status_Date");
// filtered index for active orders only
entity.HasIndex(o => o.Status)
.HasFilter("[Status] <> 'Cancelled'")
.HasDatabaseName("IX_Order_ActiveStatus");
});Gli indici filtrati riducono le dimensioni dell'indice escludendo le righe che le query non interrogano mai. Per una tabella con l'80% di ordini cancellati, un indice filtrato sugli stati attivi può risultare 5 volte più piccolo e veloce da scansionare.
Per analizzare il codice SQL generato, si abilita il logging in ambiente di sviluppo:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging());Il codice SQL registrato nei log va copiato in SQL Server Management Studio ed eseguito con SET STATISTICS IO ON per verificare le letture logiche, oppure si utilizza EXPLAIN ANALYZE su PostgreSQL. I suggerimenti di indici mancanti nel piano di esecuzione rivelano spesso le opportunità di ottimizzazione con il maggiore impatto.
Compiled Query per gli Hot Path
Gli expression tree LINQ vengono analizzati e tradotti in SQL ad ogni esecuzione. Per le query eseguite migliaia di volte al minuto (ad esempio, lookup di autenticazione, validazione di sessione), questo costo di traduzione si accumula. Le compiled query memorizzano nella cache la traduzione all'avvio dell'applicazione.
public static class UserQueries
{
// compiled once, reused on every call
public static readonly Func<AppDbContext, string, CancellationToken, Task<UserSession?>>
GetActiveSession = EF.CompileAsyncQuery(
(AppDbContext ctx, string token, CancellationToken ct) =>
ctx.UserSessions
.AsNoTracking()
.FirstOrDefault(s => s.Token == token && s.ExpiresAt > DateTime.UtcNow));
}
// Usage in middleware
var session = await UserQueries.GetActiveSession(dbContext, bearerToken, ct);Le compiled query saltano completamente la fase di parsing dell'expression tree. La differenza di prestazioni risulta misurabile solo su percorsi ad alta frequenza (oltre 1.000 chiamate al minuto). Per gli endpoint CRUD standard, la query cache integrata in EF Core gestisce già il riutilizzo della traduzione.
Traduzione delle Collezioni Parametrizzate in EF Core 10
Le query che filtrano per una lista di ID rappresentano uno dei pattern più comuni nell'accesso ai dati. EF Core 10 modifica la strategia di traduzione predefinita per le collezioni parametrizzate. Invece di codificare la lista come array JSON (EF Core 8-9) o di inserire costanti inline (EF Core 7 e precedenti), EF Core 10 traduce ogni valore come parametro SQL separato.
// Before (EF Core 8-9): JSON array parameter
// @__ids_0='[1,2,3]'
// SELECT ... WHERE Id IN (SELECT value FROM OPENJSON(@__ids_0))
// EF Core 10: individual parameters with padding
// SELECT ... WHERE Id IN (@ids1, @ids2, @ids3)
var orderIds = new[] { 101, 205, 389 };
var orders = await _context.Orders
.Where(o => orderIds.Contains(o.Id))
.ToListAsync(ct);Il nuovo approccio fornisce al query planner del database informazioni sulla cardinalità, evitando al contempo l'inflazione della plan cache attraverso il padding dei parametri. Nei casi in cui l'approccio JSON offra prestazioni superiori (collezioni molto grandi), il comportamento si sovrascrive per la singola query:
var orders = await _context.Orders
.Where(o => EF.Parameter(orderIds).Contains(o.Id)) // force JSON mode
.ToListAsync(ct);Inizia a praticare!
Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.
Conclusione
- Utilizzare
AsNoTracking()e proiezioniSelectsu ogni query di sola lettura per eliminare l'overhead del change tracker - Applicare
AsSplitQuery()durante il caricamento di più navigazioni di collezione per evitare l'esplosione cartesiana - Sostituire il pattern tracked load-modify-save con
ExecuteUpdateAsynceExecuteDeleteAsyncper le operazioni massive - Adottare l'operatore
LeftJoindi EF Core 10 in sostituzione della catena verbosaGroupJoin/SelectMany/DefaultIfEmpty - Configurare i filtri di query con nome per gestire soft-delete e multi-tenancy in modo indipendente
- Impostare
EnableRetryOnFailuree calibrare le dimensioni del connection pool per la resilienza in produzione - Definire indici compositi e filtrati basandosi sui pattern reali delle query, non su supposizioni
- Riservare le compiled query ai percorsi realmente critici che superano le 1.000 esecuzioni al minuto
- Lasciare che EF Core 10 gestisca la traduzione delle collezioni parametrizzate per impostazione predefinita, sovrascrivendo il comportamento solo quando i benchmark lo giustifichino
Approfondimenti: il modulo avanzato su EF Core tratta questi pattern in un contesto di colloquio tecnico, mentre la guida alla Clean Architecture con .NET illustra come strutturare i livelli repository e service che incapsulano queste query.
Inizia a praticare!
Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.
Tag
Condividi
Articoli correlati

Domande Colloquio C# e .NET: Guida Completa 2026
Le 17 domande più frequenti nei colloqui C# e .NET. LINQ, async/await, dependency injection, Entity Framework e best practice con risposte dettagliate ed esempi di codice.

.NET 8: Creare un'API con ASP.NET Core
Guida completa alla creazione di un'API REST professionale con .NET 8 e ASP.NET Core. Controller, Entity Framework Core, validazione e best practice.

Rust: Fondamenti per Sviluppatori Esperti nel 2026
Imparare Rust rapidamente sfruttando le competenze esistenti. Ownership, borrowing, lifetimes e pattern essenziali spiegati per sviluppatori provenienti da C++, Java o Python.