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

L'image

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.

Le trio minimal
// 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")));
Rappel de durée de vie

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 JSON
Types complexes plutôt que owned types EF 10

Pour 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
Une migration est du code : relis-la, corrige-la
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");
}
Les quatre pièges des migrations
  1. 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.
  2. Renommer une propriété : EF génère souvent supprimer + créer, donc une perte de données. Remplace par mb.RenameColumn(...).
  3. 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.
  4. 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();
Projeter plutôt qu'inclure

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

❌ Sans Include : 1 requête pour la liste, puis 1 par élément SELECT * FROM Blogs SELECT … BlogId=1 SELECT … BlogId=2 SELECT … BlogId=3 … × 200 blogs 201 allers-retours réseau ≈ 2 000 ms ✅ Avec Include (ou projection) : une seule requête SELECT … FROM Blogs LEFT JOIN Billets … 1 aller-retour ≈ 25 ms
201 requêtes
var blogs = await db.Blogs.ToListAsync();
foreach (var b in blogs)
    // chaque accès déclenche une requête
    Console.WriteLine(b.Billets.Count);
1 requête
var blogs = await db.Blogs
    .Select(b => new { b.Nom, Nb = b.Billets.Count })
    .ToListAsync();
// EF calcule le COUNT en SQL
Comment le détecter (à activer en développement)
builder.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é.
Ne jamais activer le chargement paresseux côté serveur web

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);
Opérations en masse : sans charger les entités (EF 7+)
// ❌ 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éflexeGain typiqueDétail
Un index sur chaque colonne filtrée ou triée×10 à ×1000Le gain nº 1, très loin devant tout le reste
Projeter vers un DTO×2 à ×10Moins de colonnes, moins de réseau, pas de suivi
AsNoTracking() en lecture~30 %Inutile si tu projettes déjà
Corriger un N+1×10 à ×100Le bug le plus fréquent
AsSplitQuery() avec 2+ collections×2 à ×50Évite l'explosion cartésienne
Pagination systématiqueJamais de ToListAsync() sans Take
ExecuteUpdate / ExecuteDelete×10+Pour les opérations en masse
AddDbContextPool5–10 %Recycle les instances de contexte, sous forte charge
Requêtes compiléesquelques %Utile seulement sur un chemin très chaud et mesuré
SQL brut, quand LINQ ne suffit pas
// 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

ApprocheFidélitéVerdict
UseInMemoryDatabaseFaible : ni contraintes, ni transactions, ni SQL réelÀ éviter — les tests passent, la production échoue
SQLite en mémoireMoyenne : du vrai SQL, mais des types et fonctions différentsAcceptable pour de la logique simple et rapide
Testcontainers (vrai SQL Server / PostgreSQL en conteneur)TotaleLa bonne réponse pour les tests d'intégration

Détails et exemples au chapitre 18.

Le tableau des pièges

SymptômeCauseCorrection
A second operation was started on this contextDeux opérations parallèles sur le même contexteawait 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 translatedAppel d'une méthode C# non traduisible en SQLFiltrer en SQL d'abord, puis AsEnumerable() pour la suite en mémoire
Requête très lente sans raison apparenteIndex manquant, ou AsEnumerable() trop tôtToQueryString() + plan d'exécution
Doublons entre pagesTri non déterministeAjouter la clé primaire comme dernier critère de tri
Modification perdue sans erreurPas de jeton de concurrence[Timestamp] + gestion de DbUpdateConcurrencyException
Cycle infini à la sérialisation JSONEntité renvoyée avec ses navigationsProjeter vers un DTO
Arrondis monétaires fauxdecimal sans précision → colonne par défautHasPrecision(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 ?

C'est un N+1 caractéristique. 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 ?

La projection est strictement meilleure pour de l'affichage. 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 ?

100 billets × 50 abonnés = 5 000 lignes transférées pour 151 objets utiles.

4. Comment appliquer une migration en production ?

Le script est relisable, validable, journalisé et rejouable. 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 ?

« Last write wins » : la modification perdue ne laisse aucune trace. Une propriété [Timestamp] transforme ce silence en DbUpdateConcurrencyException exploitable.

Fiches de révision

Durée de vie du DbContext ?
Scoped : un par requête. Jamais partagé entre threads, jamais singleton.
Lecture pour affichage ?
Projection vers un DTO (+ AsNoTracking si tu prends l'entité entière).
Signature du N+1 ?
La même requête répétée N fois dans les logs. Corrige par Include ou projection.
Deux collections incluses ?
AsSplitQuery() pour éviter l'explosion cartésienne.
Modifier 50 000 lignes ?
ExecuteUpdateAsync : un seul UPDATE, rien en mémoire.
Tester avec une base ?
Testcontainers (vraie base). Éviter UseInMemoryDatabase.
À retenir