Sostieni AppuntiFacili con una piccola donazione su PayPal
Dona con PayPalUn ORM (Object-Relational Mapper) è un livello software che traduce il mondo object-oriented del codice C# nel mondo relazionale del database. In pratica, invece di ragionare direttamente in termini di SELECT, INSERT, UPDATE e DELETE, lavori con classi, oggetti, collezioni e query LINQ.
graph LR
A[Classe C# \nProdotto] <-->|ORM| B[Tabella SQL\nprodotti]
C[Proprietà Id] <-->|mappa| D[Colonna id INT]
E[Proprietà Nome] <-->|mappa| F[Colonna nome VARCHAR]
G[Navigation Property Categoria] <-->|FK + JOIN| H[Tabella categorie]
Il principale ORM in C# è Entity Framework Core (EF Core), sviluppato da Microsoft.
Un ORM riduce il codice ripetitivo e centralizza la logica di accesso ai dati:
EF Core non è semplicemente un generatore di SQL. Al suo interno:
ChangeTracker;UPDATE/INSERT/DELETE necessari.Un ORM accelera lo sviluppo, ma non elimina la necessità di capire il database:
In termini teorici, se materializzi n righe con p proprietà tracciate, il costo minimo è almeno proporzionale a:
perché ogni riga e ogni proprietà significativa devono essere lette, convertite e assegnate.
| Approccio | Descrizione |
|---|---|
| Code First | Definisci le classi C# e la configurazione → EF genera o aggiorna il database |
| Database First | Parti da un database esistente → EF genera classi e configurazioni |
Il più comune è Code First, perché si integra bene con Git, review del codice, test e migrazioni versionate.
È ideale quando il dominio dell’applicazione nasce nel codice. Le entity diventano la fonte principale della struttura dati.
Vantaggi:
È utile quando lavori su un database legacy o condiviso con altri sistemi. In questo caso EF Core si adatta allo schema esistente invece di guidarlo.
Molti progetti reali usano un approccio misto:
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer # per SQL Server
dotnet add package Microsoft.EntityFrameworkCore.Sqlite # per SQLite
dotnet add package Microsoft.EntityFrameworkCore.Tools # per le migrazioni
Pacchetti spesso utili in progetti reali:
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.EntityFrameworkCore.Proxies # per lazy loading tramite proxy
EF Core è composto da un nucleo comune e da provider specifici:
Il provider influisce su:
Le entity sono classi C# che rappresentano i dati persistiti. In genere corrispondono a tabelle, ma il mapping non è sempre 1:1: EF Core può gestire proprietà ombra, owned types, ereditarietà e persino più tipi sulla stessa tabella.
public class Prodotto
{
public int Id { get; set; } // PK (per convenzione: Id o ProdottoId)
public string Nome { get; set; } = "";
public decimal Prezzo { get; set; }
public int QuantitaInStock { get; set; }
// Navigation property → relazione con Categoria
public int CategoriaId { get; set; }
public Categoria? Categoria { get; set; }
}
public class Categoria
{
public int Id { get; set; }
public string Nome { get; set; } = "";
// Collection navigation property
public ICollection<Prodotto> Prodotti { get; set; } = new List<Prodotto>();
}
EF Core usa molte convenzioni:
Id o NomeClasseId come chiave primaria;Quando le convenzioni non bastano, intervengono Data Annotations o Fluent API.
Una shadow property è una proprietà presente nel modello EF ma non dichiarata nella classe C#. È utile quando vuoi memorizzare dati infrastrutturali senza sporcare il dominio.
Esempi tipici:
CreatedAtUpdatedAtTenantIdIsDeletedprotected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Prodotto>()
.Property<DateTime>("CreatedAt");
modelBuilder.Entity<Prodotto>()
.Property<bool>("IsDeleted")
.HasDefaultValue(false);
}
Accesso a runtime:
var entry = _context.Entry(prodotto);
entry.Property("CreatedAt").CurrentValue = DateTime.UtcNow;
Sono comode, ma vanno usate con criterio: essendo invisibili nella classe, il codice è meno esplicito.
Le owned entities permettono di mappare un Value Object del dominio senza trasformarlo in un aggregate root separato. Sono molto utili quando un oggetto ha significato solo all’interno del proprietario.
public class Ordine
{
public int Id { get; set; }
public IndirizzoSpedizione IndirizzoSpedizione { get; set; } = new();
}
public class IndirizzoSpedizione
{
public string Via { get; set; } = "";
public string Citta { get; set; } = "";
public string CAP { get; set; } = "";
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Ordine>().OwnsOne(o => o.IndirizzoSpedizione, owned =>
{
owned.Property(i => i.Via).HasMaxLength(150);
owned.Property(i => i.Citta).HasMaxLength(100);
owned.Property(i => i.CAP).HasMaxLength(10);
});
}
Per default le proprietà owned vengono spesso salvate nella stessa tabella del proprietario, con colonne tipo:
IndirizzoSpedizione_ViaIndirizzoSpedizione_CittaIndirizzoSpedizione_CAPQuesto approccio è molto vicino al concetto di Value Object di Domain-Driven Design.
Con il table splitting più tipi .NET condividono la stessa tabella. È utile quando vuoi dividere logicamente un’entità grande in componenti più leggibili senza cambiare la struttura fisica del database.
modelBuilder.Entity<Ordine>(entity =>
{
entity.ToTable("Ordini");
entity.OwnsOne(o => o.IndirizzoSpedizione);
});
Dal punto di vista fisico hai una tabella sola; dal punto di vista del modello hai un oggetto composto.
EF Core supporta varie strategie di mapping dell’ereditarietà.
Tutta la gerarchia in una singola tabella con una colonna discriminatore.
Vantaggi:
Svantaggi:
public abstract class Pagamento
{
public int Id { get; set; }
public decimal Importo { get; set; }
}
public class PagamentoCarta : Pagamento
{
public string Circuito { get; set; } = "";
}
public class PagamentoBonifico : Pagamento
{
public string Iban { get; set; } = "";
}
Una tabella per il tipo base e una per ogni tipo derivato.
Vantaggi:
Svantaggi:
Se k è il numero di tabelle coinvolte nella gerarchia, il costo logico di una query completa cresce con i join richiesti, quindi in pratica tende ad aumentare con k.
Ogni tipo concreto ha una propria tabella completa, con duplicazione delle colonne ereditate.
Vantaggi:
Svantaggi:
Regola pratica:
Il DbContext è il cuore di EF Core: rappresenta la sessione con il database, mantiene il modello, espone i DbSet<T> e contiene il Change Tracker.
public class AppDbContext : DbContext
{
public DbSet<Prodotto> Prodotti { get; set; }
public DbSet<Categoria> Categorie { get; set; }
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Configurazione Fluent API (alternativa alle Data Annotations)
modelBuilder.Entity<Prodotto>(entity =>
{
entity.HasKey(p => p.Id);
entity.Property(p => p.Nome)
.IsRequired()
.HasMaxLength(100);
entity.Property(p => p.Prezzo)
.HasColumnType("decimal(18,2)");
});
modelBuilder.Entity<Categoria>()
.HasMany(c => c.Prodotti)
.WithOne(p => p.Categoria)
.HasForeignKey(p => p.CategoriaId);
}
}
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("Data Source=app.db")); // o UseSqlServer(...)
In applicazioni web il DbContext viene normalmente registrato come scoped: una istanza per richiesta HTTP. È importante non trattarlo come singleton.
Motivi:
Il Change Tracker memorizza lo stato delle entità note al contesto. Ogni entità può trovarsi in uno di questi stati:
| Stato | Significato |
|---|---|
| Added | L’entità è nuova e verrà inserita con INSERT |
| Modified | L’entità esiste già e una o più proprietà sono cambiate |
| Deleted | L’entità verrà rimossa con DELETE |
| Unchanged | L’entità è tracciata ma non ha modifiche |
| Detached | L’entità non è tracciata dal contesto |
stateDiagram-v2
[*] --> Detached
Detached --> Added: Add()
Detached --> Unchanged: Attach()/query tracciata
Unchanged --> Modified: cambio proprietà
Unchanged --> Deleted: Remove()
Added --> Detached: Clear()/Dispose
Modified --> Unchanged: SaveChanges()
Added --> Unchanged: SaveChanges()
Deleted --> Detached: SaveChanges()
Quando chiami SaveChangesAsync(), EF Core:
Il DbContext mantiene una identity map: per una certa chiave primaria, nella stessa istanza di contesto esiste una sola istanza tracciata dell’entità.
Esempio concettuale:
var p1 = await _context.Prodotti.FindAsync(1);
var p2 = await _context.Prodotti.FirstAsync(p => p.Id == 1);
Console.WriteLine(ReferenceEquals(p1, p2)); // true se entrambe sono tracciate
Questo evita incoerenze in memoria. Se due query restituissero due oggetti diversi per la stessa riga, capire quale versione aggiornare diventerebbe ambiguo.
EF Core conserva valori originali e correnti per le proprietà rilevanti. Quando rileva differenze, marca la proprietà o l’entità come modificata.
In forma semplificata, se hai n entità tracciate con p proprietà confrontabili, il rilevamento completo può costare fino a:
Per questo un contesto con migliaia di entità tracciate peggiora le performance.
In API web è frequente ricevere DTO dal client in un contesto diverso da quello che ha letto i dati. In quel caso le entità arrivano spesso Detached.
_context.Attach(prodotto); // presume esistente, inizialmente Unchanged
_context.Update(prodotto); // marca tutto come Modified
_context.Remove(prodotto); // marca come Deleted
Update() è comodo ma spesso troppo aggressivo: può generare UPDATE su colonne non cambiate. Meglio, quando possibile, caricare l’entità dal database e aggiornare solo i campi necessari.
Per query di sola lettura, il tracking è spesso inutile. AsNoTracking() dice a EF Core di non inserire le entità nel Change Tracker.
var prodotti = await _context.Prodotti
.AsNoTracking()
.Where(p => p.QuantitaInStock > 0)
.ToListAsync();
Quando usarlo:
Vantaggi:
Costo teorico della memoria tracciata, in prima approssimazione:
con un fattore costante più alto rispetto a query non tracciate, perché EF deve conservare metadati e snapshot.
Se vuoi evitare tracking ma mantenere la deduplicazione delle entità duplicate nello stesso risultato, esiste AsNoTrackingWithIdentityResolution().
In alternativa alla Fluent API si possono usare attributi direttamente sulle entity.
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
public class Prodotto
{
[Key]
public int Id { get; set; }
[Required]
[MaxLength(100)]
public string Nome { get; set; } = "";
[Column(TypeName = "decimal(18,2)")]
public decimal Prezzo { get; set; }
}
Data Annotations:
Fluent API:
La concorrenza ottimistica parte dall’idea che i conflitti siano rari. Quindi due utenti possono leggere la stessa riga, ma al salvataggio EF verifica se la riga è cambiata nel frattempo.
Due strumenti comuni:
[ConcurrencyCheck]Indica che una proprietà deve essere confrontata al momento dell’UPDATE.
public class Prodotto
{
public int Id { get; set; }
[ConcurrencyCheck]
public string Nome { get; set; } = "";
public decimal Prezzo { get; set; }
}
[Timestamp]Tipicamente usato con una colonna rowversion/timestamp in SQL Server.
public class Prodotto
{
public int Id { get; set; }
public string Nome { get; set; } = "";
public decimal Prezzo { get; set; }
[Timestamp]
public byte[] VersioneRiga { get; set; } = Array.Empty<byte>();
}
Quando EF genera l’UPDATE, aggiunge anche il token nella clausola WHERE. Se nessuna riga viene aggiornata, lancia DbUpdateConcurrencyException.
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
// gestire merge, reload o messaggio all'utente
}
Questo protegge dall’errore classico di lost update.
Le migrazioni tracciano le modifiche allo schema del database nel tempo.
# Crea una nuova migrazione
dotnet ef migrations add InitialCreate
# Applica le migrazioni al database
dotnet ef database update
# Rimuove l'ultima migrazione (se non ancora applicata)
dotnet ef migrations remove
graph LR
A[Modifica la Entity] --> B[dotnet ef migrations add]
B --> C[File di migrazione generato]
C --> D[dotnet ef database update]
D --> E[Schema DB aggiornato]
Una migrazione contiene in genere:
Up() con le modifiche da applicare;Down() per il rollback;public class ProdottoService
{
private readonly AppDbContext _context;
public ProdottoService(AppDbContext context)
{
_context = context;
}
// CREATE
public async Task<Prodotto> CreaAsync(Prodotto prodotto)
{
_context.Prodotti.Add(prodotto);
await _context.SaveChangesAsync();
return prodotto;
}
// READ - tutti
public async Task<List<Prodotto>> GetTuttiAsync()
{
return await _context.Prodotti
.Include(p => p.Categoria) // eager loading
.ToListAsync();
}
// READ - per id
public async Task<Prodotto?> GetByIdAsync(int id)
{
return await _context.Prodotti
.Include(p => p.Categoria)
.FirstOrDefaultAsync(p => p.Id == id);
}
// UPDATE
public async Task AggiornaAsync(Prodotto prodotto)
{
_context.Prodotti.Update(prodotto);
await _context.SaveChangesAsync();
}
// DELETE
public async Task EliminaAsync(int id)
{
var prodotto = await _context.Prodotti.FindAsync(id);
if (prodotto is not null)
{
_context.Prodotti.Remove(prodotto);
await _context.SaveChangesAsync();
}
}
}
Una query EF Core passa in genere da queste fasi:
Non tutto ciò che scrivi in LINQ può essere tradotto. Se una parte non è traducibile, EF può fallire o portare parte del lavoro lato client, con impatti pesanti sulle performance.
SelectSe ti servono solo pochi campi, evita di caricare l’intera entity.
var lista = await _context.Prodotti
.AsNoTracking()
.Select(p => new
{
p.Id,
p.Nome,
Categoria = p.Categoria!.Nome
})
.ToListAsync();
Vantaggi:
Se una riga ha c colonne totali ma ne usi solo k, con k << c, il trasferimento si riduce sensibilmente.
Non fare ToListAsync() su tabelle grandi senza limiti.
int pagina = 3;
int dimensionePagina = 20;
var prodotti = await _context.Prodotti
.AsNoTracking()
.OrderBy(p => p.Id)
.Skip((pagina - 1) * dimensionePagina)
.Take(dimensionePagina)
.ToListAsync();
Con offset pagination, il costo logico cresce con il numero di righe saltate:
Per dataset molto grandi è spesso preferibile la keyset pagination:
var prodotti = await _context.Prodotti
.AsNoTracking()
.Where(p => p.Id > ultimoIdVisto)
.OrderBy(p => p.Id)
.Take(20)
.ToListAsync();
Per query eseguite moltissime volte nei hot path, EF Core permette di compilare la query una volta e riutilizzarla.
private static readonly Func<AppDbContext, int, IAsyncEnumerable<Prodotto>> GetProdottiPerCategoria =
EF.CompileAsyncQuery(
(AppDbContext context, int categoriaId) =>
context.Prodotti
.AsNoTracking()
.Where(p => p.CategoriaId == categoriaId)
.OrderBy(p => p.Nome));
Uso:
var risultati = new List<Prodotto>();
await foreach (var prodotto in GetProdottiPerCategoria(_context, 5))
{
risultati.Add(prodotto);
}
Le compiled queries sono utili quando:
Non sono una scorciatoia universale: prima si misurano i colli di bottiglia.
Quando LINQ non basta o vuoi controllo totale, puoi eseguire SQL raw.
FromSqlRawPer query che restituiscono entity o tipi configurati nel modello.
var prodottiCostosi = await _context.Prodotti
.FromSqlRaw("SELECT * FROM Prodotti WHERE Prezzo > {0}", 1000)
.AsNoTracking()
.ToListAsync();
ExecuteSqlRawPer comandi che non restituiscono righe, come update massivi o stored procedure.
int righeCoinvolte = await _context.Database.ExecuteSqlRawAsync(
"UPDATE Prodotti SET QuantitaInStock = 0 WHERE QuantitaInStock < 0");
Attenzione:
EF Core:
Dapper:
Strategia realistica:
// Una Categoria ha molti Prodotti
public class Categoria
{
public int Id { get; set; }
public string Nome { get; set; } = "";
public ICollection<Prodotto> Prodotti { get; set; } = new List<Prodotto>();
}
public class Prodotto
{
public int Id { get; set; }
public int CategoriaId { get; set; } // FK
public Categoria? Categoria { get; set; } // navigation property
}
È il caso più comune: il lato “molti” contiene la chiave esterna.
public class Studente
{
public int Id { get; set; }
public string Nome { get; set; } = "";
public ICollection<Corso> Corsi { get; set; } = new List<Corso>();
}
public class Corso
{
public int Id { get; set; }
public string Titolo { get; set; } = "";
public ICollection<Studente> Studenti { get; set; } = new List<Studente>();
}
// EF Core 5+ gestisce automaticamente la tabella di join
Se la tabella di join ha campi propri, conviene modellarla come entity esplicita.
public class Utente
{
public int Id { get; set; }
public string Email { get; set; } = "";
public Profilo? Profilo { get; set; }
}
public class Profilo
{
public int Id { get; set; }
public string Bio { get; set; } = "";
public int UtenteId { get; set; } // FK
public Utente? Utente { get; set; }
}
Va usata quando il dato è realmente 1:1 dal punto di vista del dominio e del database.
I Global Query Filters applicano automaticamente un filtro a ogni query di una entity.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Prodotto>()
.HasQueryFilter(p => !EF.Property<bool>(p, "IsDeleted"));
}
Così tutte le query escludono i record eliminati logicamente.
private readonly int _tenantId;
public AppDbContext(DbContextOptions<AppDbContext> options, TenantProvider tenantProvider)
: base(options)
{
_tenantId = tenantProvider.TenantId;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Ordine>()
.HasQueryFilter(o => EF.Property<int>(o, "TenantId") == _tenantId);
}
Vantaggi:
Attenzione:
IgnoreQueryFilters();| Strategia | Come | Quando |
|---|---|---|
| Eager Loading | .Include() | Quando sai che ti serve la relazione |
| Lazy Loading | automatico (richiede configurazione) | Quando non sai se ti serve |
| Explicit Loading | .Entry().Collection().Load() | Quando vuoi caricare in un secondo momento |
// Eager Loading
var prodotti = await _context.Prodotti
.Include(p => p.Categoria)
.ThenInclude(c => c.Sottocategorie) // carica relazione annidata
.ToListAsync();
// Explicit Loading
var prodotto = await _context.Prodotti.FindAsync(1);
await _context.Entry(prodotto!)
.Reference(p => p.Categoria)
.LoadAsync();
Il problema N+1 si verifica quando esegui:
graph TD
A[Query 1: carica 100 ordini] --> B[Ordine 1 -> query cliente]
A --> C[Ordine 2 -> query cliente]
A --> D[Ordine 3 -> query cliente]
A --> E[...]
A --> F[Ordine 100 -> query cliente]
Numero di round-trip:
Se n cresce, la latenza totale cresce rapidamente anche se ogni query singola è veloce.
Di solito si scopre tramite:
Configurazione tipica del logging:
builder.Services.AddDbContext<AppDbContext>(options =>
options
.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging());
Se vedi la stessa query ripetersi decine o centinaia di volte con parametri diversi, è un forte segnale di N+1.
Includevar ordini = await _context.Ordini
.Include(o => o.Cliente)
.ToListAsync();
var ordini = await _context.Ordini
.AsNoTracking()
.Select(o => new
{
o.Id,
ClienteNome = o.Cliente!.Nome,
Totale = o.Righe.Sum(r => r.Quantita * r.PrezzoUnitario)
})
.ToListAsync();
AsSplitQueryQuando usi più Include, una singola mega-query con join può duplicare righe e gonfiare il result set. AsSplitQuery() spezza il caricamento in più query coordinate.
var ordini = await _context.Ordini
.Include(o => o.Righe)
.Include(o => o.Pagamenti)
.AsSplitQuery()
.ToListAsync();
È un compromesso:
SaveChanges() usa già una transazione per le modifiche di quella singola chiamata, ma in scenari complessi potresti voler controllare esplicitamente la transazione.
BeginTransactionAsyncawait using var transaction = await _context.Database.BeginTransactionAsync();
try
{
_context.Ordini.Add(ordine);
await _context.SaveChangesAsync();
_context.MovimentiMagazzino.Add(movimento);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
I savepoint permettono rollback parziali dentro una transazione più ampia.
await using var transaction = await _context.Database.BeginTransactionAsync();
await _context.SaveChangesAsync();
await transaction.CreateSavepointAsync("DopoOrdine");
try
{
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackToSavepointAsync("DopoOrdine");
await transaction.CommitAsync();
}
Sono utili quando vuoi salvare una parte consistente del lavoro ma recuperare da un errore successivo.
Con TransactionScope puoi usare una transazione ambientale, condivisa da più componenti che partecipano implicitamente alla stessa transazione.
using var scope = new TransactionScope(
TransactionScopeAsyncFlowOption.Enabled);
await servizioOrdini.CreaOrdineAsync(dto);
await servizioPagamenti.RegistraPagamentoAsync(dto);
scope.Complete();
Da usare con attenzione:
Linee guida molto importanti:
AsNoTracking() per query di sola lettura;Select() invece di caricare entity complete quando non servono;AsSplitQuery() quando più Include creano esplosione combinatoria di righe.Se una query unisce molte relazioni 1:N, il numero di righe restituite dal database può crescere molto più velocemente del numero di entità logiche richieste. In casi estremi il result set effettivo si comporta come un prodotto cartesiano parziale, con un costo che cresce rapidamente all’aumentare delle cardinalità coinvolte.
Cosa significa ORM?
Nell'approccio Code First:
Cosa è il DbContext?
Il comando per creare una migrazione è:
Il metodo Include() in EF Core serve per:
SaveChangesAsync() fa:
Per convenzione, EF Core usa come chiave primaria:
Una relazione One-to-Many si modella con:
Quale strategia di loading usa .Include()?
La Fluent API in EF Core si configura in:
Scenario: Costruisci un piccolo catalogo di prodotti con categorie.
Consegna:
Prodotto e Categoria con relazione One-to-Many.AppDbContext con SQLite.Prodotto (Create, Read, Update, Delete).Include().Obiettivo: padroneggiare le operazioni CRUD base con EF Core.
Scenario: Studenti si iscrivono a corsi (relazione Many-to-Many).
Consegna:
Studente e Corso con relazione Many-to-Many.Obiettivo: comprendere le relazioni Many-to-Many in EF Core.
Scenario: Vuoi eliminare i record logicamente (non fisicamente) aggiungendo un campo IsDeleted.
Consegna:
bool IsDeleted alle entity.SaveChangesAsync nel DbContext per impostare automaticamente IsDeleted = true invece di eliminare.Obiettivo: capire come personalizzare il comportamento di EF Core con query filter e override.
Prenota una lezione