Migrazione da Entity Framework 6 a EF Core: guida
Matteo Migliore

Matteo Migliore è un imprenditore e architetto software con oltre 27 anni di esperienza nello sviluppo di soluzioni basate su .NET e nell'evoluzione di architetture applicative per imprese e organizzazioni di alto profilo.

Ha guidato progetti enterprise, formato centinaia di sviluppatori e aiutato aziende di ogni dimensione a semplificare la complessità trasformando il software in guadagni per il business.

Il gestionale di un'azienda di serramenti del Vicentino usa Entity Framework 6 dal 2014: 118 entità, due DbContext, un modello EDMX disegnato a mano e 40 stored procedure chiamate da tutte le parti. Funziona. Il titolare però ha una domanda precisa: "Se il programmatore se ne va o se Microsoft smette di aggiornare il motore, che cosa ci resta in mano?".

La risposta onesta ha tre numeri. Un progetto di quella taglia si porta da Entity Framework 6 a EF Core in circa 32-49 giorni persona, quindi fra 19.200 e 29.400 euro con una tariffa di riferimento di 600 euro al giorno. Si fa a pezzi, un contesto alla volta, senza fermare chi lavora. E il costo non dipende dal numero di entità: dipende da quante query usano il caricamento pigro, da quante stored procedure ci sono e da quanto il database è stato toccato a mano fuori dalle migrazioni.

In questa guida trovi in che stato è oggi EF6, i rischi di restarci, quando migrare conviene e quando no, le quattro strade possibili con i costi, l'inventario da fare prima di scrivere una riga, tutte le differenze che rompono il codice (con esempi), come gestire le migrazioni e il database, i test per confrontare i risultati, la coesistenza dei due motori, le prestazioni, il passaggio in produzione e gli errori che vedo ripetersi. In fondo c'è una regola in cinque righe per decidere.

Scrivo software dal 1999 e ho portato più modelli EF6 su EF Core di quanti ne ricordi, inclusi gestionali multi-tenant con decine di milioni di righe. Se il tuo problema è più ampio (non solo l'accesso ai dati ma tutta l'applicazione su .NET Framework), parti da migrazione da .NET Framework a .NET 10: le due cose si incastrano e conviene decidere l'ordine.

In che stato è Entity Framework 6 oggi?

Entity Framework 6 è in manutenzione: riceve correzioni di sicurezza e di compatibilità, ma nessuna funzione nuova. Tutte le novità, le prestazioni e gli strumenti di Microsoft vanno su EF Core.

Entity Framework 6 è l'ultima versione del vecchio Entity Framework, quello uscito nel 2008 e maturato fino al 2013. Vive nel pacchetto EntityFramework e nel namespace System.Data.Entity. Nel 2016 Microsoft ha scritto un prodotto nuovo da zero, chiamato prima Entity Framework 7 e poi, per non fare confusione, Entity Framework Core. Non è un aggiornamento di EF6: è un altro motore con la stessa idea di fondo e molti nomi uguali, che però si comporta in modo diverso in decine di punti.

Che cosa significa "in manutenzione"

Significa che se esce un problema di sicurezza lo correggono, e che il pacchetto continua a funzionare su .NET Framework 4.8 e, con le versioni recenti, anche su .NET moderno. Non significa che sia sparito. Ma significa che ogni anno la distanza con EF Core cresce: nuovi tipi di dato, miglioramenti alle query, supporto a SQL Server recenti, JSON, tipi complessi, compilazione del modello, query compilate. Niente di tutto questo arriva in EF6.

Perché ce n'è ancora così tanto

Per lo stesso motivo per cui esistono ancora sistemi ASP classico: funziona e nessuno ha avuto un motivo urgente per toccarlo. Un'azienda che ha un EDMX da 118 entità e un anno di storia di correzioni non ha nessuna voglia di rifare il livello dati. È una scelta razionale, finché i costi di restare non superano quelli di partire.

Il legame con .NET Framework

EF Core recente richiede .NET moderno: l'ultima versione, la 10, gira solo su .NET 10. Chi è ancora su .NET Framework 4.8 non può quindi passare a EF Core recente senza prima (o insieme) migrare l'applicazione. Per questo, in pratica, il lavoro sui dati e il lavoro sul runtime vanno pianificati insieme, anche se si eseguono in momenti diversi. Più avanti vedrai come separarli.

Quali rischi corre un'azienda che resta su Entity Framework 6?

I rischi sono quattro: blocco del runtime (si resta inchiodati a .NET Framework), prestazioni ferme, persone (sempre meno sviluppatori nuovi lo conoscono) e strumenti che spariscono. Il danno non è improvviso: è un costo che sale ogni anno.

Runtime: il modello ti tiene su .NET Framework

Il rischio più concreto è questo: il livello dati EF6 è spesso la ragione per cui un'applicazione non passa a .NET moderno. "Dobbiamo rifare tutto l'accesso ai dati" è la frase che blocca il progetto da anni. Intanto .NET Framework 4.8 non riceve nuove funzioni, gira solo su Windows e lega l'azienda a un sistema operativo, a un IIS e a una versione di Visual Studio. Ne parlo con i numeri in quanto costa davvero il software legacy.

Prestazioni: le query non migliorano da sole

EF Core ha riscritto la traduzione delle query: genera SQL più compatto, sa dividere i caricamenti grandi in più query, supporta le query compilate e il modello precompilato. Niente di tutto questo si ottiene restando su EF6. Su un elenco ordini con quattro livelli di Include, la differenza in tempi di risposta può essere netta, perché EF6 genera un'unica query con molti JOIN e righe duplicate.

Persone: i nuovi sviluppatori non lo hanno mai usato

Chi ha cominciato a lavorare con .NET dopo il 2019 conosce EF Core. Chiedergli di mantenere un EDMX con il designer e le classi T4 generate è come chiedergli di leggere un manuale in una lingua morta. Non lo faranno bene, e chi lo conosce se ne andrà. È il rischio della persona sola: un nome su un foglio che regge il livello dati. Lavorare in coppia lo riduce subito, come spiego in pair programming, cosa è e quando funziona.

