Gestion de la persistance des données

La persistance des données dans Lino est générée autour des frontières de la Clean Architecture : le modèle de domaine reste indépendant d'Entity Framework Core, tandis que les préoccupations de base de données restent dans Infrastructure.Persistence. Cela garde les entités concentrées sur les règles métier et place les mappings, la configuration du provider, les migrations, les repositories, les transactions et l'initialisation dans la couche d'infrastructure.


Lors de la création d'un service avec lino service new, Lino enregistre des décisions importantes de persistance, comme l'architecture du service et le provider de données. Les options actuelles de provider sont SqlServer et PostgreSql. Dans les services traditionnels, la persistance est générée directement sous le service. Dans les services modulaires, chaque module reçoit son propre projet Infrastructure.Persistence et son propre ApplicationDbContext, tandis que la base de données reste partagée par le service.


Dans les services traditionnels ou modulaires, la base de données est unique par service. Dans les services modulaires, chaque module est mappé dans un schema distinct au sein de la même base. Cela permet à chaque bounded context de conserver un namespace logique séparé, avec isolation fonctionnelle, versioning plus clair et migrations mieux organisées.


Cette page explique comment Lino organise les configurations Entity Framework Core, l'enregistrement de DbContext, les repositories, IUnitOfWork, les transactions, le flux Transactional Outbox et le cycle des migrations exposé par la CLI.

Important : le code de persistance généré est un scaffolding orienté production. Revoyez toujours les mappings, constraints, index, comportements de suppression, transactions et scripts de migration avant d'appliquer des changements sur des bases partagées ou de production.

Entity Type Configurations

Lino suit le principe Persistence-Ignorant : les entités de domaine ne connaissent pas les détails d'infrastructure. Tout le mapping ORM reste dans des classes qui implémentent IEntityTypeConfiguration<TEntity>, situées dans Infrastructure.Persistence/Configurations. Ainsi, les entités expriment le comportement métier tandis que l'infrastructure définit comment elles seront stockées.

Les fichiers de configuration sont générés à partir des décisions prises dans la CLI : primary keys, strongly typed IDs, champs obligatoires, tailles de string, Value Objects, relations, énumérations, champs d'audit, tenant fields et entités enfants. Le résultat généré doit être traité comme un point de départ solide, pas comme quelque chose qui n'a jamais besoin d'être revu.

Ce que les configurations définissent généralement

  • Table et schema : nom de la table et schema du service ou module courant.
  • Primary keys : identifiants d'entité, conversions de strongly typed IDs et comportement de génération de clé.
  • Propriétés : champs obligatoires, taille maximale, types de colonne, persistance d'enum, owned structures de Value Objects et colonnes de metadata de fichiers.
  • Relations : one-to-one, one-to-many, many-to-many, child collections, foreign keys et comportement de suppression.
  • Index et constraints : unicité, performance de lookup, unicité tenant-aware et cohérence au niveau de la base.

Conventions globales

Les conventions répétées peuvent être centralisées dans ModelConfiguration ou des helpers equivalents, comme la précision décimale, la collation, la conversion de DateTime, la naming convention, les filtres globaux, les propriétés auditables et les règles communes des champs multi-tenant.

Comment les configurations sont appliquées

Le ApplicationDbContext généré applique les configurations dans OnModelCreating. Par défaut, Lino utilise des Source Generators pour enregistrer les configurations avec de meilleures performances et sans Reflection. Lorsque le scanning par assembly est utilisé, la configuration peut ressembler à ceci :

modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);

Quand la messagerie est activée, Lino applique aussi la configuration de OutboxMessage, ce qui permet de persister les événements d'integration dans la même transaction de base de données avant qu'un worker les publie de façon asynchrone.

Checklist de revue

  1. Confirmez que les noms de tables, les schemas et les conventions respectent la frontière du module.
  2. Revoyez les tailles de string, la précision décimale, les colonnes nullable et la persistance des enums avant de génèrer des migrations.
  3. Revoyez soigneusement les relations et le delete behavior, surtout pour les aggregate roots et les entités enfants.
  4. Ajoutez ou ajustez les index pour les queries utilisees par les grids, les filtres, les integrations et les background jobs.
  5. Exécutez le build avant de créer une migration afin de garantir qu'EF Core voit un modèle coherent.

DbContexts

Le ApplicationDbContext généré est le contexte EF Core pour la frontière de persistance d'un service ou d'un module. Il expose des propriétés DbSet<TEntity> pour les entités mappées et implémente IApplicationDbContext, ce qui permet aux query handlers et aux services d'application de dépendre d'une abstraction applicative plutôt que de la classe concrète d'infrastructure.

Services traditionnels et services modulaires

  • Service traditionnel : la persistance est générée dans src/Services/<ServiceName>/Infrastructure.Persistence, et l'API du service est utilisee comme startup project pour les migrations.
  • Service modulaire : chaque module possède son propre projet src/Services/<ServiceName>/Modules/<ModuleName>/Infrastructure.Persistence et son propre ApplicationDbContext. Le host du service est utilise comme startup project pour les migrations.

