Entity Framework Core.NETPersistenceDatabaseArchitecture

Bonnes pratiques Entity Framework Core : DbContext court, projections, SQL observé, mises à jour en masse, concurrence optimiste, transactions et migrations sûres.

Yva Hajatiana
11 mars 2026
8 min de lecture
Partager :X / TwitterLinkedIn
Maquette modulaire représentant l’architecture logicielle

Entity Framework Core : bonnes pratiques de persistance

Entity Framework Core réduit le code nécessaire pour lire, modifier et faire évoluer une base de données. Il ne supprime pourtant ni le coût des requêtes, ni les conflits concurrents, ni les décisions de transaction.

Le bon usage d'un ORM consiste à profiter de ses abstractions tout en gardant visibles les opérations importantes : le SQL exécuté, le volume chargé, la durée de vie du DbContext, la frontière transactionnelle et la stratégie de migration.

Le DbContext porte déjà une unité de travail

Dans une API ASP.NET Core, AddDbContext enregistre généralement le contexte avec une durée de vie scoped. Une requête HTTP obtient alors une instance, charge les données nécessaires, applique un cas d'usage et appelle SaveChangesAsync.

builder.Services.AddDbContext<OrdersDbContext>(options =>
    options.UseNpgsql(
        builder.Configuration.GetConnectionString("Orders")));

Cette durée de vie convient lorsque la requête correspond à une unité de travail courte. Un DbContext n'est pas thread-safe et ne doit pas être partagé entre plusieurs traitements concurrents.

Pour un worker ou un traitement qui enchaîne de nombreux messages, créez un contexte par unité de travail avec IDbContextFactory<TContext>. Garder le même contexte pendant des milliers d'opérations augmente le nombre d'entités suivies et rend l'état plus difficile à comprendre.

Configurer le modèle explicitement

Les conventions d'EF Core accélèrent le démarrage, mais les contraintes importantes doivent apparaître dans la configuration du modèle : taille des colonnes, index uniques, relations, conversions et concurrence.

public sealed class OrderConfiguration
    : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("orders");
        builder.HasKey(order => order.Id);

        builder.Property(order => order.Number)
            .HasMaxLength(32)
            .IsRequired();

        builder.HasIndex(order => order.Number)
            .IsUnique();

        builder.Property(order => order.Version)
            .IsRowVersion();
    }
}

IsRowVersion correspond à une colonne rowversion générée par SQL Server. Avec PostgreSQL et Npgsql, le même appel s'appuie sur la colonne système xmin : la propriété doit alors être de type uint, et non byte[].

La validation applicative peut retourner une erreur agréable, mais la base reste la dernière protection contre deux écritures concurrentes. Une contrainte unique est donc nécessaire même si l'application vérifie préalablement qu'un numéro n'existe pas.

Projeter les lectures au lieu de charger un graphe complet

Une page de liste n'a généralement pas besoin d'entités suivies ni de toutes leurs colonnes. Projetez directement le résultat attendu :

public Task<List<OrderListItem>> GetRecentOrdersAsync(
    Guid customerId,
    CancellationToken cancellationToken) =>
    _dbContext.Orders
        .Where(order => order.CustomerId == customerId)
        .OrderByDescending(order => order.CreatedAt)
        .Select(order => new OrderListItem(
            order.Id,
            order.Number,
            order.Status,
            order.Total,
            order.Items.Count))
        .Take(50)
        .ToListAsync(cancellationToken);

La projection réduit les colonnes transférées et évite de matérialiser un agrégat uniquement pour construire un DTO. Comme le résultat ne contient aucune entité, EF Core n'active pas le suivi dans cet exemple : ajouter AsNoTracking serait redondant.

AsNoTracking devient utile lorsqu'une requête matérialise des entités destinées uniquement à la lecture. Ce choix n'est toutefois pas universel. Une requête suivie effectue aussi la résolution d'identité : plusieurs lignes qui référencent la même entité peuvent réutiliser la même instance. Pour un graphe contenant beaucoup de références répétées, mesurez avant de supposer que le no-tracking sera toujours plus efficace.