Strumenti: il designer non è più al centro

Il designer EDMX di Visual Studio esiste ancora per EF6, ma non per EF Core. Nuovi strumenti, estensioni, diagnostica e librerie di terze parti (caching, auditing, filtri globali) sono scritti per EF Core. Per EF6 le librerie vive sono sempre meno, e quelle che mancano le scrivi tu.

Sicurezza e conformità

Un livello dati fermo spesso porta con sé versioni vecchie del driver SQL e delle librerie di rete. Un audit di sicurezza, una richiesta di un cliente grosso o una polizza assicurativa possono chiedere di dimostrare che i componenti sono supportati. Allora la migrazione smette di essere "una cosa da fare quando c'è tempo".

Quando conviene migrare da EF6 a EF Core e quando no?

Conviene se l'applicazione cresce, se devi passare a .NET moderno o se le prestazioni dei dati sono un problema. Non conviene per un programma piccolo, fermo e senza evoluzione prevista: lì basta metterlo in sicurezza.

Quando conviene

Conviene quando almeno una di queste condizioni è vera. L'applicazione si modifica più di sei volte all'anno. Devi passare a .NET 8 o 10, per ragioni di supporto o di hosting. Ci sono query lente che oggi si risolvono con scorciatoie in SQL. Stai per aggiungere funzioni nuove (ricerca, API, mobile) che pesano sul livello dati. Oppure l'unico sviluppatore che conosce l'EDMX sta per andarsene.

Quando non conviene

Non conviene se l'applicazione è una piccola intranet con quattro utenti, nessuna modifica prevista e un server che regge. Non conviene nemmeno se il vero problema è un altro: un database progettato male o una logica sparsa in stored procedure non si risolvono cambiando ORM. In quel caso la cura è prima sul modello dei dati, e lì il passaggio a EF Core è un effetto collaterale, non la causa.

Quando conviene qualcosa di più radicale

Se le tabelle sono 700, la logica sta quasi tutta nelle stored procedure e l'ORM serve solo a leggere, forse conviene non migrare l'ORM ma sostituirlo con accesso diretto, come racconto in Dapper contro Entity Framework. Non è una sconfitta: è guardare il codice per quello che è.

Il criterio che uso

Prima di decidere guardo tre numeri: quante volte all'anno si tocca il livello dati, quanti sviluppatori lo conoscono davvero e quante query superano il secondo di durata in produzione. Se due su tre sono "tante" o "poche persone", si migra. Se tutti e tre sono tranquilli, si aspetta e si mette tutto in sicurezza.

Quali strade esistono per portare un progetto da EF6 a EF Core?

Le strade sono quattro: restare su EF6 e metterlo in sicurezza, portare EF6 su .NET moderno senza cambiare ORM, migrare a EF Core un contesto alla volta, oppure riscrivere l'accesso ai dati con SQL diretto. Per un gestionale usato ogni giorno quasi sempre vince la terza.

Un'applicazione con due modelli sullo stesso database: i contesti già migrati usano EF Core, gli altri restano su EF6

Strada 1: restare su EF6 e metterlo in sicurezza

Non si cambia ORM. Si aggiornano i pacchetti all'ultima versione 6.x, si chiude l'accesso diretto al database, si scrivono i test di caratterizzazione che vedrai più avanti e si fa una copia della macchina. Costo: 2.000-3.000 euro una volta. Non risolve il problema, lo rimanda, ma per un programma poco importante è la scelta giusta.

Strada 2: EF6 su .NET moderno, ORM invariato

Le versioni recenti di EF6 girano anche su .NET moderno. Questo permette di separare i due problemi: prima si porta l'applicazione su .NET 8 o 10 tenendo EF6, poi si migra l'ORM con calma. Il limite è che il designer EDMX non è utilizzabile come prima e che certe funzioni (come quelle spaziali o i provider di terze parti) vanno verificate una per una. Costo tipico: 10-14 giorni persona, circa 7.200 euro, per un'applicazione già pulita.

Strada 3: migrare a EF Core un contesto alla volta

È il metodo che consiglio e che descrivo in questa guida. L'applicazione tiene EF6 e EF Core insieme: ogni DbContext o ogni area funzionale passa a EF Core quando è pronta, con gli stessi dati sotto. È la stessa idea del strangler fig pattern applicata al livello dati: il vecchio resta in piedi finché l'ultimo pezzo non è passato. Costo: 19.200-29.400 euro per il caso di riferimento.

Strada 4: riscrivere l'accesso ai dati

Si abbandona l'ORM per Dapper o SQL scritto a mano, oppure si cambia modello (per esempio verso CQRS, con letture e scritture separate). È una buona strada quando le query sono già quasi tutte stored procedure, ma è la più cara: per il caso di riferimento, circa 42.000 euro, con la contropartita di un controllo totale sul SQL. Se i dati sono per natura documentali, un database come Azure Cosmos DB può sostituire del tutto il modello relazionale in quella parte.

Non esiste una strada giusta in assoluto: esiste quella che si adatta ai tuoi tre numeri. Se il tuo sito è invece costruito su WebForms o su ASP classico, il discorso cambia e lo trovi in migrazione da ASP.NET WebForms a Core e migrazione da ASP classico a ASP.NET Core.

Quanto costa migrare da Entity Framework 6 a EF Core?

Per un modello da circa 120 entità, due contesti e 40 stored procedure servono 32-49 giorni persona, cioè 19.200-29.400 euro a 600 euro al giorno. Il costo cambia più per le query pigre e le stored procedure che per il numero di entità.

Confronto dei costi delle quattro strade: messa in sicurezza 2.400 euro, EF6 su .NET moderno 7.200, migrazione a EF Core 24.300, riscrittura con SQL diretto 42.000 euro

Il grafico confronta le quattro strade sul caso di riferimento. Le cifre sono medie del mio lavoro con una tariffa di 600 euro al giorno; il tuo caso può stare più in basso o più in alto, ma l'ordine di grandezza resta questo.

Dove vanno i giorni

