Créer une migration sur son poste est trivial : dotnet ef migrations add, puis database update. Les ennuis commencent quand plusieurs développeurs modifient le modèle en parallèle, et quand il faut appliquer ces migrations en production sans SDK, sans accès direct à la base et sans casser l’application en cours d’exécution. Cet article décrit un workflow d’équipe robuste avec EF Core 10 : snapshot, scripts idempotents, migration bundles, données de référence et pipeline GitHub Actions.
Cet article fait partie de la série Entity Framework : 9 sur 12.
- Part 1 - Comprendre la "magie" derrière Entity Framework (EF Core)
- Part 2 - Le Database Context (EF Core)
- Part 3 - Le Change Tracker (EF Core)
- Part 4 - Bonnes pratiques pour les requêtes (EF Core)
- Part 5 - Manipuler le Change Tracker (EF Core)
- Part 6 - Optimiser les performances (EF Core)
- Part 7 - Mesurer et diagnostiquer les performances (EF Core)
- Part 8 - AsSplitQuery — maîtriser le chargement de plusieurs relations (EF Core)
- Part 9 - Cet article
- Part 10 - Concurrence optimiste (EF Core)
- Part 11 - Les intercepteurs (EF Core)
- Part 12 - Tester son code d'accès aux données (EF Core)
1. Rappel : ce que contient une migration
Une migration EF Core, ce sont trois fichiers générés dans le projet qui contient le DbContext :
20260606081500_AddRoomFloor.cs: les méthodesUp()etDown()(opérations de schéma) ;20260606081500_AddRoomFloor.Designer.cs: les métadonnées du modèle à ce moment-là ;RoomBookingDbContextModelSnapshot.cs: un instantané unique du modèle courant, réécrit à chaque migration.
C’est ce snapshot qui sert de référence : migrations add compare le modèle C# actuel au snapshot pour calculer le différentiel. Côté base, la table __EFMigrationsHistory liste les migrations déjà appliquées.
1.1. L’exemple de l’article
Tout l’article s’appuie sur une petite application de réservation de salles de réunion, découpée en deux projets : src/RoomBooking.Infrastructure contient le DbContext et les migrations, src/RoomBooking.Api est l’API qui le démarre (le startup project des commandes dotnet ef).
// src/RoomBooking.Infrastructure/RoomBookingDbContext.cs
// dotnet add package Microsoft.EntityFrameworkCore.SqlServer
using Microsoft.EntityFrameworkCore;
public class Room
{
public int Id { get; set; }
public required string Name { get; set; }
public int Capacity { get; set; }
}
public class Reservation
{
public int Id { get; set; }
public int RoomId { get; set; }
public required string Title { get; set; } // ajouté par la migration AddReservationTitle
public DateTime StartsAtUtc { get; set; }
public DateTime EndsAtUtc { get; set; }
}
// Table de référence : équipements disponibles dans les salles
public class Equipment
{
public int Id { get; set; }
public required string Code { get; set; }
public required string Label { get; set; }
}
public class RoomBookingDbContext(DbContextOptions<RoomBookingDbContext> options) : DbContext(options)
{
public DbSet<Room> Rooms => Set<Room>();
public DbSet<Reservation> Reservations => Set<Reservation>();
public DbSet<Equipment> Equipments => Set<Equipment>();
}
L’API enregistre ce contexte avec AddDbContext et UseSqlServer. L’historique de départ contient deux migrations : InitialCreate puis AddReservationTitle.
2. Le workflow au quotidien
2.1. Installer l’outil en local
On préfère un manifeste d’outils local à une installation globale : toute l’équipe et la CI utilisent la même version de dotnet-ef.
# À la racine du dépôt (une seule fois)
dotnet new tool-manifest
dotnet tool install dotnet-ef --version 10.0.*
# Sur un autre poste ou en CI
dotnet tool restore
2.2. Ajouter, vérifier, appliquer
# Options communes, pour ne pas les répéter
EF="--project src/RoomBooking.Infrastructure --startup-project src/RoomBooking.Api"
# 1. Générer la migration à partir du modèle modifié
dotnet ef migrations add AddRoomFloor $EF
# 2. Relire le code généré, et le SQL correspondant
dotnet ef migrations script --idempotent $EF
# 3. Appliquer sur la base locale
dotnet ef database update $EF
✅ Toujours relire la migration : un renommage de propriété peut être interprété comme DropColumn + AddColumn, avec perte de données. Dans ce cas, on corrige à la main avec migrationBuilder.RenameColumn(...).
2.3. Revenir en arrière
# Supprime la DERNIÈRE migration et restaure le snapshot
# (refuse si elle est déjà appliquée à la base ciblée)
dotnet ef migrations remove $EF
# Ramener la base locale à une migration antérieure (exécute les Down())
dotnet ef database update AddReservationTitle $EF
Règle d’or : migrations remove uniquement pour une migration qui n’a quitté ni le poste, ni la branche. Une fois mergée ou appliquée sur une base partagée, on ne supprime plus : on écrit une nouvelle migration corrective.
2.4. Le cycle de vie d’une migration
stateDiagram-v2
state "Générée" as Generee
state "Relue" as Relue
state "Appliquée en local" as AppliqueeLocal
state "Mergée" as Mergee
state "Packagée" as Packagee
state "Appliquée en prod" as AppliqueeProd
state "Corrigée" as Corrigee
[*] --> Generee: migrations add
Generee --> [*]: migrations remove
Generee --> Relue: revue du code et du SQL
Relue --> AppliqueeLocal: database update
AppliqueeLocal --> Generee: retour à la précédente puis remove
AppliqueeLocal --> Mergee: pull request validée
Mergee --> Packagee: CI (script ou bundle)
Packagee --> AppliqueeProd: job de déploiement
AppliqueeProd --> Corrigee: nouvelle migration corrective
Corrigee --> Mergee
AppliqueeProd --> [*]
3. Conflits de snapshot entre branches
3.1. Le problème
Alice et Bob partent de main le même jour. Alice ajoute une propriété Room.Floor (l’étage de la salle), Bob ajoute Reservation.Notes. Chacun génère sa migration : chaque snapshot ne connaît que sa modification.
graph LR
M[main<br/>snapshot v1] --> A[branche Alice<br/>AddRoomFloor<br/>snapshot v1 + Floor]
M --> B[branche Bob<br/>AddReservationNotes<br/>snapshot v1 + Notes]
A --> R[main après merges<br/>snapshot ?]
B --> R
style M fill:#4A90E2
style R fill:#F5A623
Si Bob merge après Alice, deux cas :
- Git signale un conflit dans
RoomBookingDbContextModelSnapshot.cs: c’est le cas favorable, on le voit ; - Git fusionne silencieusement les deux modifications (lignes différentes du fichier) : le snapshot semble correct, mais le fichier
.Designer.csde la migration de Bob décrit un modèle sansFloor. Les migrations suivantes peuvent alors être fausses.
3.2. La résolution recommandée
La documentation EF Core préconise de recréer sa migration plutôt que de bricoler le snapshot :
# Côté Bob, AVANT de merger : annuler la migration locale
# (si elle a été appliquée en local, revenir d'abord à la migration précédente)
dotnet ef database update AddReservationTitle $EF
dotnet ef migrations remove $EF
# Récupérer le travail d'Alice
git fetch origin
git rebase origin/main
# Régénérer la migration sur un snapshot à jour
dotnet ef migrations add AddReservationNotes $EF
❌ Ne pas renommer les fichiers pour changer l’ordre des horodatages, ni lancer migrations remove après un merge déjà fait : la commande restaure le modèle décrit par le .Designer.cs précédent, qui peut ne pas contenir les changements de l’autre branche. Dans ce cas, on revient à un état cohérent avec Git, puis on applique la procédure ci-dessus.
À noter : EF Core 11 (en préversion) enregistre l’identifiant de la dernière migration dans le snapshot. Deux migrations parallèles produisent donc toujours un conflit Git, ce qui rend la divergence impossible à rater.
3.3. Se protéger en CI
Deux garde-fous simples :
dotnet ef migrations has-pending-model-changes(depuis EF Core 8) échoue si le modèle C# a changé sans migration correspondante : idéal pour détecter un snapshot incomplet ou une migration oubliée ;- une règle d’équipe : une seule PR avec migration à la fois, rebasée sur
mainjuste avant le merge.
4. Appliquer les migrations en production
4.1. Pourquoi ne pas appeler Migrate() au démarrage
Le code suivant est tentant :
// ❌ Program.cs : migration automatique au démarrage
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<RoomBookingDbContext>();
await db.Database.MigrateAsync();
}
Il pose plusieurs problèmes en production :
- Droits : le compte de l’application doit pouvoir faire du DDL (
ALTER TABLE,DROP…). On veut au contraire un compte applicatif limité à la lecture/écriture des données. - Plusieurs instances : depuis EF Core 9,
Migrate()pose un verrou global sur la base, ce qui évite la corruption. Mais toutes les instances attendent ce verrou au démarrage, et les health checks peuvent tuer les pods pendant une migration longue. - Aucune revue, échec bloquant : le SQL est exécuté sans avoir été lu, et une migration qui échoue empêche l’application de démarrer.
- Déploiements progressifs : pendant un rolling update, l’ancienne version tourne déjà sur le nouveau schéma ; le moment de la migration doit être maîtrisé.
Autre point à connaître : depuis EF Core 9, Migrate() lève une exception si le modèle contient des changements non migrés (RelationalEventId.PendingModelChangesWarning). Mieux vaut le découvrir en CI qu’au démarrage en production.
✅ MigrateAsync() au démarrage reste acceptable pour le développement local, les tests et les petites applications mono-instance. Pour le reste, la migration devient une étape de déploiement à part entière.
4.2. Le script idempotent
dotnet ef migrations script --idempotent --output artifacts/migrate.sql $EF
Le script généré entoure chaque migration d’un test sur __EFMigrationsHistory :
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260606081500_AddRoomFloor'
)
BEGIN
ALTER TABLE [Rooms] ADD [Floor] int NOT NULL DEFAULT 0;
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260606081500_AddRoomFloor'
)
BEGIN
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20260606081500_AddRoomFloor', N'10.0.0');
END;
GO
On peut donc l’exécuter sur n’importe quelle base, quelle que soit la dernière migration appliquée. C’est la bonne option quand un DBA doit relire et valider le SQL, ou quand le déploiement passe par un outil SQL existant (sqlcmd, pipeline DACPAC…).
Pour un retour arrière, on génère un script « à l’envers » (from plus récent que to) :
dotnet ef migrations script AddRoomFloor AddReservationTitle --output artifacts/rollback.sql $EF
Limites : le support de l’idempotence dépend du provider (SQLite ne le supporte pas), et un script exécuté hors EF Core ne bénéficie ni du verrou de migration ni du seeding UseSeeding (voir section 5).
4.3. Les migration bundles
Un bundle est un exécutable autonome qui contient les migrations et applique celles qui manquent, exactement comme database update, mais sans SDK, sans code source et sans outil dotnet-ef sur la machine cible.
# Bundle autonome pour un runner ou un conteneur Linux
dotnet ef migrations bundle \
--self-contained --target-runtime linux-x64 \
--configuration Release \
--output artifacts/efbundle --force $EF
# Exécution, avec un compte disposant des droits DDL
./artifacts/efbundle --connection "$DEPLOY_CONNECTION_STRING"
# Retour à une migration donnée (exécute les Down() plus récents)
./artifacts/efbundle AddReservationTitle --connection "$DEPLOY_CONNECTION_STRING"
Avantages par rapport au script : verrou de migration, gestion des transactions par EF Core, exécution du seeding. Inconvénient : on ne voit pas le SQL avant exécution. Une bonne pratique consiste à produire les deux artefacts en CI : le script pour la revue, le bundle pour l’exécution.
Le bundle construit le DbContext comme l’application : on fixe ASPNETCORE_ENVIRONMENT, et on n’embarque jamais de chaîne de connexion de production dans appsettings.json.
5. Données de référence : HasData ou UseSeeding ?
5.1. HasData : des données gérées par les migrations
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Dans RoomBookingDbContext
modelBuilder.Entity<Equipment>().HasData(
new Equipment { Id = 1, Code = "PROJ", Label = "Vidéoprojecteur" },
new Equipment { Id = 2, Code = "SCREEN", Label = "Écran interactif" },
new Equipment { Id = 3, Code = "VISIO", Label = "Kit de visioconférence" });
}
Chaque modification de ces données produit, au prochain migrations add, des opérations InsertData / UpdateData / DeleteData. Les données font donc partie du snapshot et sont versionnées avec le schéma, ce qui les rend visibles dans le script idempotent.
Contraintes : la clé primaire doit être fournie (même si elle est générée par la base), changer la clé supprime la ligne existante, et les valeurs doivent être déterministes (pas de DateTime.Now, pas de Guid.NewGuid()). Microsoft appelle désormais cette fonctionnalité model managed data : elle convient aux petites tables statiques (équipements, statuts, unités).
5.2. UseSeeding / UseAsyncSeeding : du code exécuté après migration
Introduits avec EF Core 9, ces deux hooks sont appelés par EnsureCreated, Migrate, dotnet ef database update et les bundles, sous le verrou de migration :
using Microsoft.EntityFrameworkCore;
// Program.cs de RoomBooking.Api
builder.Services.AddDbContext<RoomBookingDbContext>(options => options
.UseSqlServer(builder.Configuration.GetConnectionString("RoomBooking"))
// Appelé par Migrate(), database update et les bundles : à implémenter même si l'appli est 100 % async
.UseSeeding((context, _) =>
{
if (!context.Set<Room>().Any(r => r.Name == "Accueil"))
{
context.Set<Room>().Add(new Room { Name = "Accueil", Capacity = 6 });
context.SaveChanges();
}
})
// Appelé par MigrateAsync() / EnsureCreatedAsync()
.UseAsyncSeeding(async (context, _, ct) =>
{
if (!await context.Set<Room>().AnyAsync(r => r.Name == "Accueil", ct))
{
context.Set<Room>().Add(new Room { Name = "Accueil", Capacity = 6 });
await context.SaveChangesAsync(ct);
}
}));
Le code doit être idempotent : il s’exécute à chaque migration, même quand aucune migration n’est appliquée. Il convient aux données qui dépendent de l’état de la base, aux clés générées, aux volumes importants ou aux données calculées. En revanche, il ne figure pas dans le script SQL : avec un déploiement par script, il faut un autre mécanisme.
| Critère | HasData |
UseSeeding / UseAsyncSeeding |
|---|---|---|
| Versionné dans les migrations | ✅ Oui | ❌ Non (code C#) |
| Présent dans le script SQL | ✅ Oui | ❌ Non |
| Clé générée par la base | ❌ Non | ✅ Oui |
| Données dépendant de l’état | ❌ Non | ✅ Oui |
| Gros volumes | ❌ Alourdit le snapshot | ✅ Oui |
| Cas typique | Équipements, statuts, unités | Salle par défaut, compte admin, jeux de démo |
6. Un pipeline GitHub Actions
Un workflow GitHub Actions est un fichier YAML placé dans .github/workflows/, composé de jobs exécutés sur des runners. Celui-ci est dédié à la base de données : un premier job vérifie le modèle et produit les artefacts, un second les applique, protégé par un environment GitHub (approbation manuelle possible). Pour le build et les tests de l’application elle-même, voir GitHub Actions pour un projet .NET.
# .github/workflows/database.yml
name: Database
on:
push:
branches: [main]
pull_request:
env:
EF_ARGS: --project src/RoomBooking.Infrastructure --startup-project src/RoomBooking.Api --configuration Release
jobs:
migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- name: Restaurer dotnet-ef (manifeste local)
run: dotnet tool restore
# Échoue si une modification du modèle n'a pas de migration
- name: Vérifier le modèle
run: dotnet ef migrations has-pending-model-changes $EF_ARGS
# Le script, pour la revue (DBA, PR)
- name: Générer le script idempotent
run: dotnet ef migrations script --idempotent --output artifacts/migrate.sql $EF_ARGS
# Le bundle, pour l'exécution
- name: Générer le bundle
run: >
dotnet ef migrations bundle --self-contained --target-runtime linux-x64
--output artifacts/efbundle --force $EF_ARGS
- uses: actions/upload-artifact@v7
with:
name: database
path: artifacts/
deploy-database:
needs: migrations
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # règles de protection et secrets dédiés
steps:
- uses: actions/download-artifact@v8
with:
name: database
- name: Appliquer les migrations
env:
ASPNETCORE_ENVIRONMENT: Production
# Compte de déploiement avec droits DDL, distinct du compte applicatif
DB_CONNECTION: ${{ secrets.DB_DEPLOY_CONNECTION_STRING }}
run: |
chmod +x ./efbundle
./efbundle --connection "$DB_CONNECTION"
Quelques remarques :
chmod +xest nécessaire car les artefacts GitHub ne conservent pas les permissions d’exécution.- Le job de déploiement de l’application vient après
deploy-database: on migre d’abord, puis on déploie le code, ce qui impose des migrations rétrocompatibles (ajouter une colonne nullable, puis la rendre obligatoire dans une version ultérieure). - Le runner doit pouvoir joindre la base : runner auto-hébergé dans le réseau, règle de pare-feu temporaire, ou exécution du bundle sous forme de job dans le cluster.
- Pour vérifier que les migrations s’appliquent sur une base vierge, les tests d’intégration avec Testcontainers peuvent appeler
MigrateAsync()plutôt queEnsureCreated().
7. Bonnes pratiques
- ✅ Une migration = un changement fonctionnel, au nom explicite (
AddRoomFloor, pasUpdate12). - ✅ Migrations rétrocompatibles : expand (ajout) d’abord, contract (suppression) dans une version suivante.
- ❌ Modifier une migration déjà mergée ou appliquée.
- ❌
EnsureCreated()sur une base gérée par migrations : il crée le schéma sans historique, etMigrate()échoue ensuite.
Résumé
| Besoin | Outil | Remarque |
|---|---|---|
| Créer / annuler une migration | migrations add / remove |
remove seulement avant merge |
| Appliquer en local | database update |
Accepte une migration cible pour revenir en arrière |
| Détecter une migration manquante | migrations has-pending-model-changes |
EF Core 8+, à lancer en CI |
| Conflit de snapshot | Supprimer, rebaser, régénérer | Ne jamais réordonner les fichiers |
| Revue SQL par un DBA | migrations script --idempotent |
Pas de verrou ni de seeding |
| Déploiement automatisé | migrations bundle |
Sans SDK, verrou EF 9+, seeding exécuté |
| Migration au démarrage | Database.MigrateAsync() |
Dev, tests, petites applis mono-instance |
| Données statiques versionnées | HasData |
Clés fixes, petites tables |
| Données initiales dynamiques | UseSeeding / UseAsyncSeeding |
EF Core 9+, code idempotent |