Lucas Lusardo

No. 001 · June 24, 2025


Killing a Monolith, Half a Tuesday at a Time

The rewrite never ships. I’ve seen the pitch more than once — freeze the old system, build the new one properly this time, cut over on a weekend — and I’ve seen the postmortem for it more than once too. The strangler fig pattern is the boring alternative: route traffic to the new service incrementally, keep the old system alive and correct the entire time, and let the monolith end up with nothing left to do rather than declaring a day it dies.

What I arrived to was a Spring Boot monolith with a handful of services around it that had grown without a shared shape. The monolith had to come apart for scale, but the more immediate problem was that it was unpleasant to work in: heavy use of Spring Data REST meant the fast path was very fast and anything off that path — a custom response, a different projection, a behavior the framework didn’t anticipate — turned into a fight with the framework instead of a change to the code. Scale made the split inevitable; maintainability made it urgent. Thirteen services came out of the core — the rest of the twenty-nine the system ended up with were built new alongside them, not carved out of it.

What an architect wants, at that point, is to stop shipping features and put the house in order first. That almost never happens, and when it does happen it usually means something has already gone badly wrong. The realistic version is smaller and less satisfying: a slice of the week carved out for the migration — half a Tuesday, give or take — and by the next morning you’re shipping features again. Everything below is shaped by that constraint. If the migration only gets the hours that are left over, then every one of those hours has to be worth something.

Nothing gets extracted until four things exist

The instinct is to start with the first service. That was the mistake I didn’t make — I’d made it before. A strangler migration is a long series of small, reversible steps, and each step is only as cheap as the machinery underneath it. If standing up a service takes a week, you will batch the work, and batching is how a strangler migration turns back into a rewrite.

So the first phase produced no extracted services at all. It produced four things: instrumentation, so that “did this get worse” had an answer that wasn’t an opinion; service templates in Go and in Kotlin with Spring WebFlux, so that creating a service stopped being a set of twenty small architectural decisions made differently by whoever happened to create it; infrastructure in Terraform, so a new service’s queues, topics, database and infra in general just arrived through the same review-and-merge path as its code; and a build-and-deploy pipeline measured in tens of seconds, because a strangler migration is a few hundred deploys and you pay the pipeline once per deploy.

How that sequence got decided, and what it meant to tell people “not yet” about things that also needed doing, is the subject of its own post. What matters here is the shape of it: none of these four things extract anything. All of them determine what extraction costs, and the cost of extraction determines whether the migration finishes.

The seam is per-endpoint, and so is the strategy

The version of this pattern you read about treats the monolith as one system with one boundary. In practice the boundary is drawn once per endpoint, and the right decision is different every time.

Some endpoints were consumed by external clients against a published contract. Those moved as-is: same paths, same shapes, same quirks — including the ones I would not have designed. A migration is a bad moment to also renegotiate an interface with someone who has their own release cycle.

Some were consumed only by our own apps, where we controlled both sides and could ship them together. Those were the ones worth improving on the way out — a new version with a better shape, the old one kept alive until the clients moved off it.

And plenty of endpoints didn’t sort cleanly by consumer at all. Some were slow in ways the monolith’s data access made hard to fix, and moving them was partly an excuse to fix them. Some were fine and barely used, and the right migration was the cheapest one that worked. Some had a change already queued behind them, which made “migrate it as-is” a strange choice when we were about to touch it anyway. Consumer type, performance, pending changes, how much the existing design was hurting — all of it went into the call.

The useful discipline was to make the decision explicitly and write it down, endpoint by endpoint, rather than defaulting to one strategy across the board. Defaulting to as-is preserves warts forever. Defaulting to improve-while-you-move turns a migration into a redesign and doubles the number of things that can be wrong when something breaks. Neither default survives contact with ninety-odd endpoints that genuinely differ.

The gateway is the switch

All of this depended on there being a gateway in front, which there was. Every caller — our apps, external clients, everything — reached the monolith through it, which meant no consumer had a hardcoded address for anything. That single fact is what made an endpoint’s migration a routing change rather than a coordinated release.

