← Blog
AIlegacyrefactoringMikadoTypeScript

Mikado Method in Action: an Agent Refactoring Replayable Commit by Commit

In the previous article, I introduced the mikado-method skill: rules that enforce Mikado discipline on a coding agent.

Today, we watch it work. A complete example, from the first prompt to the last commit.

💻 All the code in this article lives in a companion repo: mikado-billing-example. Its git history was built by actually replaying the process: every error quoted here is a real compiler error, every SHA in the graph points to a real commit. You can git checkout any commit: the tests are green.

The starting point

A TypeScript BillingService, hardwired to an SMTP client:

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);

    // Billing knows the notification channel — and a failure
    // here blocks the invoice from being issued.
    await this.smtp.send(
      customerEmail,
      `Invoice #${invoice.id}`,
      `Amount due: ${(amountInCents / 100).toFixed(2)} EUR`,
    );

    return invoice;
  }
}

The real price of this coupling is paid in the tests. To test the billing logic, you have to start a fake SMTP server:

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

We want to decouple. Let’s go.

Gate 1: the goal in business value

The skill’s first reflex: rejecting a technical goal. Not “inject an interface into BillingService,” but:

Goal: invoices can be issued without billing knowing how customers are notified. Billing tests run without an SMTP server.

The nuance isn’t cosmetic. It provides the stopping criterion: the refactoring is done when this sentence is true. Not when “the interface exists.”

I confirm. The agent creates the graph with the root alone, and commits it immediately: otherwise, the upcoming reverts would wipe it out.

Cycle 1: the naive attempt

The agent notes the current SHA (d886144, the initial graph commit), then attempts the most direct change possible: calling this.gateway.notifyInvoiceIssued(invoice) instead of smtp.send.

The compiler answers:

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

It’s a failure. And it’s exactly what we wanted: every error is information.

Gate 2 kicks in. The agent proposes: “this error suggests the Ports & Adapters pattern (a NotificationGateway port on the domain side, an injected SMTP adapter). A) Apply B) Different approach.”

I confirm. Two nodes enter the graph:

[ ] Goal: billing does not know the notification channel
│ [ ] {N1} Replace SmtpClient calls with NotificationGateway
│   [discovered-by: d886144]
│   [parent-error: BillingService.ts:20:16: TS2339 Property 'gateway' does not exist]
│ [ ] {N2} Remove SmtpClient from the constructor (cleanup)
│   requires: {N1}
│   [discovered-by: d886144]
│   [parent-error: BillingService.ts:8: SmtpClient field will be orphaned once {N1} is done]

Then the graph is committed, and the code is reverted. Zero trace of the attempt, except in the graph.

A detail that matters: {N2} has no children, but its requires: {N1} makes it the last node to execute. A “do this last” comment would get lost. A constraint in the graph won’t.

Notice its parent-error too: it’s not a compilation error, it’s anticipated debt (the SmtpClient field will become orphaned as soon as {N1} is done). The validator requires a justification per node, not necessarily a compiler message.

Cycle 2: digging into {N1}

New attempt: injecting the gateway into the constructor. Three failures this time:

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.

Three errors, four nodes (a single error can reveal two prerequisites):

  • The missing module says two things: the interface doesn’t exist → {N5}, and the constructor must accept it → {N3}
  • The DI container must provide an implementation → {N4}
  • And the third line: the test fixture instantiates SmtpClient directly → {N6}

That last one is the kind of dependency you usually discover in production. Here, it enters the graph with its exact error. Graph committed. Code reverted.

Cycle 3: digging into {N4}

Attempting the DI wiring reveals two gaps:

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

The adapter doesn’t exist ({N7}), and it needs an error type to throw when SMTP fails ({N8}).

The following attempts reveal nothing new. The graph is stable:

[ ] Goal: billing does not know the notification channel
│ [ ] {N1} Replace SmtpClient calls with NotificationGateway
│ │ [ ] {N3} Constructor accepts NotificationGateway        requires: {N5}
│ │ │ [ ] {N5} Create the NotificationGateway interface
│ │ [ ] {N4} Wire SmtpNotificationGateway into DI           requires: {N3}, {N5}, {N7}
│ │ │ [ ] {N7} Implement the SMTP adapter                   requires: {N5}, {N8}
│ │ │ │ [ ] {N8} Create NotificationFailedError
│ │ [ ] {N6} Provide a stub in the test fixtures            requires: {N3}, {N5}
│ [ ] {N2} Remove SmtpClient from the constructor           requires: {N1}

Note {N5}: it’s a shared prerequisite of four nodes. The graph is a DAG, not a strict tree. One node, one checkbox.

Gate 3: who takes the wheel?

The validator (validate-mikado.sh) goes green. The agent presents the graph, identifies the true leaves ({N5} and {N8}, independent) and asks the question:

Exploration complete — 2 true leaves. Delegation level? (1 / 2 / 3)

I pick level 2: the agent implements leaf by leaf, and waits for my “ok” after each green commit.

The execution

First leaf: {N5}. Red test, then the minimal implementation:

/**
 * Port owned by the billing domain. Billing knows that customers
 * are notified — never how.
 */
export interface NotificationGateway {
  notifyInvoiceIssued(invoice: Invoice): Promise<void>;
}

Green suite. The node is checked off, the graph validated, and everything goes into one commit. The agent waits for my go-ahead. And so on, climbing back up the graph:

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

One refinement along the way: so that every commit stays green, {N3} adds the gateway as an optional constructor parameter. It’s the expand phase of a parallel change: {N4} and {N6} migrate the callers, {N1} makes the parameter mandatory, {N2} contracts by removing the old one. And this reasoning isn’t just in this article: the repo’s commit bodies literally say “Parallel change, expand phase” on {N3} and “contract phase” on {N1}.

At {N1}, the satisfying moment: the billing tests drop the SMTP server for a simple in-memory stub. And the assertion on the “125.50 EUR” format leaves those tests for the adapter’s: the formatting moved there, the test follows the responsibility.

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

And the final service knows nothing about SMTP anymore:

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;
  }
}

The history tells the whole story

Here’s the repo’s real git log (excluding the final README commit), read bottom to top:

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

Eight feat: commits, one per node. Four mikado-graph: commits, one per exploration cycle. Between any two commits, main compiles and the tests pass. You can ship at any moment, or stop and pick up again a week later: all the state is in the graph.

What if we cheat?

Imagine the agent attempts {N7} before {N8}. The compiler demands NotificationFailedError.

The skill enforces an immediate revert: it was a false leaf, and the graph already said so. No improvisation. The rule is the same as during exploration: regression = new node + revert.

What to take away

Agents bring exploration speed. The skill brings the discipline that keeps that speed from becoming destructive.

The result: an always-shippable codebase, a git history that documents the reasoning, and a delegation slider you move with confidence.

To go further: