Migration et données

Entity Framework Core pour les développeurs ADO.NET : les bons réflexes

Migration et données · · Publié le 2026-11-10

Si vous avez écrit des années de SqlCommand, de SqlDataReader et de DataSet, Entity Framework Core peut ressembler à une boîte noire qui génère du SQL à votre place. C'est à la fois sa force et son piège. Cet article présente les quelques règles qui séparent une application EF Core rapide et sûre d'une application qui envoie trois cents requêtes pour afficher une page.

Le DbContext : une unité de travail, pas un singleton

Le DbContext représente une session avec la base : il suit les entités chargées, accumule les modifications et les envoie en une transaction lors de SaveChanges. Il n'est pas prévu pour être partagé entre plusieurs threads ni conservé longtemps. Dans ASP.NET Core, l'enregistrement standard lui donne une durée de vie « par requête », ce qui correspond exactement à son usage (configuration du DbContext) :

builder.Services.AddDbContext<GestionContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Gestion")));

Le réflexe ADO.NET « une connexion ouverte au plus tard, fermée au plus tôt » reste valable : EF Core ouvre la connexion pour chaque requête et la rend au pool aussitôt, sauf transaction explicite.

En lecture seule, désactivez le suivi

Par défaut, chaque entité chargée est enregistrée dans le traqueur de modifications, pour qu'EF Core sache quoi mettre à jour plus tard. Pour une page qui ne fait qu'afficher, ce suivi coûte de la mémoire et du temps pour rien. AsNoTracking() le supprime (requêtes avec et sans suivi). Mieux encore, projetez uniquement les colonnes utiles : une projection vers un type dédié n'est jamais suivie et produit un SELECT limité aux champs demandés.

var lignes = await db.Commandes
    .Where(c => c.ClientId == clientId && c.Date >= debut)
    .OrderByDescending(c => c.Date)
    .Select(c => new CommandeLigne(c.Numero, c.Date, c.MontantTtc))
    .ToListAsync();

C'est l'équivalent moderne du SqlDataReader lu colonne par colonne : on ne rapatrie que ce que l'écran affiche.

Données liées : éviter le piège du N+1

Le problème le plus courant vient des relations. Chargez une liste de commandes, puis, dans la vue, affichez le nom du client de chacune : si le chargement différé (lazy loading) est activé, EF Core émet une requête par commande. Cent lignes, cent une requêtes. Le chargement différé n'est pas actif par défaut dans EF Core, et c'est une bonne chose ; gardez-le ainsi et dites explicitement ce dont vous avez besoin, soit par une projection comme ci-dessus, soit avec Include (chargement hâtif).

Inclure plusieurs collections dans une même requête crée l'effet inverse : une jointure dont le nombre de lignes est le produit des collections, que la documentation appelle « explosion cartésienne ». AsSplitQuery() découpe alors la requête en plusieurs SELECT plus légers (requêtes uniques et fractionnées). Il n'y a pas de bonne réponse universelle : mesurez.

Voir le SQL généré

Ne travaillez jamais à l'aveugle. La méthode ToQueryString() affiche le SQL d'une requête LINQ, et la journalisation d'EF Core écrit chaque commande exécutée avec sa durée (journalisation simple). L'option EnableSensitiveDataLogging() ajoute les valeurs des paramètres : utile en développement, à proscrire en production, où ces valeurs sont souvent des données personnelles.

Le SQL brut, en restant paramétré

EF Core n'interdit pas le SQL écrit à la main, pour une requête de reporting complexe ou une fonction propre à SQL Server. FromSql et SqlQuery acceptent une chaîne interpolée et transforment chaque valeur en paramètre SQL ; FromSqlRaw prend la chaîne telle quelle et n'est sûre que si vous lui passez les paramètres séparément (requêtes SQL). La règle de nos tutoriels ADO.NET reste donc la même : jamais de concaténation de saisie utilisateur dans une requête.

// Paramètre @p0 généré automatiquement
var actifs = await db.Database
    .SqlQuery<int>($"SELECT Id FROM Clients WHERE Departement = {codeDepartement}")
    .ToListAsync();

Mises à jour et suppressions en masse

Pour clôturer toutes les commandes d'un exercice, il est inutile de les charger une à une puis d'appeler SaveChanges. Depuis EF Core 7, ExecuteUpdate et ExecuteDelete traduisent l'opération en un seul UPDATE ou DELETE côté serveur (ExecuteUpdate et ExecuteDelete) :

await db.Commandes
    .Where(c => c.Exercice == 2025 && c.Statut == Statut.Livree)
    .ExecuteUpdateAsync(s => s.SetProperty(c => c.Statut, Statut.Cloturee));

Ces méthodes contournent le traqueur de modifications : les entités déjà chargées dans le même contexte ne sont pas mises à jour en mémoire.

Migrations : générer un script, ne pas migrer au démarrage

Les migrations décrivent l'évolution du schéma en C# et se créent avec dotnet ef migrations add. Pour la production, la documentation déconseille d'appeler Database.Migrate() au lancement de l'application : plusieurs instances peuvent tenter la même migration en même temps, et le compte applicatif aurait besoin de droits de modification du schéma. Générez plutôt un script SQL idempotent, relu puis appliqué par votre chaîne de déploiement (appliquer les migrations) :

dotnet ef migrations script --idempotent --output migrations.sql

Ce script se vérifie comme n'importe quel changement de base : un renommage de colonne mal détecté se traduit par une suppression suivie d'un ajout, donc par une perte de données.

Concurrence : le retour du « dernier qui enregistre gagne »

Dans une application Web Forms, le ViewState masquait souvent ce problème. Deux agents ouvrent la même fiche, chacun la modifie, le second écrase silencieusement le travail du premier. EF Core gère la concurrence optimiste avec un jeton : sur SQL Server, une colonne rowversion déclarée avec l'attribut [Timestamp]. Si la ligne a changé entre la lecture et l'écriture, SaveChanges lève une DbUpdateConcurrencyException que vous pouvez présenter à l'utilisateur (gestion des conflits de concurrence). N'oubliez pas de transmettre la valeur du jeton dans un champ caché du formulaire.

Tri et recherche en français

Les recherches et les tris de texte sont exécutés par la base, selon sa collation, et non par .NET. Sur une base SQL Server installée en French_CI_AS, une recherche sur « Étienne » trouvera « etienne » sans distinction de casse, mais pas forcément sans distinction d'accents (le suffixe AS signifie sensible aux accents). EF Core permet de préciser une collation par colonne ou par requête avec EF.Functions.Collate ; la page collations et respect de la casse explique les conséquences sur les index.

ADO.NET a-t-il encore sa place ?

Oui. EF Core et ADO.NET cohabitent sans difficulté : le contexte expose sa connexion, et certaines tâches (import massif avec SqlBulkCopy, appel d'une procédure stockée historique aux multiples jeux de résultats) restent plus simples en ADO.NET. Pour l'essentiel des écrans de gestion, EF Core fait gagner du temps, à condition de regarder le SQL qu'il produit. Les conseils de performance pour les requêtes de Microsoft sont la lecture suivante idéale.

Documentation officielle