Il lavoro della strada 3 si divide così, per un modello come quello del serramentista:

  • Inventario e misura: 3-4 giorni. Si conta, si legge, si fotografa lo stato attuale.
  • Rete di test: 6-10 giorni. Test che catturano il comportamento di oggi sulle 30 query più importanti.
  • Modello e mappature: 8-12 giorni. Scaffold dal database, correzione di relazioni, tipi, regole di cancellazione.
  • Riscrittura delle query: 10-15 giorni. Caricamenti, filtri, chiamate alle stored procedure.
  • Migrazioni e database: 2-3 giorni. Il punto di partenza condiviso fra i due motori.
  • Passaggio in produzione e controllo: 3-5 giorni. Release a ondate, misure, correzioni.

La somma è 32-49 giorni. Per un modello più piccolo, con una ventina di entità e nessuna stored procedure, il totale scende a 8-12 giorni: tra 4.800 e 7.200 euro.

Che cosa fa salire il prezzo

Cinque cose alzano il conto, in ordine di peso. L'uso diffuso del caricamento pigro, perché ogni punto in cui una proprietà "si carica da sola" va trovato e reso esplicito. Le stored procedure con risultati non mappati. I tipi spaziali (DbGeography), che cambiano libreria. Il modello EDMX con ereditarietà complessa. E un database modificato a mano per anni, senza migrazioni, dove lo schema reale non coincide con quello del codice.

Che cosa fa scendere il prezzo

Un database pulito, con le chiavi esterne vere. Una persona interna che conosce le regole di business e risponde in un'ora. Un ambiente di prova con una copia dei dati. E un codice che usa IQueryable in pochi punti, non sparso in tutto il progetto.

Il costo di non farlo

Una modifica semplice al livello dati, in un EDMX non toccato da tempo, costa spesso 1-2 giorni: aprire il designer, rigenerare le classi, sistemare i conflitti. Con 25 modifiche l'anno, parliamo di 25-50 giorni, cioè 15.000-30.000 euro che nessuno vede in bilancio come "costo dell'ORM". In due anni quasi pareggia la migrazione.

Come si fa l'inventario prima di toccare il codice?

L'inventario risponde a quattro domande: quanti contesti ed entità esistono, dove si usa il caricamento pigro, quali query sono SQL a mano e quali funzioni di EF6 senza equivalente diretto compaiono nel codice. Si fa in 3-4 giorni.

Conta e cerca i punti difficili

Il primo passo è meccanico. Questo strumento da riga di comando scorre i file .cs e conta i punti in cui compaiono i costrutti più rischiosi:

// Strumento da riga di comando: censisce i costrutti di EF6 che richiedono lavoro.
// Si compila con: dotnet new console, incolla questo in Program.cs,
// poi: dotnet run -- "C:\\Progetti\\Gestionale"
using System.Text.RegularExpressions;

var radice = args.Length > 0 ? args[0] : ".";

var regole = new Dictionary<string, Regex>
{
    ["DbContext EF6"]         = new(@":\s*DbContext\b", RegexOptions.Compiled),
    ["ObjectContext"]         = new(@"\bObjectContext\b|\bObjectQuery\b", RegexOptions.Compiled),
    ["Database.SqlQuery"]     = new(@"Database\.SqlQuery", RegexOptions.Compiled),
    ["ExecuteSqlCommand"]     = new(@"ExecuteSqlCommand", RegexOptions.Compiled),
    ["DbFunctions"]           = new(@"DbFunctions\.", RegexOptions.Compiled),
    ["Include con stringa"]   = new(@"\.Include\(\s*""", RegexOptions.Compiled),
    ["Proprieta virtual"]     = new(@"public virtual [A-Z]\w*(<[^>]+>)?\s+\w+\s*\{{", RegexOptions.Compiled),
    ["DbGeography"]           = new(@"DbGeography|DbGeometry", RegexOptions.Compiled),
    ["ComplexType"]           = new(@"ComplexType", RegexOptions.Compiled),
    ["WillCascadeOnDelete"]   = new(@"WillCascadeOnDelete", RegexOptions.Compiled),
    ["Mappa many-to-many"]    = new(@"Map\(m\s*=>", RegexOptions.Compiled),
    ["SetInitializer"]        = new(@"SetInitializer", RegexOptions.Compiled),
};

var totali = regole.Keys.ToDictionary(k => k, _ => 0);
var perFile = new List<(string File, int Punti)>();

