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.

01Story et critères de recette
02Modèle métier et relationnel
03Migration, tests et release

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 ?

01Sprint actif

La tâche ne peut être attribuée que dans une période de travail ouverte.

02Même projet

La personne doit être un membre actif du projet concerné.

03État de la tâche

Une tâche fermée ne doit pas revenir indirectement en réalisation.

04Idempotence

Répéter la même décision ne crée pas un second événement.

05Audit

Nous conservons qui a attribué la tâche, à qui et quand.

06Concurrence

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.

Modèle Code First : Project, Sprint, WorkItem, TeamMember et historique WorkItemAssignment

Carte du modèle. L’état courant sert l’écran opérationnel rapide ; un historique distinct prouve chaque décision.

Projects1
possède plusieurs
SprintsN
regroupent
WorkItemsN
attribués à
TeamMembers0..1
TableRôleChamps clésProtection
ProjectsFrontière du travailId, Name, Statusnom unique dans l’organisation
SprintsPériode de réalisationProjectId, StartAt, EndAtEndAt supérieur à StartAt
WorkItemsÉtat courantSprintId, Status, AssigneeId, VersionFK, index, jeton de concurrence
TeamMembersAccès au projetProjectId, UserId, IsActivepaire ProjectId + UserId unique
WorkItemAssignmentsHistorique des décisionsWorkItemId, AssigneeId, AssignedBy, AssignedAtajouts 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.

Expand

Ajoutez colonnes nullable, historique et index sans casser l’ancienne application.

Migrate

Activez la nouvelle écriture et le backfill par lots. Mesurez manques et erreurs.

Contract

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.

Domainerègles rapides et transitions
IntégrationEF Core, PostgreSQL, index, transaction
ContratHTTP, validation, format d’erreur
E2Eun parcours utilisateur critique

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

1Storycritères de recette
2Domainerègles et état
3Schémacontraintes et index
4Testpreuve du comportement
5Releasemétriques et rollback

Prêt à démarrer.

Cinq contrôles pour publier : critères, domaine objet, Code First, tests et release

Parcours de recette. Chaque contrôle apporte une preuve avant de passer à la suite.

10. Les raccourcis qui coûtent plus tard

RaccourciPourquoi il attireCe qu’il dégradeMeilleure décision
Setters publicsCRUD rapiderègles contournablesméthodes exprimant l’intention
Migration massivemoins d’étapesverrous et absence de retourexpand–migrate–contract
Include du graphe entiermapping facilerequêtes lourdesprojection adaptée à l’écran
Tests unitaires seulsrapidesSQL et contraintes non testésdomaine et intégration base
Definition of Done : « ça marche »semble simpleaucune 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

  1. Quelle décision métier ce code protège-t-il ?
  2. Le modèle empêche-t-il les états interdits ?
  3. Le schéma renforce-t-il cette règle ?
  4. La migration tolère-t-elle anciennes et nouvelles versions ?
  5. Quel test ou mesure prouve le fonctionnement après déploiement ?