Entity Framework Core (parte 1): modello e migrazioni
- #csharp
- #programmazione
- #orm
- #entity-framework
- #database
In questa lezione
- 1. Cos’è un ORM
- 2. Approcci e installazione
- 3. Entity e DbContext
- 4. Configurazione del modello
- 4.1 Data Annotations e Fluent API
- 4.2 Shadow properties e owned entities
- 4.3 Ereditarietà: TPH, TPT, TPC
- 5. Migrazioni
- 6. Change Tracker
- 6.1 AsNoTracking
- 6.2 Scenari disconnected
- 7. Quiz
- 8. Esercizi
- 8.1 Modello del catalogo e migrazioni
- 8.2 Osservare il Change Tracker
- 8.3 Soft delete con shadow property
1. Cos’è un ORM
Un ORM (Object-Relational Mapper) è un livello software che traduce il mondo object-oriented del codice C# nel mondo relazionale del database. Invece di scrivere 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. In sintesi EF Core:
- costruisce un modello delle entity;
- traduce le query LINQ in SQL e le esegue sul database;
- materializza i risultati in oggetti C#;
- traccia gli oggetti caricati e, al salvataggio, genera gli
INSERT/UPDATE/DELETEnecessari.
I vantaggi principali sono type safety (un errore di nome emerge in compilazione), query LINQ dichiarative, migrazioni versionate dello schema e meno codice ripetitivo. Un ORM però non elimina la necessità di conoscere il database: una query LINQ scritta male può generare SQL inefficiente.
2. Approcci e installazione
| Approccio | Descrizione |
|---|---|
| Code First | Definisci le classi C# e la configurazione → EF genera o aggiorna il database |
| Database First | Parti da un database esistente (es. legacy) → EF genera classi e configurazioni |
Il più comune è Code First: lo schema vive nel codice, quindi è versionato in Git, revisionabile e riproducibile in ogni ambiente tramite migrazioni.
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Sqlite # provider SQLite
dotnet add package Microsoft.EntityFrameworkCore.SqlServer # provider SQL Server
dotnet add package Microsoft.EntityFrameworkCore.Design # necessario per le migrazioni
dotnet tool install --global dotnet-ef # CLI "dotnet ef"
EF Core è composto da un nucleo comune e da provider specifici (SQL Server, PostgreSQL, SQLite, MySQL, in-memory per i test). Il provider determina il SQL generato, i tipi supportati e quali espressioni LINQ sono traducibili.
Nota
Molti progetti reali usano un approccio ibrido: EF Core per CRUD, relazioni e migrazioni, e Dapper (un micro-ORM leggero, senza change tracking) o SQL raw per report e query critiche per le performance.
3. Entity e DbContext
Le entity sono classi C# che rappresentano i dati persistiti, in genere una per 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; }
public int CategoriaId { get; set; } // FK
public Categoria? Categoria { get; set; } // navigation property
}
public class Categoria
{
public int Id { get; set; }
public string Nome { get; set; } = "";
public ICollection<Prodotto> Prodotti { get; set; } = new List<Prodotto>();
}
EF Core applica molte convenzioni: Id o NomeClasseId diventa chiave primaria, le proprietà scalari diventano colonne, le navigation property diventano relazioni. Quando le convenzioni non bastano si usano Data Annotations o Fluent API.
Il DbContext è il cuore di EF Core: rappresenta la sessione con il database, espone i DbSet<T> (uno per tabella) e contiene il Change Tracker.
public class AppDbContext : DbContext
{
public DbSet<Prodotto> Prodotti => Set<Prodotto>();
public DbSet<Categoria> Categorie => Set<Categoria>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Prodotto>(entity =>
{
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);
}
}
Registrazione in Program.cs:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("Data Source=app.db")); // o UseSqlServer(...)
Pericolo
Il DbContext non è thread-safe e accumula entità tracciate. AddDbContext lo registra come scoped (un’istanza per richiesta HTTP): non registrarlo mai come singleton e non usarlo da più task in parallelo.
4. Configurazione del modello
4.1 Data Annotations e Fluent API
La stessa configurazione della sezione 3 si può scrivere con attributi direttamente sulla 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; }
}
Le Data Annotations sono comode per vincoli semplici e leggibili accanto alla proprietà; la Fluent API (in OnModelCreating) è più potente, gestisce i mapping complessi e tiene le entity pulite da dettagli infrastrutturali.
4.2 Shadow properties e owned entities
Una shadow property esiste nel modello EF ma non nella classe C#: utile per dati infrastrutturali (CreatedAt, IsDeleted, TenantId) che non vuoi nel dominio.
modelBuilder.Entity<Prodotto>().Property<DateTime>("CreatedAt");
modelBuilder.Entity<Prodotto>().Property<bool>("IsDeleted").HasDefaultValue(false);
// Accesso a runtime
_context.Entry(prodotto).Property("CreatedAt").CurrentValue = DateTime.UtcNow;
Le owned entities mappano un Value Object (un oggetto che ha senso solo dentro il proprietario) nella stessa tabella del proprietario:
public class Ordine
{
public int Id { get; set; }
public Indirizzo Spedizione { get; set; } = new();
}
public class Indirizzo
{
public string Via { get; set; } = "";
public string Citta { get; set; } = "";
}
modelBuilder.Entity<Ordine>().OwnsOne(o => o.Spedizione);
// Tabella Ordini: Id | Spedizione_Via | Spedizione_Citta
4.3 Ereditarietà: TPH, TPT, TPC
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; } = ""; }
| Strategia | Tabelle | Pro | Contro |
|---|---|---|---|
| TPH (Table Per Hierarchy, default) | Una sola, con colonna Discriminator | Niente join, veloce | Molte colonne nullable |
| TPT (Table Per Type) | Una per il tipo base + una per derivato | Schema normalizzato | Join a ogni query |
| TPC (Table Per Concrete type) | Una completa per ogni tipo concreto | Niente join base/derivato | Colonne duplicate |
modelBuilder.Entity<Pagamento>().UseTptMappingStrategy(); // oppure UseTpcMappingStrategy()
Regola pratica: parti da TPH, il default e in genere il più performante; passa a TPT o TPC solo con un motivo concreto.
5. Migrazioni
Le migrazioni tracciano le modifiche allo schema del database nel tempo.
dotnet ef migrations add InitialCreate # crea una migrazione dalle differenze del modello
dotnet ef database update # applica le migrazioni al database
dotnet ef migrations remove # rimuove l'ultima (se non ancora applicata)
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]
Ogni migrazione contiene un metodo Up() (modifiche da applicare), un metodo Down() (rollback) e aggiorna lo snapshot del modello. Ad esempio, aggiungendo public string? Descrizione { get; set; } a Prodotto e lanciando migrations add AggiuntaDescrizione, si ottiene:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Descrizione", table: "Prodotti", nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "Descrizione", table: "Prodotti");
}
Attenzione
Rileggi sempre la migrazione generata prima di applicarla: rinominare una proprietà può essere interpretato come DropColumn + AddColumn, con perdita dei dati della colonna. Preferisci migrazioni piccole e frequenti.
6. Change Tracker
Il Change Tracker memorizza lo stato delle entità note al contesto:
| Stato | Significato |
|---|---|
| Added | Nuova, verrà inserita con INSERT |
| Unchanged | Tracciata, senza modifiche |
| Modified | Esiste già e una o più proprietà sono cambiate |
| Deleted | Verrà rimossa con DELETE |
| Detached | Non tracciata dal contesto |
Quando chiami SaveChangesAsync(), EF confronta i valori originali con quelli correnti, genera i comandi SQL, li esegue in una transazione e aggiorna gli stati.
var p = await _context.Prodotti.FirstAsync(p => p.Id == 1);
Console.WriteLine(_context.Entry(p).State); // Unchanged
p.Prezzo = 9.99m;
_context.ChangeTracker.DetectChanges();
Console.WriteLine(_context.Entry(p).State); // Modified
await _context.SaveChangesAsync(); // UPDATE Prodotti SET Prezzo = ... WHERE Id = 1
Console.WriteLine(_context.Entry(p).State); // Unchanged
Il contesto mantiene anche una identity map: per una certa chiave esiste una sola istanza tracciata, quindi due query sulla stessa riga restituiscono lo stesso oggetto.
var p1 = await _context.Prodotti.FindAsync(1);
var p2 = await _context.Prodotti.FirstAsync(p => p.Id == 1);
Console.WriteLine(ReferenceEquals(p1, p2)); // True
6.1 AsNoTracking
Per query di sola lettura (liste, dashboard, report, API read-only) il tracking è lavoro sprecato. AsNoTracking() evita di inserire le entità nel Change Tracker, riducendo memoria e CPU.
var disponibili = await _context.Prodotti
.AsNoTracking()
.Where(p => p.QuantitaInStock > 0)
.ToListAsync();
Attenzione
Le entità caricate con AsNoTracking() non vengono salvate: se modifichi una loro proprietà e chiami SaveChangesAsync(), non succede nulla. Se ti serve la deduplicazione delle istanze senza tracking, usa AsNoTrackingWithIdentityResolution().
6.2 Scenari disconnected
In una API web l’oggetto arriva dal client già Detached. Attach() lo marca Unchanged, Update() marca tutte le proprietà come Modified, Remove() lo marca Deleted.
// Aggressivo: UPDATE su tutte le colonne, anche quelle non inviate dal client
_context.Prodotti.Update(prodottoDalClient);
// Preferibile: carica e aggiorna solo i campi necessari
var p = await _context.Prodotti.FindAsync(dto.Id);
if (p is null) return;
p.Prezzo = dto.Prezzo; // UPDATE solo della colonna Prezzo
await _context.SaveChangesAsync();
Nella parte 2 useremo questo modello per scrivere query e operazioni CRUD, gestire le relazioni, evitare il problema N+1 e controllare concorrenza e transazioni.
7. Quiz
Mettiti alla prova
0/8 risposte
Cosa significa ORM?
Nell'approccio Code First:
Cosa è il DbContext?
Con quale ciclo di vita AddDbContext registra il DbContext?
Per convenzione, EF Core usa come chiave primaria:
Qual è la strategia di mapping dell'ereditarietà predefinita in EF Core?
Il comando per creare una migrazione è:
A cosa serve AsNoTracking()?
8. Esercizi
8.1 Modello del catalogo e migrazioni
Scenario: Prepari lo schema di un piccolo catalogo di prodotti con categorie.
Consegna:
- Crea le entity
ProdottoeCategoriacon relazione One-to-Many. - Configura
AppDbContextcon SQLite e usa la Fluent API per rendereNomeobbligatorio (massimo 100 caratteri). - Crea e applica la migrazione
InitialCreate. - Aggiungi la proprietà
DescrizioneaProdotto, genera una seconda migrazione e leggi i metodiUp()eDown(). - Rinomina una proprietà, genera la migrazione e verifica se EF produce un rename o un
DropColumn+AddColumn.
Obiettivo: padroneggiare il flusso Code First e imparare a rileggere le migrazioni generate.
8.2 Osservare il Change Tracker
Scenario: Vuoi vedere concretamente come cambiano gli stati delle entità.
Consegna:
- Carica un prodotto e stampa
_context.Entry(p).State. - Modifica il prezzo, chiama
DetectChanges()e stampa di nuovo lo stato; poi salva e ristampalo. - Crea un nuovo prodotto con
Add()e uno esistente conRemove(): stampa gli stati prima diSaveChangesAsync(). - Carica due volte lo stesso prodotto (con e senza
AsNoTracking()) e confronta le istanze conReferenceEquals.
Obiettivo: capire gli stati Added, Unchanged, Modified, Deleted e Detached e l’identity map.
8.3 Soft delete con shadow property
Scenario: Vuoi eliminare i prodotti logicamente (non fisicamente) senza aggiungere IsDeleted alla classe C#.
Consegna:
- Aggiungi a
Prodottouna shadow propertybool IsDeletedcon valore di defaultfalsee crea la migrazione. - Sovrascrivi
SaveChangesAsyncnelDbContext: per ogni entry diProdottoin statoDeleted, imposta lo stato aModifiedeIsDeletedatrue. - Elimina un prodotto con
Remove()e verifica che la riga resti nel database conIsDeleted = 1.
Obiettivo: usare shadow properties e Change Tracker per personalizzare il salvataggio.