Code agile, modèle durable : Agile, POO et Code First dans un même parcours
Des critères de recette au modèle objet et relationnel, puis à EF Core, aux tests et à une release sûre : lisez, choisissez et découvrez les conséquences.
L’article complet est disponible ci-dessous. Activez JavaScript pour les quiz et les simulations.
Atelier : du besoin à une release sûre
Trois techniques, un même parcours fonctionnel.
Agile définit un petit pas et sa recette. La POO place les règles dans le modèle. Code First transpose ce modèle en base. Un maillon faible suffit pour qu’une belle pull request échoue en production.
Agile, programmation objet et Code First sont souvent présentés séparément : tableau des tâches, livre sur les classes, documentation EF Core. En production, ils se rencontrent dans une même pull request.
Construisons une partie d’un système de travail : un projet contient des sprints, un sprint des tâches et une tâche peut être attribuée à un membre. Nous partirons du comportement attendu, puis irons jusqu’à la migration sûre.
1. L’agilité commence par les limites
La story paraît simple : en tant que responsable de projet, je veux attribuer une tâche à une personne dans un sprint actif pour rendre la responsabilité claire et visible. Le périmètre n’est pourtant pas complet : peut-on réattribuer une tâche fermée, faut-il appartenir au projet, qui a le droit de changer et comment gérer deux requêtes simultanées ?
La tâche ne peut être attribuée que dans une période de travail ouverte.
La personne doit être un membre actif du projet concerné.
Une tâche fermée ne doit pas revenir indirectement en réalisation.
Répéter la même décision ne crée pas un second événement.
Nous conservons qui a attribué la tâche, à qui et quand.
Un conflit produit une réponse claire, pas une perte de données.
Décision 1 sur 3
Par quoi commencer l’implémentation ?
Vous avez la story et une branche vide. Choisissez.
Par la table WorkItems
Par les critères et le comportement
Par l’endpoint POST
Choisissez pour voir la conséquence.
2. POO : l’objet protège la décision
Un modèle anémique est un sac de données aux setters publics. Chaque handler peut fixer n’importe quel état et doit répéter les validations. Un modèle plus riche représente le métier et défend les transitions permises.
public sealed class WorkItem
{
private WorkItem() { }
public Guid Id { get; private set; }
public Guid ProjectId { get; private set; }
public Guid SprintId { get; private set; }
public string Title { get; private set; } = string.Empty;
public WorkItemStatus Status { get; private set; }
public Guid? AssigneeId { get; private set; }
public DateTimeOffset? AssignedAt { get; private set; }
public Guid? AssignedBy { get; private set; }
public uint Version { get; private set; }
public Result AssignTo(
TeamMember member,
Sprint sprint,
Guid actorId,
DateTimeOffset now)
{
if (Status == WorkItemStatus.Done)
return Result.Fail("Une tâche clôturée ne peut pas être réattribuée.");
if (sprint.ProjectId != ProjectId || !sprint.IsActiveAt(now))
return Result.Fail("La tâche ne fait pas partie du sprint actif.");
if (member.ProjectId != ProjectId || !member.IsActive)
return Result.Fail("La personne n’est pas un membre actif du projet.");
if (AssigneeId == member.Id)
return Result.Ok();
AssigneeId = member.Id;
AssignedAt = now;
AssignedBy = actorId;
return Result.Ok();
}
}
Le constructeur privé sert EF Core, les setters privés limitent les mutations accidentelles et la méthode AssignTo exprime l’intention. Réattribuer la même personne réussit sans changer l’heure ni créer une nouvelle trace : c’est l’idempotence.
Pourquoi recevoir Sprint et TeamMember ?
La règle utilise trois objets. Dans un petit modèle, passer les entités associées reste clair. Plus loin, une politique de domaine peut convenir si la règle n’appartient pas naturellement à un agrégat. L’essentiel est de ne pas la disperser entre contrôleur, dépôt et frontend.
3. Le modèle objet ne dispense pas de penser relationnel
Code First ne consiste pas à écrire des classes puis à faire aveuglément confiance à la migration. La base conserve ses règles : clés, sélectivité des index, verrous, intégrité et coût des requêtes.
Carte du modèle. L’état courant sert l’écran opérationnel rapide ; un historique distinct prouve chaque décision.
| Table | Rôle | Champs clés | Protection |
|---|---|---|---|
| Projects | Frontière du travail | Id, Name, Status | nom unique dans l’organisation |
| Sprints | Période de réalisation | ProjectId, StartAt, EndAt | EndAt supérieur à StartAt |
| WorkItems | État courant | SprintId, Status, AssigneeId, Version | FK, index, jeton de concurrence |
| TeamMembers | Accès au projet | ProjectId, UserId, IsActive | paire ProjectId + UserId unique |
| WorkItemAssignments | Historique des décisions | WorkItemId, AssigneeId, AssignedBy, AssignedAt | ajouts uniquement |
La valeur courante de AssigneeId dans WorkItems rend la lecture du tableau rapide. L’historique séparé fournit un audit fiable. C’est un compromis délibéré entre lecture opérationnelle et traçabilité.
Décision 2 sur 3
Que faire à la suppression d’un TeamMember ?
Les tâches historiques doivent rester lisibles.
Cascade Delete
Restrict et désactivation
Aucune clé étrangère
Choisissez le comportement de la base.
4. Configuration EF Core explicite
public sealed class WorkItemConfiguration : IEntityTypeConfiguration<WorkItem>
{
public void Configure(EntityTypeBuilder<WorkItem> builder)
{
builder.ToTable("WorkItems");
builder.HasKey(x => x.Id);
builder.Property(x => x.Title)
.HasMaxLength(180)
.IsRequired();
builder.Property(x => x.Status)
.HasConversion<string>()
.HasMaxLength(24);
builder.Property(x => x.Version)
.IsRowVersion()
.IsConcurrencyToken();
builder.HasOne<TeamMember>()
.WithMany()
.HasForeignKey(x => x.AssigneeId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.ProjectId, x.SprintId, x.Status });
builder.HasIndex(x => new { x.ProjectId, x.AssigneeId });
}
}
Longueurs, conversion d’enum, index, concurrence et suppression sont définis explicitement. La migration devient un objet de revue, pas une surprise après publication.
5. Migration sûre : expand, migrate, contract
Ne supposez pas que base et instances basculent ensemble. Un rolling deployment laisse deux versions actives pendant plusieurs minutes. Le schéma doit les tolérer.
Ajoutez colonnes nullable, historique et index sans casser l’ancienne application.
Activez la nouvelle écriture et le backfill par lots. Mesurez manques et erreurs.
Après preuve, renforcez les contraintes et retirez la compatibilité ancienne.
migrationBuilder.AddColumn<Guid>(
name: "AssigneeId",
table: "WorkItems",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_WorkItems_ProjectId_AssigneeId",
table: "WorkItems",
columns: new[] { "ProjectId", "AssigneeId" });
Décision 3 sur 3
Vous avez 8 millions de tâches. Quand passer la colonne en NOT NULL ?
Dès la première migration
Après backfill et vérification de zéro valeur manquante
Jamais
Choisissez quand renforcer le schéma.
6. L’application orchestre, le domaine décide
Le handler charge les données, appelle le comportement métier, enregistre l’historique et gère le conflit d’infrastructure. Il ne recopie pas les règles de l’entité.
public async Task<Result> Handle(AssignWorkItem command, CancellationToken ct)
{
var item = await db.WorkItems
.SingleOrDefaultAsync(x => x.Id == command.WorkItemId, ct);
var member = await db.TeamMembers
.SingleOrDefaultAsync(x => x.Id == command.MemberId, ct);
var sprint = item is null
? null
: await db.Sprints.SingleOrDefaultAsync(x => x.Id == item.SprintId, ct);
if (item is null || member is null || sprint is null)
return Result.NotFound();
var now = clock.UtcNow;
var result = item.AssignTo(member, sprint, command.ActorId, now);
if (result.IsFailure) return result;
db.WorkItemAssignments.Add(
WorkItemAssignment.From(item, command.ActorId, now));
try
{
await db.SaveChangesAsync(ct);
return Result.Ok();
}
catch (DbUpdateConcurrencyException)
{
return Result.Conflict(
"La tâche vient d’être modifiée. Actualisez la vue.");
}
}
La concurrence est concrète : deux personnes ouvrent le même tableau. Sans version, la dernière écriture gagne silencieusement et peut écraser la décision d’autrui.
7. Concevoir la lecture pour l’écran
L’entité métier prend des décisions. Le tableau a besoin de données plates et d’une requête rapide, pas d’un graphe complet, de lazy loading ou de navigation sérialisée.
var board = await db.WorkItems
.AsNoTracking()
.Where(x => x.ProjectId == projectId && x.SprintId == sprintId)
.OrderBy(x => x.Status)
.ThenBy(x => x.Title)
.Select(x => new WorkItemBoardRow(
x.Id,
x.Title,
x.Status,
x.AssigneeId,
x.AssigneeId == null
? null
: db.TeamMembers
.Where(m => m.Id == x.AssigneeId)
.Select(m => m.DisplayName)
.Single()))
.ToListAsync(ct);
La projection limite les colonnes, AsNoTracking supprime le suivi et l’index ProjectId + SprintId + Status correspond au filtrage réel. Lecture et écriture peuvent avoir des modèles distincts sans imposer un CQRS lourd.
8. Les tests protègent la décision
[Fact]
public void AssignTo_rejects_member_from_another_project()
{
var item = WorkItemTestFactory.Open(projectId: ProjectA.Id);
var sprint = SprintTestFactory.Active(projectId: ProjectA.Id);
var outsider = TeamMemberTestFactory.Active(projectId: ProjectB.Id);
var result = item.AssignTo(outsider, sprint, Admin.Id, Now);
result.IsFailure.Should().BeTrue();
item.AssigneeId.Should().BeNull();
}
[Fact]
public void AssignTo_is_idempotent_for_the_same_member()
{
var item = WorkItemTestFactory.Open(projectId: ProjectA.Id);
var member = TeamMemberTestFactory.Active(projectId: ProjectA.Id);
var sprint = SprintTestFactory.Active(projectId: ProjectA.Id);
item.AssignTo(member, sprint, Admin.Id, Now);
var second = item.AssignTo(member, sprint, Admin.Id, Now.AddMinutes(1));
second.IsSuccess.Should().BeTrue();
item.AssignedAt.Should().Be(Now);
}
Le premier test prouve que la règle ne peut être contournée. Le second que la répétition ne modifie pas l’historique. Puis un test sur PostgreSQL réel vérifie contraintes, mapping et conflit de version : le fournisseur en mémoire ne suffit pas.
9. Definition of Done : le contrat de recette
Du code terminé n’est pas une fonction terminée. L’agilité raccourcit le retour sans supprimer la qualité. La liste suivante est courte parce que chaque point est vérifiable.
- Les critères ont des tests de domaine ou de contrat.
- La migration a été lue en SQL, pas uniquement en C#.
- Les index correspondent aux vues et opérations d’administration.
- Le journal contient tâche et corrélation, pas de données privées.
- Les métriques distinguent succès, refus métier et conflit.
- La release est contrôlée par flag ou déploiement progressif.
- L’ancienne application peut revenir sans casser le nouveau schéma.
- La documentation explique décision et conséquences, pas seulement les fichiers.
Recette interactive
Faites passer la modification par le pipeline de décision
L’animation illustre l’ordre des contrôles. Elle ne prétend pas exécuter le code de l’article.
Lancer le contrôle
Prêt à démarrer.
Parcours de recette. Chaque contrôle apporte une preuve avant de passer à la suite.
10. Les raccourcis qui coûtent plus tard
| Raccourci | Pourquoi il attire | Ce qu’il dégrade | Meilleure décision |
|---|---|---|---|
| Setters publics | CRUD rapide | règles contournables | méthodes exprimant l’intention |
| Migration massive | moins d’étapes | verrous et absence de retour | expand–migrate–contract |
| Include du graphe entier | mapping facile | requêtes lourdes | projection adaptée à l’écran |
| Tests unitaires seuls | rapides | SQL et contraintes non testés | domaine et intégration base |
| Definition of Done : « ça marche » | semble simple | aucune preuve ni responsabilité | critères de recette vérifiables |
L’idée essentielle
Agile conçoit un pas assez petit pour obtenir vite une preuve. La POO place les règles là où on ne les contourne pas accidentellement. Code First fait du code la source du modèle, sans retirer à l’humain la responsabilité des conséquences en base et en production.
Un bon modèle aide l’équipe à réaliser le prochain changement en sécurité, au-delà de son élégance sur un diagramme.
À emporter dans votre projet
Cinq questions avant la prochaine pull request
- Quelle décision métier ce code protège-t-il ?
- Le modèle empêche-t-il les états interdits ?
- Le schéma renforce-t-il cette règle ?
- La migration tolère-t-elle anciennes et nouvelles versions ?
- Quel test ou mesure prouve le fonctionnement après déploiement ?