Case File: Migrating a Legacy Reservation Handler Without a Rewrite
A migration isn't finished when the new code works. It's finished when the old code is gone. That distinction sounds obvious until you're the one holding a legacy handler that half a dozen other things quietly depend on, and "it works now" starts to feel like a reasonable place to stop. This Case File walks through migrating one, the reservation handler from the testing series, start to finish, using the end-to-end net built through it as the only thing that makes any of the following steps safe to attempt.
The Handler Nobody Wants to Touch
Here's roughly what POST /reservations looks like before anything changes:
app.post("/reservations", async (req, res) => {
const { roomId, date } = req.body;
if (!roomId || !date) return res.status(400).send("Missing fields");
const existing = db
.prepare("SELECT id FROM reservations WHERE room_id = ? AND date = ?")
.get(roomId, date);
if (existing) return res.status(409).send("Slot taken");
const id = db
.prepare("INSERT INTO reservations (room_id, date) VALUES (?, ?)")
.run(roomId, date).lastInsertRowid;
res.status(201).json({ id, roomId, date });
});
Nothing here is unusual for a fifteen-year-old codebase. Request parsing, the conflict rule, and the database call are the same twelve lines, and that's exactly the problem: there's no seam1 anywhere in this function. Changing the conflict rule means editing the same block that talks to SQL. Swapping the database means touching the same block that decides who wins a double-booking. Every change carries the risk of every other change, because nothing is separated from anything else.
Before this series built an end-to-end test2 around this exact endpoint, that risk was reason enough to leave it alone. That's the case for starting with conflict endpoints in the first place, not because they're the easiest thing to touch, but because they're the thing you eventually have to. A bug in this specific twelve lines has two possible failure modes, and both are visible to a customer within seconds: either two people get sent to the same room on the same date, or the check misfires and turns away a room that was actually free. There's no quiet way to get this wrong.
Extract the Rule, Change Nothing Else
The instinct once you finally have a safety net is to redesign the whole thing at once, a new module, a clean interface, a proper repository layer, all in one pass. Resist it. The first step changes as little as possible:
function assertSlotAvailable(db: Database, roomId: string, date: string) {
const existing = db
.prepare("SELECT id FROM reservations WHERE room_id = ? AND date = ?")
.get(roomId, date);
if (existing) throw new ConflictError("Slot taken");
}
The handler calls this function in the same place the inline check used to live. Same SQL, same behavior, same response codes. The only thing that moved is where the rule is written down, not what it does. Run the e2e suite. It should be green, and it should stay green, because nothing observable has changed yet. If it isn't green, stop: that's a sign the "extraction" quietly changed behavior, and the bug needs fixing before the migration continues, not after.
This step feels like it accomplishes nothing, and that's the point. A migration that only ever takes steps this small is a migration you can stop in the middle of without leaving the codebase worse than you found it. A migration that takes one giant step is a migration you're committed to finishing under deadline pressure, whether or not it's still going well.
If an agent is doing the extraction, this is also where the plan-first discipline from earlier in the series earns its keep: propose exactly this one extraction, name the function it moves into and nothing else, and get that plan reviewed before any code changes. A one-function extraction is small enough to review in a minute. A plan that quietly bundles the interface, the repository, and the deletion of the old path into "step one" is not, and it's exactly the kind of plan that's harder to catch in review than to have never proposed.
Put an Interface Behind the Rule, Not the Database
The second step is where the actual architecture changes, and it's a narrower move than it sounds: the rule stops calling db.prepare directly and starts depending on an interface instead.
interface ReservationRepository {
findConflict(roomId: string, date: string): Reservation | null;
save(roomId: string, date: string): Reservation;
}
function assertSlotAvailable(repo: ReservationRepository, roomId: string, date: string) {
if (repo.findConflict(roomId, date)) throw new ConflictError("Slot taken");
}
A SqliteReservationRepository implements that interface with the exact same two queries the handler used to run inline:
class SqliteReservationRepository implements ReservationRepository {
constructor(private db: Database) {}
findConflict(roomId: string, date: string): Reservation | null {
return this.db
.prepare("SELECT id FROM reservations WHERE room_id = ? AND date = ?")
.get(roomId, date) ?? null;
}
save(roomId: string, date: string): Reservation {
const id = this.db
.prepare("INSERT INTO reservations (room_id, date) VALUES (?, ?)")
.run(roomId, date).lastInsertRowid;
return { id, roomId, date };
}
}
Nothing about the database access changed; what changed is that the conflict rule no longer knows it's talking to SQLite. It knows it's talking to something that can find a conflict and save a reservation, and that's the entire contract.
This is the moment the rule actually gets decoupled3 from the database, and the handler's dependency direction4 flips. Before, the business rule depended on the database. After, both the business rule and the database depend on the same interface, and neither one depends on the other directly. It's a small diagram, but it's the diagram this whole migration was for:
The migration isn't the new code working. It's the old code no longer being the only thing that can.
The Migration Finishes When the Old Path Disappears
At this point it's tempting to call it done. The interface exists, the rule is extracted, the tests are green. But the original twelve-line handler is usually still sitting in the codebase somewhere, either commented out "just in case" or left as dead code behind a feature flag nobody's turned off. That's not a finished migration; it's two systems now, one of which is unreachable but still costs everyone reading the file the effort of understanding it.
The actual last step is deleting it. Remove the inline SQL from the handler entirely, wire it exclusively through assertSlotAvailable and the repository, and run the suite one more time. If it's green, the migration is over, not because the new path works, but because the old one no longer exists for anyone to accidentally depend on, extend, or copy into the next handler that needs the same logic.
That last run of the test suite is doing more work than it looks like. It's the same end-to-end net from earlier in this series, unchanged since the first extraction, still asserting the exact same thing it always did: send two conflicting requests, get exactly one success. The implementation underneath has been rebuilt twice since then. The contract never moved.
Conclusion
None of the three steps here were individually impressive. Pull a rule into its own function. Put an interface behind it. Delete what's left. What made the migration real wasn't the sophistication of any one step, it was that each one was small enough to verify immediately and reversible if it wasn't, and that the whole sequence ended with something removed, not just something added. A legacy migration that only adds code is a codebase that's now carrying both the old risk and the new complexity at once. The one that's actually finished is the one where, a week later, nobody can find the twelve-line function that used to make this endpoint so dangerous to touch, because it isn't there anymore.
Footnotes
-
Seam: a point in the code where you can change behavior without editing the code at that point, usually by passing in something different. ↩
-
End-to-end test: a test that drives a request through the real system the way a client would, over HTTP, through the real handler, into the real database, and asserts on the response. ↩
-
Decoupling: reducing how much one part of a system needs to know about another, so each can change without dragging the other along with it. ↩
-
Dependency direction: which way an arrow points in a dependency diagram, toward the stable abstraction, not toward whichever concrete detail happens to be called. ↩
Related articles
Get new articles in your inbox
Writing about pragmatic architecture, maintainable systems, and real-world engineering trade-offs. Published when ready.