← Blog
IAlegacyrefactoringMikadoTypeScript

Mikado Method en action : un refactoring d'agent rejouable commit par commit

Dans l’article précédent, je présentais le skill mikado-method : des règles qui imposent la discipline Mikado à un agent de code.

Aujourd’hui, on le voit travailler. Un exemple complet, du premier prompt au dernier commit.

💻 Tout le code de cet article est dans un repo compagnon : mikado-billing-example. Son historique git a été construit en rejouant réellement le processus : chaque erreur citée ici est une vraie erreur du compilateur, chaque SHA du graphe pointe sur un vrai commit. Vous pouvez faire git checkout sur n’importe quel commit : les tests sont verts. Seule liberté prise ici : les commentaires de code sont traduits en français, le repo est en anglais.

Le point de départ

Un BillingService TypeScript, couplé en dur à un client SMTP :

export class BillingService {
  constructor(
    private readonly repository: InvoiceRepository,
    private readonly smtp: SmtpClient,
  ) {}

  async issueInvoice(customerEmail: string, amountInCents: number): Promise<Invoice> {
    const invoice = new Invoice(/* ... */);
    this.repository.save(invoice);

    // La facturation connaît le canal de notification — et un échec
    // ici bloque l'émission de la facture.
    await this.smtp.send(
      customerEmail,
      `Invoice #${invoice.id}`,
      `Amount due: ${(amountInCents / 100).toFixed(2)} EUR`,
    );

    return invoice;
  }
}

Le vrai prix de ce couplage se paie dans les tests. Pour tester la logique de facturation, il faut démarrer un faux serveur SMTP :

test('issuing an invoice persists it and notifies the customer', async () => {
  const smtpServer = await startFakeSmtpServer();  // 😱 pour tester une facture
  const smtp = new SmtpClient('127.0.0.1', smtpServer.port);
  const service = new BillingService(repository, smtp);
  // ...
});

On veut découpler. C’est parti.

Gate 1 : l’objectif en valeur métier

Premier réflexe du skill : refuser un objectif technique. Pas « injecter une interface dans BillingService », mais :

Objectif : les factures peuvent être émises sans que la facturation sache comment les clients sont notifiés. Les tests de facturation tournent sans serveur SMTP.

La nuance n’est pas cosmétique. Elle donne le critère d’arrêt : le refactoring est terminé quand cette phrase est vraie. Pas quand « l’interface existe ».

Je valide. L’agent crée le graphe avec la racine seule, et le commite immédiatement : sinon, les reverts à venir l’emporteraient.

Cycle 1 : la tentative naïve

L’agent note le SHA courant (d886144, le commit du graphe initial), puis tente le changement le plus direct possible : appeler this.gateway.notifyInvoiceIssued(invoice) à la place du smtp.send.

Le compilateur répond :

src/services/BillingService.ts(20,16): error TS2339:
  Property 'gateway' does not exist on type 'BillingService'.

C’est un échec. Et c’est exactement ce qu’on voulait : chaque erreur est une information.

Gate 2 s’active. L’agent propose : « cette erreur suggère le pattern Ports & Adapters (un port NotificationGateway côté domaine, un adaptateur SMTP injecté). A) Appliquer B) Autre approche ».

Je valide. Deux nœuds entrent dans le graphe :

[ ] Goal: la facturation ne connaît pas le canal de notification
│ [ ] {N1} Remplacer les appels SmtpClient par NotificationGateway
│   [discovered-by: d886144]
│   [parent-error: BillingService.ts:20:16: TS2339 Property 'gateway' does not exist]
│ [ ] {N2} Retirer SmtpClient du constructeur (cleanup)
│   requires: {N1}
│   [discovered-by: d886144]
│   [parent-error: BillingService.ts:8: SmtpClient field will be orphaned once {N1} is done]

Puis le graphe est commité, et le code est reverté. Zéro trace de la tentative, sauf dans le graphe.

Un détail qui compte : {N2} n’a pas d’enfant, mais son requires: {N1} en fait le dernier nœud à exécuter. Un commentaire « à faire en dernier » se perdrait. Une contrainte dans le graphe, non.

Remarquez aussi sa parent-error : ce n’est pas une erreur de compilation, c’est une dette anticipée (le champ SmtpClient deviendra orphelin dès que {N1} sera fait). Le validateur exige une justification par nœud, pas forcément un message du compilateur.

Cycle 2 : on creuse {N1}

Nouvelle tentative : injecter le gateway dans le constructeur. Trois échecs cette fois :

src/di/container.ts(14,21): error TS2554: Expected 3 arguments, but got 2.
src/services/BillingService.ts(4,37): error TS2307:
  Cannot find module '../notifications/NotificationGateway.js'
tests/billing-service.test.ts(30,19): error TS2554: Expected 3 arguments, but got 2.

Trois erreurs, quatre nœuds (une même erreur peut révéler deux prérequis) :

  • Le module introuvable dit deux choses : l’interface n’existe pas → {N5}, et le constructeur doit l’accepter → {N3}
  • Le conteneur DI doit fournir une implémentation → {N4}
  • Et la troisième ligne : la fixture de test instancie SmtpClient directement → {N6}

Cette dernière est le genre de dépendance qu’on découvre d’habitude en production. Ici, elle entre dans le graphe avec son erreur exacte. Commit du graphe. Revert du code.

Cycle 3 : on creuse {N4}

Tenter le câblage DI révèle deux manques :

src/di/container.ts(12,23): error TS2304:
  Cannot find name 'SmtpNotificationGateway'.
