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 checkoutany 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
SmtpClientdirectly →{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:
- The companion repo, commit by commit: mikado-billing-example
- The skill: mikado-method (MIT)