foreach (var file in Directory.EnumerateFiles(radice, "*.cs", SearchOption.AllDirectories)
    .Where(f => !f.Contains(@"\obj\") && !f.Contains(@"\bin\")))
{
    var testo = File.ReadAllText(file);
    var punti = 0;
    foreach (var (nome, regola) in regole)
    {
        var n = regola.Matches(testo).Count;
        totali[nome] += n;
        punti += n;
    }
    if (punti > 0) perFile.Add((Path.GetRelativePath(radice, file), punti));
}

foreach (var (nome, n) in totali.OrderByDescending(x => x.Value))
    Console.WriteLine($"{n,5}  {nome}");

Console.WriteLine();
Console.WriteLine("I 15 file con piu punti difficili:");
foreach (var (file, punti) in perFile.OrderByDescending(x => x.Punti).Take(15))
    Console.WriteLine($"{punti,5}  {file}");

L'output è un elenco ordinato dei costrutti e dei quindici file che concentrano il lavoro. In quasi tutti i progetti che ho visto, il 15% dei file contiene più della metà dei punti difficili. È lì che si decide il preventivo.

Misura le query che contano

Il codice dice cosa esiste, non cosa si usa. Per capire quali query contano, serve la misura in produzione: quali sono le venti query più chiamate e quali le dieci più lente. Si ottiene da Query Store di SQL Server, o da un profilo di una settimana. Le trenta query più importanti saranno il cuore della rete di test.

Leggi lo schema reale

Confronta il database di produzione con quello che il codice crede di avere. In un progetto su tre che ho visto, ci sono colonne aggiunte a mano, indici fuori dal modello e vincoli di chiave esterna che nel codice non esistono. Meglio scoprirlo ora che al terzo giorno di migrazione.

Elenca le attività nascoste

Report, job notturni, importazioni, programmi esterni che leggono lo stesso database. Se un'altra applicazione scrive nelle stesse tabelle, qualunque cambio di regole sulla cancellazione o sulla concorrenza la riguarda. Si trovano nei registri di SQL Server e nei job dell'agente.

Quali differenze fra EF6 e EF Core rompono il codice?

Le differenze che rompono il codice sono otto: nomi e namespace, configurazione del modello, caricamento pigro, validazione, chiamate SQL dirette, funzioni di database, relazioni molti a molti e comportamento delle cancellazioni. Quasi tutte si risolvono con una regola fissa.

1. Namespace e nomi: la parte noiosa

Il namespace System.Data.Entity diventa Microsoft.EntityFrameworkCore. DbModelBuilder diventa ModelBuilder. Le stringhe di connessione non si leggono più dal nome nel file di configurazione: si passano da AddDbContext. Questa parte si fa in un giorno con un cerca e sostituisci guidato, e si vede subito dal compilatore.

2. Configurazione del modello

I nomi della configurazione fluente sono cambiati. Ecco un esempio tipico, prima e dopo:

// EF6: relazione uno a molti con cancellazione disabilitata.
public class OrdineConfigurazione : EntityTypeConfiguration<Ordine>
{
    public OrdineConfigurazione()
    {
        HasKey(o => o.Id);
        Property(o => o.Numero).IsRequired().HasMaxLength(20);
        HasRequired(o => o.Cliente)
            .WithMany(c => c.Ordini)
            .HasForeignKey(o => o.ClienteId)
            .WillCascadeOnDelete(false);
        Property(o => o.Totale).HasPrecision(18, 2);
    }
}

// EF Core: stesso significato, nomi diversi.
public class OrdineConfigurazione : IEntityTypeConfiguration<Ordine>
{
    public void Configure(EntityTypeBuilder<Ordine> builder)
    {
        builder.HasKey(o => o.Id);
        builder.Property(o => o.Numero).IsRequired().HasMaxLength(20);
        builder.HasOne(o => o.Cliente)
            .WithMany(c => c.Ordini)
            .HasForeignKey(o => o.ClienteId)
            .OnDelete(DeleteBehavior.Restrict);
        builder.Property(o => o.Totale).HasPrecision(18, 2);
    }
}

Le due versioni dicono la stessa cosa: HasRequired diventa HasOne con IsRequired, WillCascadeOnDelete(false) diventa OnDelete(DeleteBehavior.Restrict), EntityTypeConfiguration diventa IEntityTypeConfiguration. Cambia anche il comportamento predefinito delle cancellazioni, ed è una delle fonti di sorprese: te ne parlo al punto 8.

3. Caricamento pigro: il punto che costa di più

In EF6 le proprietà virtual si caricano da sole quando le leggi. In EF Core questo comportamento non c'è di serie: va attivato con un pacchetto (Microsoft.EntityFrameworkCore.Proxies) e con UseLazyLoadingProxies(). Se non lo attivi, la proprietà resta null, e il codice che prima funzionava ora non trova più il cliente di un ordine. Questo è il punto che manda più spesso fuori strada chi migra in fretta.

Ci sono due scelte. La prima è attivare i proxy e far girare tutto come prima. È la strada più rapida, ma si porta dietro il difetto di sempre: per ogni ordine di una lista, una query in più per leggere il cliente. È il problema che descrivo in lazy loading in Entity Framework. La seconda è togliere il caricamento pigro e rendere esplicito ciò che serve:

// EF6: il cliente si carica da solo, una query per ogni ordine della lista.
var ordini = db.Ordini.Where(o => o.Data >= dal).ToList();
foreach (var o in ordini)
{
    Console.WriteLine($"{o.Numero} - {o.Cliente.RagioneSociale}"); // query nascosta
}

// EF Core: carichi quello che serve, in una query sola.
var ordini = await db.Ordini
    .AsNoTracking()
    .Where(o => o.Data >= dal)
    .Select(o => new { o.Numero, Cliente = o.Cliente.RagioneSociale })
    .ToListAsync();
foreach (var o in ordini)
{
    Console.WriteLine($"{o.Numero} - {o.Cliente}");
}

Io consiglio la seconda, applicata però per fasi. Si migra con i proxy attivi, cosicché il comportamento resta identico e i test passino. Poi, con le misure in mano, si tolgono i proxy un'area alla volta, partendo dalle liste più chiamate. Così non si rompe nulla e si ottiene il miglioramento vero.

4. Validazione: sparisce in silenzio

Questa è una differenza che non dà errori. In EF6, SaveChanges controllava gli attributi come [Required] e [StringLength] e lanciava DbEntityValidationException. In EF Core non lo fa più: gli attributi servono a definire lo schema, non a validare. Se il database ha una colonna NOT NULL l'errore arriva comunque, ma più tardi e con un messaggio peggiore; se la colonna accetta null, il dato sbagliato entra.

La correzione è spostare la validazione dove deve stare: nel livello applicativo, con un validatore esplicito prima di salvare.

// EF Core: validazione esplicita prima del salvataggio.
public static class ValidazioneEntita
{
    public static void Controlla<T>(T entita) where T : class
    {
        var contesto = new ValidationContext(entita);
        var errori = new List<ValidationResult>();
        if (!Validator.TryValidateObject(entita, contesto, errori, validateAllProperties: true))
        {
            var testo = string.Join("; ", errori.Select(e => e.ErrorMessage));
            throw new ValidationException($"Dati non validi per {typeof(T).Name}: {testo}");
        }
    }
}

// Si può anche agganciare a SaveChanges in un solo punto:
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
    foreach (var voce in ChangeTracker.Entries()
        .Where(e => e.State is EntityState.Added or EntityState.Modified))
    {
        ValidazioneEntita.Controlla(voce.Entity);
    }
    return base.SaveChangesAsync(ct);
}

Con questo, il comportamento torna quello di prima. È il tipo di differenza che si scopre solo con i test di caratterizzazione, per questo li scrivo prima di toccare il resto.

5. SQL diretto: nomi diversi e un po' più di cautela

Database.SqlQuery<T> esiste ancora, ma solo dalle versioni recenti di EF Core; per le entità mappate si usa FromSqlInterpolated, e per i comandi ExecuteSqlInterpolated. La versione interpolata trasforma i valori in parametri veri, quindi è sicura contro la SQL injection: nel codice EF6 che ho letto, invece, non è raro trovare ExecuteSqlCommand con stringhe concatenate.

// EF6: stringa concatenata, vulnerabile a iniezione.
var righe = db.Database.SqlQuery<RigaOrdine>(
    "SELECT * FROM RigheOrdine WHERE OrdineId = " + id).ToList();

// EF Core: i valori diventano parametri.
var righe = await db.RigheOrdine
    .FromSqlInterpolated($"SELECT Id, OrdineId, Codice, Quantita, Prezzo FROM RigheOrdine WHERE OrdineId = {id}")
    .AsNoTracking()
    .ToListAsync();

Le stored procedure funzionano allo stesso modo con FromSql, ma con una regola in più: il risultato deve avere una colonna per ogni proprietà dell'entità, e Include sopra una stored procedure si può usare solo in casi semplici. Se hai 40 stored procedure con risultati non mappati, è la voce che pesa di più sul preventivo.

6. Funzioni di database e tipi particolari

DbFunctions.TruncateTime(x) si sostituisce con x.Date. DbFunctions.DiffDays con EF.Functions.DateDiffDay. I tipi spaziali DbGeography e DbGeometry non esistono più: si usa NetTopologySuite, con un pacchetto in più e le coordinate in un altro formato. È raro, ma se c'è, aggiunge qualche giorno.

Un'altra differenza sottile: il confronto fra stringhe segue la collazione del database in entrambi i motori, ma EF Core è più rigido nel tradurre funzioni che non ha un equivalente in SQL. Dalla versione 3 in poi, una query che non si può tradurre lancia un errore invece di leggere tutto in memoria e filtrare lì. Per chi migra è un bene: si scoprono subito le query che prima erano lente di nascosto.

7. Molti a molti ed ereditarietà

In EF6 una relazione molti a molti con tabella ponte si scriveva con Map. In EF Core recente si dichiara con HasMany().WithMany() e si può indicare la tabella ponte con UsingEntity. Se la tabella ponte ha colonne proprie (come la data di assegnazione), si modella come entità normale, ed è un cambio che coinvolge il codice che la usa.

Per l'ereditarietà: EF Core usa per predefinito una sola tabella per tutta la gerarchia (TPH). Una tabella per tipo (TPT) è supportata da EF Core 5, ma con prestazioni diverse da quelle di EF6. Se il tuo EDMX usa una gerarchia complessa, conviene misurarla subito con dati veri.

8. Cancellazioni a cascata e chiavi opzionali

Il comportamento predefinito delle cancellazioni a cascata è diverso: se una chiave esterna è obbligatoria EF6 attiva la cascata per convenzione, EF Core pure, ma la gestione degli oggetti già caricati in memoria e delle chiavi opzionali cambia. Il risultato più comune: un'operazione di cancellazione che in EF6 lasciava le righe figlie orfane o falliva in silenzio ora ne cancella altre. Prima di andare in produzione, ogni relazione con cascata va elencata e testata con un caso reale.

Conclusione di questa parte: le differenze sono tante ma finite. Ho una lista di controllo di 38 punti che uso a ogni progetto, e il trucco non è conoscerle a memoria: è metterle tutte in una tabella prima di cominciare, accanto ai file dove compaiono.

Come si gestiscono le migrazioni e il database?

Il database non si migra: resta uguale. Si crea un punto di partenza condiviso (una migrazione di base vuota), si legge lo schema con lo scaffold e da lì in poi le modifiche allo schema passano solo da EF Core.

Il database non cambia

La cosa più rassicurante di questo lavoro: le tabelle restano quelle. EF Core legge lo stesso schema di EF6, quindi non serve spostare dati né fermare nulla. Quello che cambia è il modo in cui il codice descrive lo schema e il modo in cui si registrano le modifiche.

Lo scaffold come punto di partenza

Per un modello nato con il designer, il punto di partenza è generare le classi e il contesto dal database vero:

dotnet ef dbcontext scaffold "Server=SRV01;Database=Gestionale;Trusted_Connection=True;TrustServerCertificate=True" Microsoft.EntityFrameworkCore.SqlServer --context GestionaleContext --output-dir Modello --data-annotations --no-onconfiguring

Il risultato non va usato così com'è: serve a vedere cosa EF Core capisce dal database. Si confronta con il modello EF6 e si corregge. In questo passaggio escono le differenze che nessuna lista elenca: una colonna decimal senza precisione, un indice che il modello non conosce, una relazione che il database non dichiara.

La migrazione di base

EF6 teneva la storia delle modifiche nella tabella __MigrationHistory; EF Core usa __EFMigrationsHistory, con un formato diverso. Le vecchie migrazioni non si portano: non servono, perché lo schema c'è già. Si crea una migrazione di base che rappresenta lo stato attuale e si segna come già applicata:

dotnet ef migrations add Base --context GestionaleContext
# Si svuota il contenuto di Up() e Down() nella migrazione generata: lo schema esiste già.
# Poi si registra come applicata, senza eseguirla:
dotnet ef migrations script 0 Base --idempotent --context GestionaleContext
# Dallo script si prende solo l'inserimento nella tabella __EFMigrationsHistory.

Da quel momento ogni modifica allo schema nasce da EF Core, e la tabella __MigrationHistory della versione vecchia viene lasciata in pace finché esiste un solo pezzo di applicazione che usa ancora EF6. Una regola semplice: uno solo dei due motori scrive lo schema. Durante la coesistenza, il motore nuovo; l'altro legge e scrive i dati ma non cambia le tabelle.

Le stored procedure e le viste

Restano dove sono. Le viste si mappano come entità senza chiave (HasNoKey().ToView("NomeVista")), le stored procedure si chiamano con FromSql. Una buona occasione, a migrazione finita, è chiedersi quali stored procedure servono ancora e quali sono scorciatoie nate per aggirare i limiti di EF6: ma è una domanda per dopo, non per durante.

Come si dimostra che il comportamento non è cambiato?

Con test di caratterizzazione: si scrivono prima della migrazione, sulle trenta query più importanti, e si eseguono contro EF6 e EF Core con gli stessi dati. Se i risultati coincidono, la migrazione è corretta in quel punto.

Che cos'è un test di caratterizzazione

Non verifica che il codice sia "giusto": verifica che faccia ciò che faceva prima. Si parte da una copia del database di produzione (con i dati anonimizzati), si eseguono le query importanti con EF6 e si salvano i risultati. Poi si eseguono le stesse query con EF Core e si confrontano. Ogni differenza o è un errore della migrazione, o è un difetto che EF6 nascondeva: in entrambi i casi è una scoperta preziosa.

Un esempio concreto

Questo è il modello di test che uso: lo stesso metodo di servizio, eseguito contro due implementazioni, con un confronto dei risultati:

// Test di caratterizzazione: stessa richiesta, due implementazioni dell'accesso ai dati.
public class OrdiniCaratterizzazioneTest
{
    private readonly IRepositoryOrdini _ef6 = new RepositoryOrdiniEf6(Ambiente.StringaConnessione);
    private readonly IRepositoryOrdini _efCore = new RepositoryOrdiniEfCore(Ambiente.StringaConnessione);

    [Theory]
    [InlineData(2026, 1)]
    [InlineData(2026, 6)]
    [InlineData(2025, 12)]
    public async Task ElencoOrdiniDelMese_DeveCoincidere(int anno, int mese)
    {
        var vecchi = await _ef6.OrdiniDelMeseAsync(anno, mese);
        var nuovi = await _efCore.OrdiniDelMeseAsync(anno, mese);

        Assert.Equal(vecchi.Count, nuovi.Count);
        Assert.Equal(
            vecchi.OrderBy(o => o.Id).Select(o => (o.Id, o.Numero, o.Totale, o.ClienteRagioneSociale)),
            nuovi.OrderBy(o => o.Id).Select(o => (o.Id, o.Numero, o.Totale, o.ClienteRagioneSociale)));
    }

    [Fact]
    public async Task SalvaOrdine_ConDatiNonValidi_DeveRifiutare()
    {
        var ordine = new OrdineNuovo { Numero = null, ClienteId = 0 };
        var e6 = await Assert.ThrowsAnyAsync<Exception>(() => _ef6.CreaAsync(ordine));
        var core = await Assert.ThrowsAnyAsync<Exception>(() => _efCore.CreaAsync(ordine));
        Assert.NotNull(e6);
        Assert.NotNull(core);
    }
}

Il secondo test è quello che trova la validazione sparita. Il primo trova le query che restituiscono dati diversi per via del caricamento pigro, dell'ordinamento o del confronto fra stringhe. Con trenta test così, la migrazione cambia natura: non è più un atto di fede, è un lavoro verificabile.

Confrontare anche l'SQL

Per le query più pesanti conviene guardare anche il testo SQL generato dai due motori e il piano di esecuzione. EF Core registra le query con LogTo o con ToQueryString(). Se una query che durava 80 millisecondi ne richiede 900, il test sui risultati passa ma la migrazione non è finita.

Si possono usare EF6 e EF Core insieme durante la migrazione?

Sì, nello stesso progetto, con due contesti separati sullo stesso database. Funziona bene se le due parti non condividono le stesse entità in memoria e se le scritture che devono essere atomiche passano da un'unica transazione.

È il cuore del metodo a pezzi. Per un periodo, nell'applicazione convivono un contesto EF6 per le aree non ancora migrate e uno EF Core per quelle già passate. I due non si parlano: ciascuno ha le sue classi, il suo DbContext, la sua configurazione. Il database è uno.

Le regole della coesistenza

Le regole sono quattro. Primo: ogni tabella ha un solo "proprietario" durante la migrazione, cioè un solo motore che la scrive. Secondo: le classi delle entità non si condividono, si duplicano in cartelle diverse con namespace diversi, e si cancellano quelle vecchie a lavoro finito. Terzo: lo schema lo modifica solo EF Core. Quarto: quando una operazione deve toccare tabelle dei due motori in modo atomico, si usa una transazione condivisa.

// Una transazione condivisa fra un contesto EF6 e uno EF Core, sulla stessa connessione.
// Qui "conn" è una SqlConnection aperta, passata ad entrambi.
using var conn = new SqlConnection(stringaConnessione);
await conn.OpenAsync();
using var tx = conn.BeginTransaction();

try
{
    // Parte EF6: la connessione e la transazione sono già aperte.
    using (var vecchio = new GestionaleEf6(conn, contextOwnsConnection: false))
    {
        vecchio.Database.UseTransaction(tx);
        vecchio.Magazzino.Add(new MovimentoMagazzino { Codice = "SER-120", Quantita = -4 });
        await vecchio.SaveChangesAsync();
    }

    // Parte EF Core: stessa connessione, stessa transazione.
    var opzioni = new DbContextOptionsBuilder<GestionaleContext>().UseSqlServer(conn).Options;
    using (var nuovo = new GestionaleContext(opzioni))
    {
        await nuovo.Database.UseTransactionAsync(tx);
        nuovo.Ordini.Add(new Ordine { Numero = "2026-0412", ClienteId = 88 });
        await nuovo.SaveChangesAsync();
    }

    await tx.CommitAsync();
}
catch
{
    await tx.RollbackAsync();
    throw;
}

Questo schema funziona con una connessione sola e con SQL Server. È una soluzione di transizione, non una soluzione di arrivo: serve a evitare di dover migrare un'area intera solo perché un'operazione tocca due tabelle di aree diverse. Quando l'ultima area è passata, il codice EF6 sparisce e la transazione torna a essere normale.

L'ordine delle aree

Il criterio che uso è semplice. Si comincia da un'area piccola, usata ogni giorno e con poche relazioni: un'anagrafica di tabelle di servizio, un modulo di reportistica. Si passa poi a un'area centrale ma ben coperta dai test. Si lascia per ultimo il pezzo con più stored procedure e più regole nascoste, quando ormai la squadra ha preso il ritmo. È lo stesso ragionamento che applico quando porto un sito ASP classico a pagine, e che puoi leggere in migrazione da ASP classico a ASP.NET Core.

Che prestazioni aspettarsi da EF Core rispetto a EF6?

In genere meglio, ma non da sole. EF Core è più veloce nella traduzione e nell'esecuzione, e offre strumenti nuovi (query divise, query compilate, modello precompilato). Il guadagno grosso arriva solo se si toglie il caricamento pigro e si scrivono query con la forma giusta.

Quello che cambia senza fare nulla

Un progetto migrato "uno a uno" ottiene spesso un miglioramento modesto ma reale: avvio più rapido, meno memoria per entità, SQL più compatto. Non prometto percentuali, perché dipendono dal modello e dalle query. Misura prima e dopo, sul tuo carico, con gli stessi dati.

Quello che si ottiene toccando le query

Tre interventi fanno la differenza, e sono gli stessi che descrivo nella guida su Entity Framework Core. Il primo è AsNoTracking() per le sole letture: EF Core non deve seguire le modifiche di entità che non cambierai. Il secondo è la proiezione con Select: leggere solo le colonne che servono invece dell'entità intera. Il terzo è AsSplitQuery() quando un Include su collezioni moltiplica le righe.

// Una lista con testata, righe e cliente: query divisa e sola lettura.
var ordini = await db.Ordini
    .AsNoTracking()
    .AsSplitQuery()
    .Include(o => o.Righe)
    .Include(o => o.Cliente)
    .Where(o => o.Stato == StatoOrdine.Aperto)
    .OrderByDescending(o => o.Data)
    .Take(100)
    .ToListAsync();

// Una query usata migliaia di volte al giorno: si compila una volta.
private static readonly Func<GestionaleContext, int, Task<Cliente?>> ClientePerId =
    EF.CompileAsyncQuery((GestionaleContext c, int id) =>
        c.Clienti.FirstOrDefault(x => x.Id == id));

La regola dei cento

Una lista che mostra a una persona non serve a niente se ha più di cento righe, e un'elaborazione che legge tutta una tabella ogni volta non scala. In più, una query senza limite è il problema più comune che trovo nei progetti migrati: funziona con mille ordini, si blocca con un milione. Ogni elenco deve avere un limite e una paginazione. Per chi ha query pesanti sulle statistiche, conviene una tabella di riepilogo scritta da un'attività notturna, non un calcolo a ogni richiesta.

Quando EF Core non basta

Per le due o tre query davvero critiche (un report su milioni di righe, un'importazione massiva) EF Core non è lo strumento giusto, e nemmeno EF6 lo era. Lì si usa Dapper o SQL diretto, e la regola è: l'ORM per il 95% del lavoro, il SQL per il resto. Il confronto lo trovi in Dapper contro Entity Framework Core.

Come si passa in produzione senza fermare l'azienda?

A ondate: ogni area migrata va in produzione da sola, con la possibilità di tornare indietro in pochi minuti. Il database non cambia, quindi il ritorno al vecchio codice è sempre possibile finché il motore vecchio esiste.

Prima dell'ondata

Si fa una copia del database e si verifica che si ripristini davvero. Si esegue la rete di test contro i dati reali anonimizzati. Si prepara un interruttore: una impostazione che decide, area per area, quale motore usare. Con l'interruttore, tornare indietro è cambiare un valore e riavviare, non rifare una pubblicazione.

Durante l'ondata

Si pubblica fuori orario, all'inizio con un gruppo ristretto di utenti. Si guardano tre misure per due giorni: il tempo di risposta delle pagine dell'area, il numero di errori nel registro e le query lente. Se una delle tre peggiora, si torna indietro con l'interruttore, si capisce la causa e si riprova.

Dopo l'ondata

Si lascia il codice vecchio in piedi per due settimane, non di più: oltre quel tempo diventa una scusa per non finire. Poi si cancella. Il giorno in cui si cancella l'ultima classe EF6 si toglie anche il pacchetto EntityFramework dal progetto, e si celebra: è la fine del rischio della persona sola nel livello dati.

Quando c'è anche il runtime da cambiare

Se l'applicazione è ancora su .NET Framework, il passaggio all'ultimo EF Core richiede anche .NET moderno. In quel caso l'ordine che consiglio è: prima rete di test e pulizia del modello su EF6, poi migrazione del runtime con EF6, poi ORM a pezzi. Tre fasi separate, ciascuna con il suo rilascio. Il percorso del runtime lo descrivo in .NET Framework a .NET 10.

Quali errori vedo ripetersi nella migrazione da EF6 a EF Core?

I cinque errori più comuni: migrare tutto in un colpo solo, fidarsi dello scaffold senza controllarlo, ignorare il caricamento pigro, non scrivere i test prima e dimenticare le altre applicazioni che leggono lo stesso database.

1. Migrare tutto in un colpo solo

È la tentazione più forte, perché sembra più pulito. In pratica significa due mesi di lavoro su un ramo che non va mai in produzione e un giorno di passaggio pieno di sorprese. Con il metodo a pezzi, la prima area nuova va in produzione alla terza settimana, e quello che si impara lì vale per tutte le altre.

2. Fidarsi dello scaffold

Il generatore produce un modello che compila, non un modello corretto. Dimentica le regole che esistono solo nel codice (valori predefiniti, conversioni, filtri), indovina male le chiavi esterne non dichiarate e perde i nomi significativi delle relazioni. Va confrontato, riga per riga, con il modello vecchio.

3. Ignorare il caricamento pigro

Se non si decide cosa fare, il progetto compila, i test fatti a mano passano e in produzione compaiono i NullReferenceException o, peggio, i caricamenti lenti. La scelta (proxy attivi o caricamento esplicito) va fatta area per area e annotata.

4. Non scrivere i test prima

Senza una rete che dice cosa faceva il codice ieri, ogni differenza di oggi è un'opinione. Sono 6-10 giorni che sembrano un lusso e che fanno risparmiare il doppio. Ho visto progetti arrivare al collaudo dopo due mesi con un elenco di discrepanze che nessuno sapeva se fossero errori o miglioramenti.

5. Dimenticare le altre applicazioni

Il database dell'azienda non è quasi mai usato solo dal gestionale. Un programma di contabilità, un sito di e-commerce, un report in un foglio di calcolo leggono e scrivono le stesse tabelle. Cambiare una regola di cancellazione o un tipo di dato li può rompere in modo silenzioso. L'inventario delle attività nascoste serve a questo.

Un'ultima nota sull'intelligenza artificiale

Gli assistenti AI aiutano molto: leggono una configurazione fluente di EF6, propongono la versione per EF Core e segnalano i punti dubbi. Ma non conoscono il tuo database e non sanno quali relazioni sono vere. Usati bene, riducono del 30-40% i giorni di scrittura delle mappature; usati male, producono un modello che sembra giusto e nasconde errori su cascate e precisione dei decimali. Per questo il codice generato passa sempre dai test di caratterizzazione. Come lavoro con l'AI nello sviluppo lo spiego anche in Entity Framework Core per accedere ai dati.

Come decidere in cinque righe se migrare da EF6?

Se tocchi il livello dati più di sei volte l'anno, devi passare a .NET moderno o hai query lente, migra a pezzi con una rete di test. Se è un programma fermo e piccolo, mettilo in sicurezza e rimanda.

  1. Conta contesti ed entità, e quante stored procedure usa il codice.
  2. Conta quante volte all'anno si modifica il livello dati e quanti sviluppatori lo conoscono davvero.
  3. Cerca i punti difficili: caricamento pigro, tipi spaziali, SQL a mano, gerarchie complesse.
  4. Controlla chi altro legge o scrive lo stesso database.
  5. Preventiva 32-49 giorni persona ogni 120 entità con 40 stored procedure, e riduci per ogni cosa in ordine (database pulito, persona interna disponibile, ambiente di prova).

Con quei cinque numeri, in mezz'ora ti dico quale strada conviene e quanto costa, senza sorprese. Il lavoro vero comincia dopo, ma almeno cominci sapendo dove arrivi.

Che cosa cambia dopo la migrazione?

Cambia che il livello dati diventa un progetto normale: moderno, testato, mantenibile da qualsiasi sviluppatore .NET, pronto per .NET 10 e per le funzioni nuove. Il costo nascosto delle modifiche scende e il rischio della persona sola sparisce.

Il primo effetto che i titolari notano non è la velocità, ma la tranquillità: l'applicazione può passare a .NET moderno, si può spostare su un server nuovo e si può far lavorare un secondo sviluppatore senza istruzioni di vent'anni fa. Il secondo effetto sono le modifiche: una funzione nuova che toccava il livello dati richiedeva due giorni, adesso ne richiede mezzo, perché non c'è più un designer da rigenerare e c'è una rete di test. Il terzo è la sicurezza, perché il SQL diventa parametrizzato di serie.

Il quarto effetto è il più importante: le possibilità. Un livello dati su EF Core permette di mettere un'API davanti, di separare letture e scritture, di aggiungere una ricerca o un componente di intelligenza artificiale senza riscrivere nulla. Non è il motivo per cui si migra, ma è il motivo per cui ne vale la pena.

Se ti interessa il quadro completo di come affronto questi progetti, dall'inventario al passaggio, lo descrivo nella pagina sulla modernizzazione del software legacy. Non è un prodotto da comprare: è un metodo, e comincia sempre da quei cinque numeri.

Entity Framework 6 ha retto per più di dieci anni il lavoro di migliaia di aziende. Staccarlo con ordine, un pezzo alla volta e senza fermare nessuno, è il modo giusto di ringraziarlo.

Domande frequenti

Un modello da circa 120 entità, due contesti e 40 stored procedure richiede 32-49 giorni persona, cioè 19.200-29.400 euro con una tariffa di riferimento di 600 euro al giorno. Un modello piccolo da una ventina di entità costa 4.800-7.200 euro. Il costo dipende da caricamento pigro, stored procedure e stato del database più che dal numero di entità.

Sì, ma solo in manutenzione: correzioni di sicurezza e compatibilità, nessuna funzione nuova. Tutte le novità e i miglioramenti di prestazioni vanno su EF Core.

Sì, con due contesti separati sullo stesso database. Ogni tabella ha un solo motore che la scrive, lo schema lo modifica solo EF Core e le operazioni atomiche fra i due usano una transazione condivisa sulla stessa connessione.

No. EF Core legge lo stesso schema di EF6. Si crea una migrazione di base che rappresenta lo stato attuale e da lì in poi le modifiche allo schema passano da EF Core.

Il caricamento pigro: in EF Core non è attivo di serie e le proprietà di navigazione restano nulle. Subito dopo viene la validazione degli attributi, che EF Core non esegue più al salvataggio.

Con test di caratterizzazione sulle trenta query più importanti, eseguiti contro EF6 e EF Core con gli stessi dati: se i risultati coincidono, quel punto è migrato correttamente.

Lascia i tuoi dati nel form qui sotto

Matteo Migliore

Matteo Migliore è un imprenditore e architetto software con oltre 27 anni di esperienza nello sviluppo di soluzioni basate su .NET e nell'evoluzione di architetture applicative per imprese e organizzazioni di alto profilo.

Nel corso della sua carriera ha collaborato con realtà come Cotonella, Il Sole 24 Ore, FIAT e NATO, guidando team nello sviluppo di piattaforme scalabili e modernizzando ecosistemi legacy complessi.

Ha formato centinaia di sviluppatori e affiancato aziende di ogni dimensione nel trasformare il software in un vantaggio competitivo, riducendo il debito tecnico e portando risultati concreti in tempi misurabili.

Stai leggendo perché vuoi smettere di rattoppare software fragile.Scopri il metodo per progettare sistemi che reggono nel tempo.