Dans les services modulaires, cette séparation rend chaque bounded context explicite dans le code. Les modules peuvent faire évoluer leur modèle de persistance indépendamment, même lorsqu'ils s'exécutent dans la même base de données du service si c'est l'architecture choisie.

Enregistrement et configuration du provider

Lino génère l'enregistrement de persistance via une extension de IHostApplicationBuilder. Le code génère enregistre Unit of Work, repositories, domain services, une factory pooled de ApplicationDbContext, le ApplicationDbContext scoped et l'abstraction IApplicationDbContext.

La configuration spécifique au provider est générée selon la base de données sélectionnée pour le service :

  • UseSqlServer(...) pour les services SQL Server.
  • UseNpgsql(...).UseSnakeCaseNamingConvention() pour les services PostgreSQL.
  • MigrationsHistoryTable(Constants.Database.MigrationsHistoryTable, Constants.Database.Schema) pour stocker l'historique des migrations dans la table et le schema attendus.
  • Les constraint validators spécifiques au provider sont enregistrés pour convertir les violations de base de données en erreurs d'application coherentes.

Contexts tenant-aware

Quand un module contient des entités tenant-aware, le contexte généré peut inclure l'état du tenant et des filtres globaux de query. Dans ce scénario, la factory scoped crée un contexte configuré pour le scope courant, évitant aux handlers de répéter manuellement les filtres de tenant à chaque point de lecture et d'écriture.

Repositories

Lino génère des interfaces de repository dans la couche de domaine et des implémentations concrètes dans Infrastructure.Persistence/Repositories. Cela préserve la direction de dépendance : le domaine définit le contrat de persistance dont il a besoin, et l'infrastructure fournit l'implémentation avec Entity Framework Core.

Les repositories concrets peuvent aussi apparaître dans <ModuleName>/Infrastructure.Persistence.Repositories et implémenter des interfaces de <ModuleName>/Domain.Repositories, selon la structure du service. Le point important est que l'application et le domaine ne dépendent pas directement des détails concrets d'EF Core.

Les repositories sont principalement utilisés par les command handlers et les flux orientés domaine qui doivent persister des aggregates. Les Query Handlers peuvent utiliser directement IApplicationDbContext lorsqu'ils ont besoin de projections de lecture optimisées, de filtres, de pagination et de résultats au format DTO.

Responsabilités de repository

  • Charger les aggregate roots et les données enfants nécessaires aux command handlers.
  • Ajouter, mettre à jour et supprimer des aggregates selon le modèle de persistance.
  • Encapsuler les queries de persistance qui font partie du comportement de l'aggregate ou de l'orchestration d'écriture.
  • Garder les opérations spécifiques à EF Core hors du projet de domaine.
  • Encapsuler les queries complexes lorsqu'elles appartiennent au flux d'écriture, y compris LINQ, FromSql et les projections auxiliaires.

Orientations pratiques

  • Utilisez les repositories pour les use cases d'écriture qui doivent préserver la cohérence de l'aggregate.
  • Utilisez des projections de query pour la lecture au lieu de charger des aggregates complets uniquement pour construire un DTO.
  • Gardez des méthodes de repository explicites ; évitez les méthodes génériques qui exposent des opérations arbitraires de persistance à l'application.
  • Revoyez les includes et le tracking lorsqu'un handler modifie un aggregate, une child collection ou une relation many-to-many.
  • Exposez uniquement les méthodes nécessaires au domaine et à l'application, en gardant le repository aggregate-root-centric.

Unit of Work

IUnitOfWork est la frontière transactionnelle générée par Lino. Il coordonne la persistance avec EF Core, le contrôle de transaction, la publication des événements de domaine et la persistance dans l'Outbox pour les événements d'intégration. Les handlers utilisent cette abstraction pour confirmer explicitement les changements, au lieu d'appeler DbContext.SaveChangesAsync directement à tous les points de l'application.

Dans les scénarios simples, le DbContext lui-même suffit souvent comme unité de travail. Malgré cela, Lino génère une implémentation dédiée de UnitOfWork afin d'offrir un contrôle coherent sur les transactions, les événements et l'integration avec l'Outbox.

Opérations générées

  • SaveChangesAsync(cancellationToken) : sauvegarde les changements et publie les événements de domaine lorsque c'est configuré.
  • SaveChangesAsync(publishDomainEvents, cancellationToken) : permet de sauvegarder avec ou sans publication d'événements de domaine.
  • SaveChangesInTransactionAsync(cancellationToken) : ouvre une transaction, sauvegarde les changements et commit.
  • BeginTransactionAsync, CommitAsync et RollbackAsync : permettent un contrôle explicite de transaction.
  • CommitOrRollbackAsync : commit ou rollback la transaction sur la base d'un Result.

Événements de domaine et Outbox