Examiner le SQL réellement produit

Une expression LINQ lisible peut générer une requête coûteuse. En développement, ToQueryString() permet d'inspecter sa traduction sans l'exécuter :

var query = _dbContext.Orders
    .TagWith("Orders.RecentForCustomer")
    .Where(order => order.CustomerId == customerId)
    .OrderByDescending(order => order.CreatedAt)
    .Take(50);

var sql = query.ToQueryString();

TagWith aide à retrouver la requête dans les logs ou les outils de diagnostic. L'étape suivante reste l'analyse côté base : plan d'exécution, index utilisés, cardinalité et nombre de lectures.

Surveillez notamment :

  • les N+1 provoqués par des chargements répétés ;
  • les Include qui récupèrent plusieurs collections ;
  • les filtres exécutés côté client après matérialisation ;
  • les colonnes volumineuses dupliquées par des jointures ;
  • les listes sans limite ni pagination ;
  • les appels multiples qui pourraient être évités.

Single query ou split query : choisir le coût acceptable

Charger plusieurs collections avec des jointures peut produire une explosion cartésienne. AsSplitQuery demande à EF Core d'exécuter plusieurs requêtes :

var order = await _dbContext.Orders
    .AsSplitQuery()
    .Include(current => current.Items)
    .Include(current => current.Payments)
    .SingleAsync(current => current.Id == orderId, cancellationToken);

La split query réduit la duplication des lignes, mais introduit plusieurs allers-retours et peut observer des états différents si les données changent entre les requêtes. Le mode single query reste celui par défaut. Choisissez selon le volume, la latence réseau et le besoin de cohérence, puis mesurez le SQL et les résultats.

Pour un écran de lecture, une projection ciblée est souvent plus simple qu'un grand graphe chargé avec plusieurs Include.

Modifier un agrégat avec un contexte court

Pour une écriture métier, chargez l'agrégat nécessaire, invoque son comportement puis enregistre :

public async Task HandleAsync(
    CancelOrderCommand command,
    CancellationToken cancellationToken)
{
    var order = await _dbContext.Orders
        .SingleOrDefaultAsync(
            current => current.Id == command.OrderId,
            cancellationToken)
        ?? throw new OrderNotFoundException(command.OrderId);

    order.Cancel(command.Reason);

    await _dbContext.SaveChangesAsync(cancellationToken);
}

Le domaine décide si l'annulation est autorisée. EF Core détecte les changements et construit l'UPDATE. Le handler orchestre le cas d'usage sans réimplémenter les invariants dans la couche de persistance.

Un repository générique qui expose Add, Update, Delete et IQueryable<T> apporte rarement une frontière plus claire que le DbContext. Si une abstraction est nécessaire, donnez-lui un vocabulaire métier ou une intention de requête précise. Ce point est développé dans la Clean Architecture en .NET sans dogmatisme.

Mettre à jour en masse sans charger les entités

Charger des milliers d'entités pour modifier un champ est coûteux. Depuis EF Core 7, ExecuteUpdateAsync et ExecuteDeleteAsync traduisent l'opération en une seule instruction SQL :

int archived = await _dbContext.Orders
    .Where(order => order.Status == OrderStatus.Delivered
        && order.DeliveredAt < cutoff)
    .ExecuteUpdateAsync(
        setters => setters.SetProperty(order => order.Status, OrderStatus.Archived),
        cancellationToken);

Ces méthodes contournent le suivi des changements : les entités déjà chargées dans le contexte ne sont pas mises à jour, les intercepteurs de SaveChanges ne s'exécutent pas et les invariants du domaine ne sont pas appelés. Réservez-les aux opérations techniques ou aux règles que vous acceptez d'exprimer directement en SQL.

Gérer explicitement la concurrence optimiste

