Entity Framework Core.NETPersistenceDatabaseArchitecture

Utiliser Entity Framework Core sans masquer les requêtes, les transactions ni la concurrence : une méthode claire pour une persistance .NET fiable et durable.

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

Entity Framework Core : contrôler ses données

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ée 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();
    }
}

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. Projette 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, mesure 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.

Surveille 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. Choisis selon le volume, la latence réseau et le besoin de cohérence, puis mesure 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, charge 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, donne-lui un vocabulaire métier ou une intention de requête précise.

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.

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. inspecte le code et le SQL générés ;
  2. teste la migration sur un volume représentatif ;
  3. vérifie la compatibilité entre l'ancienne et la nouvelle version de l'application ;
  4. prépare le retour arrière ou la correction ;
  5. applique 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. Évite 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.

Projette les lectures, garde les contextes courts, observe le SQL, laisse la base garantir l'intégrité et traite les migrations comme une opération de production. Cette discipline permet de profiter d'EF Core sans transformer la persistance en boîte noire.

Pour approfondir, consulte 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

Articles techniques sur l'ingénierie logicielle, .NET, le cloud et l'intelligence artificielle appliquée aux applications.