Quand il existe des événements de domaine, Lino exigé une transaction ouverte avant de les publier. C'est intentionnel. Si un evenement de domaine génère des événements d'integration, ces événements sont enregistrés dans l'Outbox et persistés dans la même transaction. Ensuite, un worker peut traiter les enregistrements de l'Outbox et publier les messages via l'infrastructure de messagerie configurée.

Ce flux protège la cohérence : lorsqu'une command crée des entités qui lèvent des événements et que le projet utilise l'Outbox, le handler doit sauvegarder via une transaction, par exemple avec SaveChangesInTransactionAsync, ou ouvrir et confirmer explicitement la transaction. Sans cette transaction, l'infrastructure doit échouer au lieu d'autoriser un flux d'événements non fiable.

Quand utiliser chaque style de save

  • Utilisez SaveChangesAsync pour une persistance directe sans exigénce de cohérence entre événements et Outbox.
  • Utilisez SaveChangesInTransactionAsync quand les événements de domaine et la persistance dans l'Outbox doivent faire partie du même commit.
  • Utilisez BeginTransactionAsync, CommitAsync et RollbackAsync quand le handler comporte plusieurs etapes et doit decider du résultat final sur la base d'un Result.

Règle pratique : si une opération d'écriture publie des événements de domaine qui peuvent produire des événements d'integration, gardez les changements de base de données et les enregistrements Outbox dans la même transaction.

Managing Migrations

Lino encadre le cycle des migrations Entity Framework Core avec des commandes CLI qui connaissent le service, le module, l'architecture, le provider, le DbContext, le startup project, le fichier de version et l'emplacement de sortie des scripts. Cela reduit les erreurs de commande manuelle et garde la metadata de la migration suivie par le modèle du projet Lino.

Les migrations enregistrent l'évolution de la base à partir des changements dans les entités, Value Objects, relations, index et configurations Entity Framework. Avant de créer une migration, compilez la solution ou au moins le service concerné afin qu'EF Core charge le modèle courant.

Creer une migration

lino database migrations add --service <ServiceName> --module <ModuleName>

La commande demande le service, le module, la version courante du service dans src/Services/<ServiceName>/version.txt et la description de la migration. Lino crée un nom utilisant la version et la séquence, puis exécute dotnet ef migrations add pour le projet de persistance et le startup project corrects.

Lino génère aussi un script SQL de la migration avec dotnet ef migrations script. Le script est écrit sous le projet de persistance concerné dans un dossier versionné, selon le modèle :

Infrastructure.Persistence/Scripts/<Version>/<Sequence>_<Description>.sql
Infrastructure.Persistence/Scripts/v1.2.3/001_AddCustomerIsActive.sql

Note : en plus des fichiers .cs générés par Entity Framework, Lino génère le script .sql correspondant avec des instructions DDL. Cela facilite la revue par les équipes d'infrastructure, les DBA et les processus de déploiement contrôlé.

Appliquer, lister, revert et supprimer

lino database migrations add --service <ServiceName> --module <ModuleName>
lino database migrations list --service <ServiceName> --module <ModuleName>
lino database migrations apply --service <ServiceName> --module <ModuleName>
lino database migrations revert --service <ServiceName> --module <ModuleName>
lino database migrations remove --service <ServiceName> --module <ModuleName>
  • add : crée une migration pour les changements en attente dans le modèle.
  • list : liste les migrations connues pour le contexte sélectionné.
  • apply : exécute dotnet ef database update pour appliquer les migrations dans la base configurée.
  • revert : revient sur le dernier chemin de migration réussi lorsque le flux le permet.
  • remove : supprime le dernier enregistrement de migration créee et les fichiers EF générés lorsque c'est applicable.

Le groupe canonique est database migrations. Des alias comme db et migration peuvent exister pour la productivité, mais la documentation doit préférer la forme complète.

Ce que Lino choisit pour vous

  • Dans les services traditionnels, le projet EF est src/Services/<ServiceName>/Infrastructure.Persistence et le startup project est src/Services/<ServiceName>/Api.
  • Dans les services modulaires, le projet EF est src/Services/<ServiceName>/Modules/<ModuleName>/Infrastructure.Persistence et le startup project est src/Services/<ServiceName>/Host.
  • Le contexte est le ApplicationDbContext généré pour le service ou module sélectionné.
  • La table d'historique des migrations EF est configurée avec Constants.Database.MigrationsHistoryTable et Constants.Database.Schema.

Workflow recommande

  1. Modélisez ou modifiez les entités, Value Objects, relations, index ou configurations de persistance.
  2. Exécutez le build et corrigez les erreurs de compilation avant de génèrer la migration.
  3. Exécutez lino database migrations add pour le service et le module corrects.
  4. Revoyez la migration C# et le script SQL générés avant d'appliquer.
  5. Appliquez la migration en environnement local ou de développement et vérifiez le schema de la base.
  6. Commitez les fichiers de migration et les scripts SQL avec le changement de domaine ou d'application qui a exigé la modification.

Checklist : revoyez le diff de la migration, appliquez sur une base locale, exécutez le build et testez le flux concerné avant de publier.

Une erreur non gérée est survenue. Rafraîchir 🗙