Deux utilisateurs peuvent charger la même commande puis la modifier. Avec un token de concurrence, EF Core ajoute sa valeur d'origine à la clause WHERE de l'UPDATE. Si aucune ligne ne correspond, SaveChangesAsync lève DbUpdateConcurrencyException.

try
{
    await _dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
    throw new OrderChangedByAnotherUserException(command.OrderId);
}

L'application doit choisir une stratégie : demander à l'utilisateur de recharger, fusionner certains champs ou rejouer une opération idempotente. Réessayer automatiquement sans recharger les données peut reproduire le même conflit ou écraser une décision plus récente.

Utiliser les transactions au bon niveau

Par défaut, un appel à SaveChanges est transactionnel lorsque le fournisseur le permet. Toutes les modifications de cet appel réussissent ou sont annulées. Pour de nombreux cas d'usage, cela suffit.

Une transaction manuelle devient pertinente lorsqu'une opération atomique contient plusieurs appels à SaveChanges ou mélange EF Core avec des commandes sur la même connexion. Elle ne doit pas englober un appel HTTP distant ou un traitement long : cela conserve des verrous et fragilise le système.

Pour publier un événement après une écriture, une table Outbox enregistrée dans la même transaction locale est généralement plus fiable qu'une transaction distribuée avec le broker. La mise en œuvre complète est décrite dans Inbox et Outbox en .NET : messages fiables.

Déployer les migrations comme du code de production

Une migration générée n'est pas automatiquement sûre. Elle peut transformer un renommage en suppression de colonne, verrouiller une grande table ou nécessiter un déploiement en plusieurs étapes.

Avant la production :

  1. inspectez le code et le SQL générés ;
  2. testez la migration sur un volume représentatif ;
  3. vérifiez la compatibilité entre l'ancienne et la nouvelle version de l'application ;
  4. préparez le retour arrière ou la correction ;
  5. appliquez la migration avec un processus contrôlé.

Microsoft recommande les scripts SQL pour les déploiements où une revue est nécessaire. Un script idempotent peut être généré ainsi :

dotnet ef migrations script --idempotent

Les migration bundles constituent une autre option pour la CI/CD. Évitez d'accorder par défaut à chaque instance de l'application les droits de modifier le schéma au démarrage.

Checklist de persistance

  • Le DbContext vit-il uniquement pendant l'unité de travail ?
  • Les lectures projettent-elles les colonnes réellement nécessaires ?
  • Les requêtes non modifiées ont-elles besoin du tracking ?
  • Le SQL et son plan d'exécution ont-ils été observés ?
  • Les listes sont-elles bornées ou paginées ?
  • Les contraintes uniques et étrangères existent-elles dans la base ?
  • Les conflits concurrents sont-ils traduits en comportement métier ?
  • La transaction est-elle aussi courte que possible ?
  • Les migrations sont-elles relues et testées avant déploiement ?

Conclusion

Entity Framework Core est une bonne abstraction lorsque ses effets restent observables. Il accélère le mapping, le suivi des changements et les migrations, mais la base de données conserve ses propres contraintes de coût et de concurrence.

Projetez les lectures, gardez les contextes courts, observez le SQL, laissez la base garantir l'intégrité et traitez les migrations comme une opération de production. Côté validation des entrées en amont, voir FluentValidation en ASP.NET Core ; côté diagnostic, performance .NET : mesurer, profiler puis optimiser. Cette discipline permet de profiter d'EF Core sans transformer la persistance en boîte noire.

Pour approfondir, consultez la documentation Microsoft sur les requêtes efficaces, la concurrence optimiste, les transactions et le déploiement des migrations.

Mots-clés :Entity Framework Core.NETPersistenceDatabaseArchitecture
Y

Yva Hajatiana

Lead développeur .NET à Paris et architecte logiciel, 10 ans d'expérience sur des SI critiques bancaires (.NET, Angular, systèmes distribués, IAM, DevOps).