src/notifications/SmtpNotificationGateway.ts(15,17): error TS2304:
  Cannot find name 'NotificationFailedError'.

L’adaptateur n’existe pas ({N7}), et il a besoin d’un type d’erreur à lever quand le SMTP échoue ({N8}).

Les tentatives suivantes ne révèlent plus rien. Le graphe est stable :

[ ] Goal: la facturation ne connaît pas le canal de notification
│ [ ] {N1} Remplacer les appels SmtpClient par NotificationGateway
│ │ [ ] {N3} Le constructeur accepte NotificationGateway    requires: {N5}
│ │ │ [ ] {N5} Créer l'interface NotificationGateway
│ │ [ ] {N4} Câbler SmtpNotificationGateway dans le DI      requires: {N3}, {N5}, {N7}
│ │ │ [ ] {N7} Implémenter l'adaptateur SMTP                requires: {N5}, {N8}
│ │ │ │ [ ] {N8} Créer NotificationFailedError
│ │ [ ] {N6} Passer un stub dans les fixtures de test       requires: {N3}, {N5}
│ [ ] {N2} Retirer SmtpClient du constructeur               requires: {N1}

Notez {N5} : c’est un prérequis partagé de quatre nœuds. Le graphe est un DAG, pas un arbre strict. Un seul nœud, une seule case à cocher.

Gate 3 : à qui le volant ?

Le validateur (validate-mikado.sh) passe au vert. L’agent présente le graphe, identifie les vraies feuilles ({N5} et {N8}, indépendantes) et pose la question :

Exploration terminée — 2 vraies feuilles. Niveau de délégation ? (1 / 2 / 3)

Je choisis le niveau 2 : l’agent implémente feuille par feuille, et attend mon « ok » après chaque commit vert.

L’exécution

Première feuille : {N5}. Test rouge, puis l’implémentation minimale :

/**
 * Port possédé par le domaine facturation. La facturation sait que
 * les clients sont notifiés — jamais comment.
 */
export interface NotificationGateway {
  notifyInvoiceIssued(invoice: Invoice): Promise<void>;
}

Suite verte. Le nœud est coché, le graphe validé, et tout part dans un commit. L’agent attend mon feu vert. Et ainsi de suite, en remontant le graphe :

{N5}, {N8}  →  {N7}, {N3}  →  {N4}, {N6}  →  {N1}  →  {N2}

Un raffinement en route : pour que chaque commit reste vert, {N3} ajoute le gateway en paramètre optionnel du constructeur. C’est la phase expand d’un parallel change : {N4} et {N6} migrent les appelants, {N1} rend le paramètre obligatoire, {N2} contracte en supprimant l’ancien. Et ce raisonnement n’est pas que dans cet article : les corps des commits du repo disent littéralement « Parallel change, expand phase » sur {N3} et « contract phase » sur {N1}.

Au passage de {N1}, le moment satisfaisant : les tests de facturation abandonnent le serveur SMTP pour un simple stub en mémoire. Et l’assertion sur le format « 125.50 EUR » quitte ces tests pour celui de l’adaptateur : le formatage y a déménagé, le test suit la responsabilité.

function notificationStub() {
  const notified: Invoice[] = [];
  return {
    notified,
    gateway: {
      async notifyInvoiceIssued(invoice) {
        notified.push(invoice);
      },
    },
  };
}

Et le service final ne sait plus rien du SMTP :

export class BillingService {
  constructor(
    private readonly repository: InvoiceRepository,
    private readonly gateway: NotificationGateway,
  ) {}

  async issueInvoice(customerEmail: string, amountInCents: number): Promise<Invoice> {
    const invoice = new Invoice(/* ... */);
    this.repository.save(invoice);

    await this.gateway.notifyInvoiceIssued(invoice);

    return invoice;
  }
}

L’historique raconte tout

Voici le git log réel du repo (hors commit de README final), à lire de bas en haut :

feat: remove SmtpClient from BillingService — root goal reached
feat: BillingService notifies through NotificationGateway —
      billing tests run without an SMTP server
feat: billing test fixtures provide a NotificationGateway stub
feat: wire SmtpNotificationGateway into the DI container
feat: BillingService constructor accepts NotificationGateway (optional during migration)
feat: implement SmtpNotificationGateway adapter
      (wraps failures in NotificationFailedError)
feat: create NotificationFailedError class
feat: create NotificationGateway interface (notifyInvoiceIssued)
mikado-graph: N4 requires SmtpNotificationGateway adapter, ...
mikado-graph: N1 requires constructor param, DI wiring, interface and test stub
mikado-graph: Goal requires NotificationGateway calls in BillingService.ts:22
mikado-graph: initial graph for decouple-billing-from-smtp (root only)
chore: legacy billing service — SmtpClient hardwired into BillingService

Huit commits feat:, un par nœud. Quatre commits mikado-graph:, un par cycle d’exploration. Entre deux commits, main compile et les tests passent. On peut livrer à tout moment, ou s’interrompre et reprendre dans une semaine : tout l’état est dans le graphe.

Et si on triche ?

Imaginons que l’agent tente {N7} avant {N8}. Le compilateur réclame NotificationFailedError.

Le skill impose le revert immédiat : c’était une fausse feuille, le graphe le disait déjà. Pas d’improvisation. La règle est la même qu’en exploration : régression = nouveau nœud + revert.

Ce qu’il faut retenir

Les agents apportent la vitesse d’exploration. Le skill apporte la discipline qui empêche cette vitesse de devenir destructrice.

Le résultat : un codebase toujours livrable, un historique git qui documente le raisonnement, et un curseur de délégation que vous déplacez en confiance.

Pour aller plus loin :