Shrinking the estate: consolidating to go faster
When Azure deprecated App Service managed certificates, we had to fix it in hundreds of places. One platform change; hundreds of near-identical services, each with its own pipeline, its own secrets, its own tests to break. It cost days and delivered no new value to our customers. The day-to-day friction was real, but the deeper issue was structural: the estate had grown past the capacity we had to maintain it.
That constraint set the effort behind every improvement we wanted to make. Fast local development, reliable testing, a single deployment story: each had to be applied across every service, so the work scaled with the size of the estate rather than the size of the change. The improvements with the most leverage were exactly the ones the architecture made hardest to ship. It wasn't only slowing us down day to day; it was keeping the systematic, compounding improvements out of reach.
So over the following months we shrank it. We took roughly 400 deployable units down to around 160. No customer noticed, because the behaviour was identical on the other side. Internally, the day-to-day friction dropped and the high-leverage improvements we'd deferred became feasible. This is how we did it, and why the number that mattered wasn't the count we deleted but the work it freed us to start.

How we got here
Earlier in Codat's history, we went pretty hard on microservices, tantalised by the prospect of small, independently deployable pieces to let teams move without stepping on each other, and we got the benefits it promised: independent deployment, blast-radius isolation, clear boundaries.
More recently, however, it became increasingly apparent that having so many fine-grained services was slowing us down and holding us back. Every shared library bump became a dozen pull requests. Every certificate rotation became hundreds. A long tail of services that no one had touched in months still had to be patched, observed, and kept alive. We were spending way too much time Keeping The Lights On.
The independence was also partly an illusion. Enough of the services were tightly coupled that we had, in effect, a distributed monolith: nominally separate deployables that still had to be released in a careful, coordinated sequence. A single meaningful change often spanned several repositories and more than one team, and inconsistent deployment patterns meant no two releases felt quite the same. Shipping became an exercise in coordination rather than engineering.
Merge first, fix later
The goal was simple: rationalise our service domains and drastically reduce the number of deployable units within each, with each domain owned by exactly one team.
The decision that made it achievable was what we chose not to do. Phase one merged services and nothing else. No re-architecting, no consolidating databases, no aligning every service to whatever we now consider best practice. If a service owned three databases after a merge, it kept three databases. Untangling that is a separate data-migration project for another day.
In hindsight, this was a prudent call. Giving in would have turned a bounded job into an open-ended rewrite we might never have finished. Drawing the line at merging the units, and leaving everything else where it was, is what kept the work finite; consistency could come later, on a smaller base.
Approach
We started by mapping the whole estate to find the natural domain boundaries. This was iterative rather than a grand up-front design: propose a boundary, test it against how the services actually talked to each other, adjust.
Once the boundaries were set, we identified the distinct set of deployable unit types and discovered that nearly every merge reduced to one of five consolidation paths between source type and target type. Windows App Services were retired, not carried forward. WebJobs and in-process Azure Functions became isolated Functions, which run in their own process on any version of .NET and, unlike the in-process model, emit the distributed traces we need to actually see what a background job is doing.
Moving from Windows to Linux surfaced a handful of subtle differences between the platforms: path separators, filename casing (a single mis-cased appsettings.json and the app refuses to start), environment variables that Windows treats as case-insensitive and Linux does not. We catalogued these gotchas along the way to help avoid them on future merges.
Merging two applications also meant merging two dependency injection containers, and reconciling every registration between them. The trick we settled on was to dump both service collections just before building the container and diff them, letting the difference tell us what the combined configuration needed to be, rather than reasoning about it by hand.
We also decided that if a merge target was not already running in Kubernetes, we would migrate the target to k8s first rather than migrating host infrastructure mid-flight. We updated our internal routing setup to ensure deprecated connection strings now routed to the new home of the merged service.
Leveraging AI to consolidate faster
What struck us early on was how mechanical most of the work turned out to be: move the code, reconcile the references, fix the build, port the tests, update the Helm charts. It was repetitive and error-prone, and we started to realise it was close to the kind of work modern coding agents are good at, which usefully coincided with us rolling out Claude Code across engineering. Claude performed the merges directly: resolving namespace collisions, untangling conflicting DI registrations, porting unit and integration tests into the target project, and updating charts and compose files.
But being in the driving seat guiding Claude, we were still liable to miss steps or do them in the wrong order, relearning the same lessons. So we used Claude to build a merge tool, incorporating two stages into the merge process. The first stage executed deterministic C# to perform the mechanical steps, including preserving full git history by importing the source as a stack of branches, so git blame still worked across the merged code, and it rewrote namespaces and using-statements to match the target's layout. The second stage generated Claude prompts for aspects of the merge that required judgement, such as consolidating two appsettings.json files and resolving dependency/restore failures.
The part we came to rely on most was that it never touched a file in the target. Any conflict was copied in beside the original under an _Imported or _OLD suffix for a person to reconcile; the build was validated before we called a merge done; and progress was saved after each stage, so an interrupted run could pick up where it stopped.

We're not going to pretend it was hands-off. It still needed direction. Occasionally it would fixate on a symptom instead of chasing the root cause, and a human had to redirect it. But the balance had tipped away from mechanical toil and toward judgement, which was what we'd been hoping for.
What it unlocks
We removed hundreds of deployable units and consolidated our estate onto a unified host architecture. Features that used to require a coordinated march across three or four repositories now land in a single pull request. Maintenance is cheaper for the obvious reason that there are fewer moving parts to maintain; we immediately started spending less time Keeping The Lights On. But perhaps the most exciting benefit is that all the improvements we've dreamt of making to move our estate forward suddenly have become way more feasible.
There's some relatively low-hanging fruit such as replacing legacy custom libraries with idiomatic .NET, which we're now chipping away at. But we've immediately started work on something much bigger: we're reducing our number of environments to two, local and production. Stick around if you want to learn more!