So the unit of migration was the endpoint, and it moved whole. One line of gateway configuration pointed a path at the new service instead of the old one, and the old implementation stayed in place, untouched and still working, behind a route nothing used anymore. If the new service misbehaved, the rollback was the same line in reverse — not a redeploy, not a revert, not a data migration run backwards under pressure. That’s the endpoint’s mechanism specifically — a background job has no gateway route to flip; migrating one meant repointing the job itself, not a routing change.

That clean reversal held as long as the old implementation was still wired to the real data — the no-data-change and read-replica cases. Once writes or the data itself had moved out from under the monolith, flipping the route back wasn’t the whole rollback anymore; the data side needed its own plan.

That asymmetry is the whole reason this is survivable in half-day increments. The expensive part of a migration isn’t writing the new service; it’s the window where you’re committed to it and can’t easily go back. Keeping the old path alive and switchable collapses that window to nearly nothing, at the cost of running two implementations of the same endpoint for a while.

Which endpoint goes first

There was no rule for what to migrate next, and I don’t think there could have been. The ordering came out of the same variables every time, weighed differently: what was performing badly, what had open issues nobody could fix comfortably in place, what was used heavily enough that improving it paid off, and what had become genuinely unmaintainable inside the monolith.

Sometimes that meant starting with something that was actively on fire. The standard recommendation is to begin with a low-risk endpoint and build confidence with it, and that recommendation is a good one — it’s how you find out that your templates, your pipeline and your routing actually work before anything important depends on them. Often that’s exactly what we did. But when something is blowing up regularly, the healthiest thing you can do is get it out: you’re going to be notified about it either way, and outside the monolith you can actually change it.

I don’t think there’s a recipe here, and I’d distrust one. Other people’s experience is worth reading and worth taking seriously — most of what I did came from somewhere else — but none of it arrives knowing your system, and none of it is carved in stone. You take the recommendation, you hold it against the endpoint in front of you, and sometimes it survives that and sometimes it doesn’t. The evaluation is the part you can’t outsource.

That has a price, and it’s worth being honest about it rather than presenting the absence of a policy as pure virtue. Thinking every case through costs time and it costs energy, and it costs them repeatedly, on work that a rule would have dispatched in a minute. Multiply that by the number of endpoints and it’s a real line item.

What you get back is that after a dozen of these, the criteria stop living in one person’s head. The team has done the exercise enough times to have the instinct, and the conversation moves from “what do we do here” to “this one’s the read-replica case, right” — which is the only version of this that scales past the number of decisions one architect can personally make.

What happened to the data was also case by case

Moving an endpoint is easy. Deciding what the new service reads from is the part that actually varies.

Plenty of endpoints needed nothing at all: the service came out, kept talking to the same database, and the only thing that changed was which process served the request. Not elegant, but there’s no reason to solve a data problem you don’t have yet.

When the endpoint was read-only, a read replica was often enough. The new service got its own connection to a replica, the monolith stayed the writer, and nobody had to think about consistency beyond the replication lag that was already there.

That wasn’t always the final shape. Some of these stayed read-only for good — there was never a reason to move the write side. For others, read-only was step one: once the extracted service had run long enough to earn trust, the writes came out too, and the monolith stopped being the writer for that data as well. Two separate migrations, not one decision made in two stages, and the second one got the same endpoint-by-endpoint evaluation as everything else — it didn’t happen automatically just because the read side had already moved.

And in some cases we replicated the data outright — published through queues and topics into a store shaped for how that service actually read it, a key-value table instead of a set of joins across tables that existed for somebody else’s access pattern. That was more work, and it paid for itself twice: the reads got faster because the data was shaped for them, and they got cheaper because they stopped hitting the operational database at all.

The wrong instinct here is to pick one of these and apply it everywhere. The read replica is the cheapest thing that works and it works surprisingly often. The replicated store is worth it exactly when the access pattern justifies it, and is overhead when it doesn’t.

The questions that actually got asked

If none of this is a recipe, it’s fair to ask what was left. A recipe fixes the answer — read-only always means a replica, external client always means as-is. What repeated was the set of questions, asked in roughly the same order every time; the answers came out different for almost every endpoint, which is exactly the part a recipe doesn’t allow for.

