En quinze ans de missions .NET — startups, scale-ups, grands comptes — j'ai audité des dizaines de codebases. Et il y a des erreurs Entity Framework Core que je retrouve systématiquement, quel que soit le niveau de l'équipe. Pas par incompétence — EF Core est un ORM sophistiqué avec des comportements subtils qui ne sont pas toujours intuitifs. Cet article est ma liste personnelle des 7 patterns qui font le plus de dégâts en production, avec dans chaque cas le code fautif et la correction.
"Un ORM bien configuré vous fait gagner un temps considérable. Un ORM mal utilisé génère des problèmes de performance qui peuvent être dix fois plus coûteux à corriger que si vous aviez écrit les requêtes SQL à la main. EF Core n'échappe pas à cette règle."
Erreur #1 — Le problème N+1 avec les relations
C'est l'erreur numéro un, et de loin la plus destructrice en production. Elle se manifeste quand vous chargez une liste d'entités puis accédez à leurs relations dans une boucle — EF Core génère une requête SQL par entité au lieu d'une seule jointure.
Code fautif :
// ❌ N+1 — 1 SELECT pour les commandes + 1 SELECT par commande pour le client
var orders = await _context.Orders.ToListAsync(cancellationToken);
foreach(var order in orders)
{
// Chaque accès à order.Customer déclenche un SELECT supplémentaire
Console.WriteLine($"{order.Id} — {order.Customer.Name}");
}
Code corrigé :
// ✅ 1 seule requête avec JOIN
var orders = await _context.Orders
.Include(o => o.Customer)
.ToListAsync(cancellationToken);
foreach(var order in orders)
{
Console.WriteLine($"{order.Id} — {order.Customer.Name}");
}
Pour des relations imbriquées profondes, ThenInclude fait la chaîne :
var orders = await _context.Orders
.Include(o => o.Customer)
.ThenInclude(c => c.Address)
.Include(o => o.Lines)
.ThenInclude(l => l.Product)
.ToListAsync(cancellationToken);
Mais attention : Include n'est pas la solution universelle. Si vous chargez 500 commandes avec 10 lignes chacune et 3 relations par ligne, le résultat set explose. Dans ce cas, le Select avec projection (erreur #3) est la vraie solution.
Erreur #2 — AsNoTracking absent sur les queries de lecture
Par défaut, EF Core track toutes les entités qu'il charge — il les met en mémoire dans le change tracker pour détecter les modifications. C'est utile quand vous allez sauvegarder les changements. Pour toutes les queries qui servent uniquement à lire des données (affichage, export, API GET), ce tracking est pur gaspillage.
Code fautif :
// ❌ Change tracking actif pour une lecture pure
public async Task<List<OrderDto>> GetOrdersAsync(CancellationToken ct)
{
var orders = await _context.Orders
.Include(o => o.Lines)
.ToListAsync(ct);
return orders.Select(o => new OrderDto(o.Id, o.Status, o.Lines.Count)).ToList();
}
Code corrigé :
// ✅ AsNoTracking — pas de change tracker, mémoire réduite, queries plus rapides
public async Task<List<OrderDto>> GetOrdersAsync(CancellationToken ct)
{
return await _context.Orders
.AsNoTracking()
.Include(o => o.Lines)
.Select(o => new OrderDto(o.Id, o.Status, o.Lines.Count))
.ToListAsync(ct);
}
Sur des endpoints sous forte charge, l'impact est mesurable : 20 à 40% de réduction de l'allocation mémoire selon la complexité des entités. J'ai également eu de bons résultats avec UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) au niveau du DbContext pour forcer le no-tracking global et l'activer explicitement là où c'est nécessaire.
Erreur #3 — Charger des entités complètes quand une projection suffit
Charger une entité EF Core complète pour n'utiliser que 3 de ses 20 propriétés est un gaspillage systématique. EF Core génère un SELECT * et charge tout en mémoire — colonnes de texte long, champs de configuration, colonnes d'audit — alors que vous n'avez besoin que de l'ID et du statut.
Code fautif :
// ❌ Charge l'entité complète avec toutes ses colonnes
var orders = await _context.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct);
return orders.Select(o => new OrderSummaryDto(o.Id, o.Status, o.CreatedAt));
Code corrigé :
// ✅ Projection côté base de données — SELECT Id, Status, CreatedAt uniquement
return await _context.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.Select(o => new OrderSummaryDto(o.Id, o.Status, o.CreatedAt))
.ToListAsync(ct);
La différence est fondamentale : dans le second cas, EF Core génère un SELECT Id, Status, CreatedAt FROM Orders WHERE CustomerId = @p0. Pas de chargement de colonnes inutiles, pas d'allocation d'objets EF Core intermédiaires. En termes de performance, c'est souvent 3 à 5 fois plus rapide sur des tables avec beaucoup de colonnes.
Erreur #4 — Index manquants sur les clés étrangères
EF Core crée automatiquement des index sur les clés primaires. Il ne crée pas automatiquement d'index sur toutes les clés étrangères. Résultat : une jointure ou un filtrage sur une FK déclenche un full table scan.
Symptôme : une requête sur WHERE CustomerId = @p0 qui prend 2 secondes sur 100 000 lignes alors qu'elle devrait prendre quelques millisecondes.
Configuration Fluent API :
// Infrastructure/Persistence/Configurations/OrderConfiguration.cs
public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders");
builder.HasKey(o => o.Id);
// ✅ Index explicite sur la FK — indispensable pour les queries par client
builder.HasIndex(o => o.CustomerId)
.HasDatabaseName("IX_Orders_CustomerId");
// ✅ Index composé pour les queries fréquentes par statut + date
builder.HasIndex(o => new { o.Status, o.CreatedAt })
.HasDatabaseName("IX_Orders_Status_CreatedAt");
builder.HasOne<Customer>()
.WithMany()
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
}
}
La règle que j'applique : toute colonne qui apparaît dans un WHERE, un JOIN ON, ou un ORDER BY fréquent est candidate à un index. Pas besoin d'indexer tout — les index ont un coût en écriture — mais les FKs utilisées en filtrage sont presque toujours à indexer.
Erreur #5 — Modèle anémique qui contourne l'encapsulation du domaine
EF Core peut mapper n'importe quelle classe, y compris des classes avec des setters publics sur tout. Beaucoup d'équipes tombent dans le piège : des entités EF Core avec des public set partout, modifiables depuis n'importe où. L'ORM dicte alors le design du domaine au lieu du contraire.
Code fautif :
// ❌ Entité anémique — n'importe qui peut mettre n'importe quel état
public class Order
{
public Guid Id { get; set; }
public string CustomerId { get; set; } = default!;
public string Status { get; set; } = default!;
public decimal TotalAmount { get; set; }
public List<OrderLine> Lines { get; set; } = [];
}
Code corrigé :
// ✅ Entité riche — encapsulation des invariants, setters privés
public sealed class Order
{
private readonly List<OrderLine> _lines = [];
// Constructeur privé pour EF Core
private Order() { }
public Guid Id { get; private set; }
public string CustomerId { get; private set; } = default!;
public OrderStatus Status { get; private set; }
public decimal TotalAmount { get; private set; }
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public static Order Create(string customerId, IReadOnlyList<OrderLine> lines)
{
if(string.IsNullOrWhiteSpace(customerId))
{
throw new ArgumentException("CustomerId ne peut pas être vide.", nameof(customerId));
}
if(lines.Count == 0)
{
throw new DomainException("Une commande doit contenir au moins une ligne.");
}
return new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Status = OrderStatus.Pending,
TotalAmount = lines.Sum(l => l.TotalPrice),
_lines = lines.ToList() // via backing field
};
}
public void Cancel()
{
if(Status != OrderStatus.Pending)
{
throw new DomainException($"Impossible d'annuler une commande en statut {Status}.");
}
Status = OrderStatus.Cancelled;
}
}
EF Core gère très bien les backing fields (_lines), les constructeurs privés, et les propriétés en lecture seule — à condition de le configurer correctement dans IEntityTypeConfiguration. Ne laissez pas l'ORM dicter votre modèle de domaine.
Erreur #6 — Migrations incontrôlées en production
La fonctionnalité la plus dangereuse d'EF Core est probablement Database.MigrateAsync() appelé au démarrage de l'application. Je l'ai vu en production sur des systèmes critiques. C'est une bombe à retardement.
Code fautif :
// ❌ Migration automatique au démarrage — catastrophique en production
app.Services.GetRequiredService<AppDbContext>()
.Database.MigrateAsync()
.GetAwaiter()
.GetResult();
Les problèmes : une migration qui échoue à mi-chemin laisse la base dans un état incohérent. Sur plusieurs instances en parallèle (load balancer, Kubernetes), plusieurs instances lancent la migration simultanément — race condition garantie. Et surtout, personne n'a validé manuellement le SQL généré avant qu'il parte en production.
Approche correcte :
# 1. Générer le script SQL idempotent
dotnet ef migrations script --idempotent --output ./migrations/$(date +%Y%m%d)_migration.sql
# 2. Reviewer le SQL manuellement (c'est obligatoire)
# 3. Tester sur staging
# 4. Appliquer via pipeline CI/CD avec étape de validation
En développement local, MigrateAsync() est acceptable. En production, jamais. Le script SQL doit être reviewé, versionné et appliqué via votre processus de déploiement — comme n'importe quel changement critique.
Erreur #7 — CancellationToken oublié sur les opérations async
C'est la plus silencieuse de toutes. Le code fonctionne — jusqu'à ce que votre application soit sous charge et que des utilisateurs annulent leurs requêtes ou que votre infrastructure ait un timeout.
Code fautif :
// ❌ Sans CancellationToken — la requête SQL continue même si le client est parti
public async Task<List<Order>> GetLargeReportAsync()
{
return await _context.Orders
.AsNoTracking()
.Include(o => o.Lines)
.Include(o => o.Customer)
.Where(o => o.CreatedAt.Year == DateTime.Now.Year)
.ToListAsync(); // Pas de CancellationToken !
}
Code corrigé :
// ✅ Avec CancellationToken — la requête SQL est annulée si le client abandonne
public async Task<List<Order>> GetLargeReportAsync(CancellationToken cancellationToken)
{
return await _context.Orders
.AsNoTracking()
.Include(o => o.Lines)
.Include(o => o.Customer)
.Where(o => o.CreatedAt.Year == DateTime.Now.Year)
.ToListAsync(cancellationToken);
}
Propagez le CancellationToken depuis le controller ASP.NET Core jusqu'à chaque appel EF Core. ASP.NET Core l'annule automatiquement quand le client coupe la connexion. Sans ça, vos workers continuent à exécuter des requêtes coûteuses pour personne, consommant des connexions au pool et dégradant les performances pour tous les autres utilisateurs.
"Chacune de ces 7 erreurs a un point commun : elles ne causent pas de bug immédiat. Elles dégradent les performances graduellement, jusqu'au jour où le système tombe sous charge. C'est pour ça qu'elles persistent — personne ne les voit en développement."
Comment éviter ces erreurs structurellement
La plupart de ces erreurs peuvent être détectées automatiquement. Voici les outils que j'intègre sur toutes mes missions :
- EF Core logging en développement :
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information)— vous voyez chaque SQL généré - Convention de test d'architecture : vérifier que toutes les queries passent par
AsNoTracking()via un test d'architecture automatisé - Règle SonarQube personnalisée : détecter les appels
ToListAsync()sansCancellationToken - Review de migrations obligatoire : le SQL généré fait partie de la PR, il est reviewé comme n'importe quel code
- MiniProfiler en staging : détecte les N+1 et les requêtes lentes automatiquement
La règle d'or : tout appel EF Core en lecture doit avoir AsNoTracking(), une projection Select() si vous n'avez pas besoin de l'entité complète, et un CancellationToken. Ces trois points à eux seuls éliminent 80% des problèmes de performance EF Core que j'ai rencontrés en mission.
Vous avez des problèmes de performance sur votre stack .NET / EF Core ?
J'audite votre codebase, identifie les goulots d'étranglement et propose
un plan d'amélioration concret et priorisé.