Accueil › Construire le web › Chapitre 15
EF Core : la base de données
EF Core traduit tes classes C# en tables et tes requêtes LINQ en SQL. C'est extrêmement confortable — et c'est précisément le danger : trois lignes innocentes peuvent produire quatre cents requêtes. Ce chapitre apprend à s'en servir et à voir ce qu'il fait.
Ce qu'est un ORM
Un interprète entre deux cultures. D'un côté, des objets : ils ont une identité, des références les uns vers les autres, de l'héritage. De l'autre, des tables : des lignes, des colonnes, des clés étrangères, des jointures. L'ORM traduit dans les deux sens. Comme tout interprète, il est excellent sur les phrases usuelles, et il faut le surveiller sur les tournures exotiques.
// 1. Les entités : des classes ordinaires
public class Blog
{
public int Id { get; set; } // clé primaire par convention
public required string Nom { get; set; }
public List<Billet> Billets { get; set; } = []; // relation un-à-plusieurs
}
public class Billet
{
public int Id { get; set; }
public required string Titre { get; set; }
public DateTime Publie { get; set; }
public int BlogId { get; set; } // clé étrangère
public Blog? Blog { get; set; } // navigation inverse
}
// 2. Le contexte : ta session de travail avec la base
public class AppDb(DbContextOptions<AppDb> options) : DbContext(options)
{
public DbSet<Blog> Blogs => Set<Blog>();
public DbSet<Billet> Billets => Set<Billet>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<Blog>().Property(b => b.Nom).HasMaxLength(200).IsRequired();
mb.Entity<Billet>().HasIndex(b => b.Publie);
}
}
// 3. L'enregistrement (Program.cs)
builder.Services.AddDbContext<AppDb>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Defaut")));
Un DbContext est scoped : un par requête HTTP, jamais partagé entre threads, jamais singleton. Il n'est pas thread-safe et accumule le suivi des entités chargées (chapitre 11).
Modéliser : conventions, annotations, API fluide
| Ce que EF devine seul (conventions) |
|---|
Une propriété Id ou <Type>Id devient la clé primaire, auto-incrémentée |
<Navigation>Id devient une clé étrangère |
Une propriété non nullable devient une colonne NOT NULL |
Le nom du DbSet devient le nom de la table |
Une string devient nvarchar(max) — à contraindre, sinon pas d'index possible |
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<Billet>(e =>
{
e.ToTable("Billets");
e.HasKey(b => b.Id);
e.Property(b => b.Titre).HasMaxLength(250).IsRequired();
e.Property(b => b.Publie).HasDefaultValueSql("GETUTCDATE()");
e.HasIndex(b => new { b.BlogId, b.Publie }); // index composite
e.Property(b => b.Prix).HasPrecision(18, 2); // decimal : TOUJOURS préciser
// Conversion de valeur : un enum stocké en texte lisible
e.Property(b => b.Statut).HasConversion<string>().HasMaxLength(20);
});
// Séparer la configuration par entité quand le modèle grossit
mb.ApplyConfigurationsFromAssembly(typeof(AppDb).Assembly);
}L'API fluide garde le modèle de persistance hors des classes métier : ces dernières ne dépendent pas d'EF Core. C'est l'approche à privilégier dès qu'un projet dépasse quelques entités.
public class Billet
{
public int Id { get; set; }
[Required, MaxLength(250)]
public string Titre { get; set; } = "";
[Precision(18, 2)]
public decimal Prix { get; set; }
[Column("date_publication")]
public DateTime Publie { get; set; }
[NotMapped] // ignoré en base
public string Resume => Titre[..Math.Min(50, Titre.Length)];
}Plus rapide à écrire, mais mélange métier et persistance, et ne couvre pas tout (index composites, relations complexes, filtres globaux). En pratique : annotations pour le trivial, API fluide pour le reste.
// Un-à-plusieurs (le plus fréquent)
mb.Entity<Billet>()
.HasOne(b => b.Blog)
.WithMany(b => b.Billets)
.HasForeignKey(b => b.BlogId)
.OnDelete(DeleteBehavior.Cascade); // supprimer le blog supprime ses billets
// Plusieurs-à-plusieurs : EF crée la table de liaison tout seul
public class Billet { public List<Etiquette> Etiquettes { get; set; } = []; }
public class Etiquette { public List<Billet> Billets { get; set; } = []; }
// Personnaliser la table intermédiaire si besoin :
mb.Entity<Billet>().HasMany(b => b.Etiquettes).WithMany(e => e.Billets)
.UsingEntity("BilletEtiquette");
// Un-à-un
mb.Entity<Utilisateur>().HasOne(u => u.Profil).WithOne(p => p.Utilisateur)
.HasForeignKey<Profil>(p => p.UtilisateurId);
// Type complexe : une valeur imbriquée, sans identité propre (EF 10)
mb.Entity<Client>().ComplexProperty(c => c.Adresse); // → colonnes Adresse_Rue, Adresse_Ville…
mb.Entity<Client>().ComplexProperty(c => c.Preferences, p => p.ToJson()); // → une colonne JSONPour modéliser une adresse ou un ensemble de préférences imbriqué, les
types complexes remplacent avantageusement les
owned types : ils ont une sémantique de valeur (comparés par contenu,
assignables d'une propriété à l'autre) et fonctionnent avec ExecuteUpdate. Les owned types
restaient des entités déguisées, avec une identité cachée et ses pièges.
Migrations : faire évoluer la base
# Installation de l'outil, une fois par machine
dotnet tool install --global dotnet-ef
# Cycle de travail habituel
dotnet ef migrations add AjoutTableBillets # génère le code de migration (à relire !)
dotnet ef database update # applique à la base
dotnet ef migrations list # état des migrations
dotnet ef migrations remove # annule la DERNIÈRE, si non appliquée
# Production : ne jamais lancer « database update » depuis un poste de développement
dotnet ef migrations script --idempotent -o migration.sql # script relisable et validable
dotnet ef migrations bundle # exécutable autonome pour la CI/CD
public partial class AjoutReference : Migration
{
protected override void Up(MigrationBuilder mb)
{
mb.AddColumn<string>("Reference", "Produits", maxLength: 20, nullable: true);
// Ajouté à la main : remplir l'existant AVANT de rendre la colonne obligatoire
mb.Sql("UPDATE Produits SET Reference = 'LEGACY-' + CAST(Id AS nvarchar(10))");
mb.AlterColumn<string>("Reference", "Produits", maxLength: 20, nullable: false);
mb.CreateIndex("IX_Produits_Reference", "Produits", "Reference", unique: true);
}
protected override void Down(MigrationBuilder mb) // le retour arrière doit fonctionner
=> mb.DropColumn("Reference", "Produits");
}
- Ajouter une colonne non nullable sans valeur par défaut sur une table peuplée : la migration échoue en production alors qu'elle passait sur ta base vide.
- Renommer une propriété : EF génère souvent supprimer + créer, donc une perte de données.
Remplace par
mb.RenameColumn(...). - Deux développeurs, deux migrations en parallèle : l'historique diverge. EF 11 fait volontairement apparaître un conflit Git dans l'instantané du modèle pour t'alerter.
EnsureCreated()et migrations ne se mélangent pas. Le premier crée le schéma sans historique : réserve-le aux tests jetables.
Interroger : ce que devient ton LINQ
// Lecture seule : AsNoTracking évite de construire le suivi des modifications (~30 % plus rapide)
var billets = await db.Billets
.AsNoTracking()
.Where(b => b.Publie > DateTime.UtcNow.AddDays(-7))
.OrderByDescending(b => b.Publie)
.Select(b => new BilletDto(b.Id, b.Titre, b.Blog!.Nom)) // projection : seules 3 colonnes lues
.Take(20)
.ToListAsync(ct);
// Une entité par sa clé (utilise le cache du contexte s'il l'a déjà chargée)
var blog = await db.Blogs.FindAsync([id], ct);
// Charger les données liées explicitement
var avecBillets = await db.Blogs
.Include(b => b.Billets.OrderByDescending(p => p.Publie).Take(5)) // filtre dans l'Include
.ThenInclude(p => p.Etiquettes)
.FirstOrDefaultAsync(b => b.Id == id, ct);
// Voir le SQL généré (précieux en débogage)
var sql = db.Billets.Where(b => b.BlogId == 1).ToQueryString();
Include ramène toutes les colonnes de toutes les entités liées.
Select vers un DTO ne lit que ce que tu affiches, n'a pas besoin de
AsNoTracking (rien à suivre), et documente au passage le besoin réel. Réflexe :
affichage → projection, modification → entité suivie.
Le problème N+1 : l'ennemi nº 1
var blogs = await db.Blogs.ToListAsync();
foreach (var b in blogs)
// chaque accès déclenche une requête
Console.WriteLine(b.Billets.Count);var blogs = await db.Blogs
.Select(b => new { b.Nom, Nb = b.Billets.Count })
.ToListAsync();
// EF calcule le COUNT en SQLbuilder.Services.AddDbContext<AppDb>(o => o
.UseSqlServer(chaine)
.LogTo(Console.WriteLine, LogLevel.Information) // affiche chaque requête SQL
.EnableSensitiveDataLogging() // avec les paramètres — JAMAIS en production
.EnableDetailedErrors());
// Si la console affiche 200 SELECT identiques pour un seul appel HTTP : N+1 trouvé.
UseLazyLoadingProxies() rend le N+1 invisible : chaque accès à une navigation part
silencieusement en base, y compris pendant la sérialisation JSON de la réponse. En application web, charge
explicitement (Include) ou projette.
L'autre extrême : l'explosion cartésienne
// Un blog, 100 billets, 50 abonnés → 100 × 50 = 5 000 lignes ramenées pour 151 objets
var blog = await db.Blogs
.Include(b => b.Billets)
.Include(b => b.Abonnes)
.FirstAsync(b => b.Id == id);
// Solution : plusieurs requêtes, une par collection
var blog = await db.Blogs
.Include(b => b.Billets)
.Include(b => b.Abonnes)
.AsSplitQuery() // ← 3 requêtes simples au lieu d'une monstrueuse
.FirstAsync(b => b.Id == id);
Règle : une seule collection incluse → requête unique ; deux collections ou plus →
AsSplitQuery(). EF Core 11 améliore d'ailleurs sensiblement le SQL des requêtes découpées
(suppression des jointures inutiles, environ 29 % de gain sur un scénario type).
Écrire : suivi des modifications et enregistrement
// Le contexte photographie l'état initial, puis calcule la différence à l'enregistrement
var billet = await db.Billets.FirstAsync(b => b.Id == 42, ct);
billet.Titre = "Nouveau titre"; // aucune requête ici
await db.SaveChangesAsync(ct); // → UPDATE Billets SET Titre=… WHERE Id=42
// Créer
db.Billets.Add(new Billet { Titre = "Bonjour", BlogId = 1 });
// Supprimer
db.Billets.Remove(billet);
// Un SEUL SaveChanges pour tout : c'est une transaction implicite (tout ou rien)
await db.SaveChangesAsync(ct);
// ❌ charge 50 000 entités en mémoire pour les modifier une par une
foreach (var p in await db.Produits.Where(p => p.Categorie == "solde").ToListAsync())
p.Prix *= 0.8m;
await db.SaveChangesAsync();
// ✅ un seul UPDATE côté serveur, rien en mémoire
await db.Produits
.Where(p => p.Categorie == "solde")
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Prix, p => p.Prix * 0.8m), ct);
await db.Sessions.Where(s => s.Expiration < DateTime.UtcNow).ExecuteDeleteAsync(ct);
⚠️ Ces opérations contournent le suivi : les entités déjà chargées en mémoire gardent
l'ancienne valeur, et les événements de SaveChanges (audit, soft delete) ne sont pas
déclenchés. Depuis EF 10, ExecuteUpdateAsync accepte un lambda ordinaire (plus besoin de construire
un arbre d'expression) et sait modifier des propriétés dans une colonne JSON.
Concurrence optimiste
public class Produit
{
public int Id { get; set; }
public decimal Prix { get; set; }
[Timestamp] // colonne rowversion gérée par la base
public byte[]? Version { get; set; }
}
try
{
produit.Prix = 42m;
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException ex)
{
// Quelqu'un a modifié la ligne entre ta lecture et ton écriture
var actuel = await ex.Entries.Single().GetDatabaseValuesAsync(ct);
return Results.Conflict(new { message = "Modifié entre-temps", valeurActuelle = actuel });
}
Sans ce mécanisme, la dernière écriture gagne en silence : la modification de ton collègue
disparaît sans que personne ne le sache. À combiner avec un ETag et
If-Match côté API (chapitre 14).
Filtres globaux : multi-tenant et suppression logique
protected override void OnModelCreating(ModelBuilder mb)
{
// EF 10 : filtres NOMMÉS, donc désactivables individuellement
mb.Entity<Billet>()
.HasQueryFilter("SuppressionLogique", b => !b.EstSupprime)
.HasQueryFilter("Locataire", b => b.LocataireId == _contexte.LocataireId);
}
// Toutes les requêtes sont filtrées automatiquement…
var billets = await db.Billets.ToListAsync(); // WHERE EstSupprime = 0 AND LocataireId = @id
// …et on peut lever un seul filtre quand c'est légitime (page d'administration)
var corbeille = await db.Billets
.IgnoreQueryFilters(["SuppressionLogique"])
.Where(b => b.EstSupprime)
.ToListAsync();
La liste de contrôle performance
| Réflexe | Gain typique | Détail |
|---|---|---|
| Un index sur chaque colonne filtrée ou triée | ×10 à ×1000 | Le gain nº 1, très loin devant tout le reste |
| Projeter vers un DTO | ×2 à ×10 | Moins de colonnes, moins de réseau, pas de suivi |
AsNoTracking() en lecture | ~30 % | Inutile si tu projettes déjà |
| Corriger un N+1 | ×10 à ×100 | Le bug le plus fréquent |
AsSplitQuery() avec 2+ collections | ×2 à ×50 | Évite l'explosion cartésienne |
| Pagination systématique | — | Jamais de ToListAsync() sans Take |
ExecuteUpdate / ExecuteDelete | ×10+ | Pour les opérations en masse |
AddDbContextPool | 5–10 % | Recycle les instances de contexte, sous forte charge |
| Requêtes compilées | quelques % | Utile seulement sur un chemin très chaud et mesuré |
// Paramétré : FromSql interpole en toute sécurité (paramètres SQL, pas de concaténation)
var actifs = await db.Clients
.FromSql($"SELECT * FROM Clients WHERE Ville = {ville}")
.Where(c => c.Actif) // composable avec du LINQ !
.ToListAsync(ct);
// Requête scalaire ou procédure stockée
var total = await db.Database
.SqlQuery<decimal>($"SELECT SUM(Montant) FROM Ventes WHERE Annee = {annee}")
.SingleAsync(ct);
// ⚠️ FromSqlRaw n'échappe RIEN : concaténer une saisie utilisateur = injection SQL.
// EF 10 ajoute un analyseur qui signale la concaténation dans ces méthodes.Transactions explicites — nécessaires seulement si tu enchaînes plusieurs
SaveChanges ou du SQL brut :
await using var tx = await db.Database.BeginTransactionAsync(ct);
try
{
await db.SaveChangesAsync(ct);
await autreService.FaireAsync(ct);
await tx.CommitAsync(ct);
}
catch { await tx.RollbackAsync(ct); throw; }Résilience : o.UseSqlServer(c, s => s.EnableRetryOnFailure()) réessaie
automatiquement les erreurs transitoires (indispensable sur Azure SQL). Attention : incompatible avec les
transactions explicites sans passer par une ExecutionStrategy.
Tester du code qui touche la base
| Approche | Fidélité | Verdict |
|---|---|---|
UseInMemoryDatabase | Faible : ni contraintes, ni transactions, ni SQL réel | À éviter — les tests passent, la production échoue |
| SQLite en mémoire | Moyenne : du vrai SQL, mais des types et fonctions différents | Acceptable pour de la logique simple et rapide |
| Testcontainers (vrai SQL Server / PostgreSQL en conteneur) | Totale | La bonne réponse pour les tests d'intégration |
Détails et exemples au chapitre 18.
Le tableau des pièges
| Symptôme | Cause | Correction |
|---|---|---|
| A second operation was started on this context | Deux opérations parallèles sur le même contexte | await chaque appel ; ou un contexte par tâche via IDbContextFactory |
| Cannot access a disposed object 'AppDb' | Usage après la fin de la requête (tâche non attendue, IQueryable renvoyé trop loin) | Matérialiser avant de sortir ; créer une portée dédiée |
| The LINQ expression could not be translated | Appel d'une méthode C# non traduisible en SQL | Filtrer en SQL d'abord, puis AsEnumerable() pour la suite en mémoire |
| Requête très lente sans raison apparente | Index manquant, ou AsEnumerable() trop tôt | ToQueryString() + plan d'exécution |
| Doublons entre pages | Tri non déterministe | Ajouter la clé primaire comme dernier critère de tri |
| Modification perdue sans erreur | Pas de jeton de concurrence | [Timestamp] + gestion de DbUpdateConcurrencyException |
| Cycle infini à la sérialisation JSON | Entité renvoyée avec ses navigations | Projeter vers un DTO |
| Arrondis monétaires faux | decimal sans précision → colonne par défaut | HasPrecision(18, 2) |
Vérifie que c'est passé
🎯 Quiz — 5 questions
1. Une page affiche 50 commandes et le nom du client de chacune. Les logs montrent 51 requêtes SQL. Que fais-tu ?
Select(c => new { c.Id, Client = c.Client!.Nom })
ramène tout en une requête.2. Différence entre AsNoTracking() et une projection vers un DTO ?
AsNoTracking
reste utile quand tu as besoin de l'entité complète en lecture seule.3. Tu inclus deux collections (Billets et Abonnes) sur un blog. Quel risque ?
4. Comment appliquer une migration en production ?
EnsureCreated ignore complètement l'historique des migrations.5. Deux utilisateurs modifient le même produit. Sans jeton de concurrence, que se passe-t-il ?
[Timestamp] transforme ce silence en DbUpdateConcurrencyException exploitable.Fiches de révision
AsNoTracking si tu prends l'entité entière).Include ou projection.AsSplitQuery() pour éviter l'explosion cartésienne.ExecuteUpdateAsync : un seul UPDATE, rien en mémoire.UseInMemoryDatabase.- Regarde le SQL généré (
LogTo,ToQueryString) : c'est le seul moyen de savoir ce qui se passe. - Projette pour lire, charge l'entité pour modifier.
- N+1 et explosion cartésienne : les deux pièges à reconnaître au premier coup d'œil.
- Migrations : relire le code généré, appliquer par script en production.
- Index sur ce qui est filtré ou trié — le gain le plus important, de loin.
- Concurrence :
[Timestamp], sinon les modifications se perdent en silence.