
The plan was simple: pull the notification route out of an old service I run, so a newer component could own it. Then I looked at what the old service actually did, and the plan got a lot less simple. That one process served the notification route and the entire dashboard I use to see my whole fleet of AI agents. Retire the process, and the dashboard goes dark with it.
Some vocabulary, since the rest of the post leans on it. The old service is a monolith: one big program that does several unrelated jobs at once. A port is the numbered door a program listens on, and every other service knows the monolith by its door number. What I wanted to migrate was one route, one specific URL path, the one that accepts notifications and turns them into voice callouts.
The wrong turn I almost took
The tempting plan was the big-bang version: write the replacement, stop the monolith, start the new thing, done in an afternoon.
That plan has a body count. The replacement would have to re-implement the dashboard too, or the dashboard dies in the gap. Re-implementing a whole UI to move one route is exactly the kind of scope creep that turns an afternoon into a quarter. I have written before about the daemon that was running while its socket was not there; that post was about a process looking alive while doing nothing. This one is about the opposite problem: a process I could not kill, because it was doing two jobs and I only wanted to take one away.
The fix: a shim that owns the door
Instead of replacing the monolith, I put a tiny router in front of it. I call it a shim. The shim is a small program, a few hundred lines, and it does two things:
- It binds the original port, the door number everything already knows.
- It answers the notification route itself, and forwards everything else to the monolith, which quietly moves to a neighboring port.
That forwarding is called a reverse proxy: the shim plays receptionist, takes every request, and passes along the ones it does not personally handle. Nothing that talks to the monolith had to change. Same door, same answers. Nobody repoints a client, because from the outside nothing moved.
Owning the notification route came with a bonus. The monolith used to hold each notification open while it placed the voice call, and slow calls made the route time out with a 502, the error you get when the server behind the door stops answering. The shim answers immediately, in the same second it receives the request, and places the call in the background. The false alarms stopped.
The pattern has a proper name, coined by Martin Fowler: the strangler fig, after the vine that grows around a host tree until the tree is gone. Most strangler-fig stories are about peeling routes off onto new hosts and new load balancers. Mine fit on a single machine and a single port. The vine is the shim. The tree retires in place, behind it, one route at a time.
The takeaway I would put on a mug: own the port, not the process. Whoever holds the door number decides what happens behind it, and a few hundred lines of router can hold that door while the old tree keeps the lights on.
The cutover, step by step
Moving the door without dropping requests took a specific order of operations:
- Relocate the monolith first. A systemd drop-in (a small override file that changes one setting without touching the whole unit) pointed it at the neighboring port. No code change, just configuration.
- Verify the monolith answers on its new port before stopping anything.
- Stop the old listener, confirm the original port is free, start the shim on it.
- Verify the dashboard loads through the shim, then keep the monolith behind it.
Each step polled for proof before the next one ran, and any failed check rolled the whole thing back to the starting state. The window where the door was unbound at all measured in seconds, and nobody’s browser noticed.
One last trap almost got me. The dashboard polls for updates, and I assumed it only ever asked for plain JSON. It also opens a live event stream, the kind where the server holds the connection open and pushes updates as they happen. A lazy proxy buffers that stream, and the dashboard freezes while looking perfectly connected. The shim passes those bytes through untouched. Check your streams before you learn about them from a frozen UI.
When to reach for this
Reach for the shim when one route needs a new owner and the rest of the process is fine where it is. The cost is a few hundred lines and one careful cutover. The alternative is a rewrite measured in weeks, or a migration where every client has to move at once.
It is the same posture I landed on when my plugins drifted because no deploy owned them: small, explicit ownership beats a big vague one. Own the port. Let the tree stand until the vine is ready.