Who consumes this? If the answer was an external client against a published contract, the endpoint moved as-is and the conversation ended there. When an endpoint had both — external clients on a published contract and our own apps alongside them — the external contract won; you don’t renegotiate someone else’s interface just because you also happen to be a consumer. If the answer was our own apps, where we controlled both sides and could ship them together, the door was open to move it and improve it at the same time — versioning, of course.

Does it hurt right now? Something that pages people, or that has a queue of bugs nobody can fix comfortably inside the monolith, is a candidate for going early despite the risk — you’re paying for it either way, and outside the monolith you can finally do something about it.

Is it read-only? If so, a replica usually settled the data question for now — sometimes for good, sometimes as the first of two moves once the service had earned the writes too.

Does the access pattern justify moving the data? Only when it clearly did — when the reads were fighting a schema shaped for somebody else’s use case — was replicating into a store of its own worth the work.

And the one I’d weight more heavily today than I did then: does this need to be its own service at all, or am I splitting it because splitting is what I’m doing this year?

What I’d do differently

Two things, and neither is about the migration mechanics. Migration strategy had to flex per endpoint — the runtime underneath didn’t. That’s a decision worth standardizing once, not re-litigating per service.

Scale was the reason the split happened in the first place, and it’s where the runtime choice ends up mattering too — not on day one, but once there are enough services for it to compound. I’d default to Go, and reach for Kotlin only where something actually required it. At the time the split was made on the merits of each service in isolation, which is a reasonable-sounding way to decide and quietly ignores that the bill is cumulative. A JVM service reserves its heap whether or not it’s using it, and takes its time getting up to speed after every deploy and every scale-out event — I know there are alternatives, but none of them are natively designed for this the way Go is. A Go service starts in milliseconds and holds a fraction of the memory. On any platform that charges for reserved memory per task, that difference is money, and it recurs for every replica of every service, every month, forever. Once you have a few dozen services, it stops being a rounding error. The runtime you pick isn’t only an engineering choice; past a certain number of services it’s a line in the budget, and I was slower to see that than I should have been.

The other one: several of those services are smaller than they need to be. Splitting is easy — it’s a local decision, one team, one afternoon of design. Merging is not. Putting two services back together means reconciling two deployment pipelines, two datastores, and two sets of people who have spent a year building habits around the boundary between them. The asymmetry means the cost of over-splitting is much higher than the cost of under-splitting, and the default should follow from that: the largest service that still makes sense, rather than the smallest one you can defend. You can always split later, and the later split will be better informed than the one you’d have made up front.

What’s left, and what “dead” actually means here

The old paths didn’t get decommissioned on a target date. Endpoint by endpoint, they got decommissioned when instrumentation showed zero real traffic for thirty consecutive days. Health checks never counted — pinging isn’t usage. Background jobs got the same discipline as the endpoints — migrated deliberately, on their own schedule, even without a gateway route to flip — and until that happened, a job still running against the old code was real traffic, not noise to filter out.

That’s endpoint by endpoint, job by job. The monolith as a whole never got to zero — there’s no day I can point to and call the death date. What’s left of it is small enough that it stopped being anyone’s problem: whatever hadn’t earned its own extraction yet, still running, unremarkable. “Killed” is the title, not the ending; the honest version is that it shrank until it stopped mattering.

The strangler fig pattern’s real promise isn’t that the migration is easy. It’s that at every point in the process, both systems are correct, so the migration has no deadline that correctness depends on — only a deadline for how long you’re willing to operate two systems at once, which is a cost problem, not a risk one. Three years of this, with production up the whole time and no maintenance window, is not a story about a clever cutover. The system ended up with twenty-nine services — thirteen pulled out of the core, the rest built new alongside them — and none of them arrived on a date anyone had announced in advance. It’s a story about four unglamorous investments made before the first extraction, and about refusing to make the same decision ninety-five times when the endpoints were never the same.

Mostly, though, it’s a story about the arithmetic of half a Tuesday. A monolith doesn’t get small because someone finally gets a quarter to gut it. It gets small because for long enough, the small window that was actually available was worth more than it looked.

← all posts