Compiling Ideas
Compiling Ideas Podcast
Decomposing the Monolith Starts With Facing the Mess
0:00
-29:37

Decomposing the Monolith Starts With Facing the Mess

You can’t just rip a giant monolith into microservices overnight — first you have to untangle the beast from within. This article walks through how to identify natural breakpoints in a sprawling, messy codebase and reorganize your monolith into clear modules and layers before splitting it into services. It’s a candid look at the unglamorous but critical work required to prevent your microservices dream from turning into a distributed nightmare.

If you like written articles, feel free to check out my medium here: https://medium.com/@patrickkoss

Introduction

Ever opened a project so big and tangled that you felt lost in your own code? I have. Think 150,000 lines spread across thousands of files, built up over three frantic years. Features piled on features, often contradicting old assumptions as the business pivoted and product owners changed their minds. Our team inherited a true monster of a monolith — a single deployable application handling everything — and it was starting to show its age. Build times were crawling. Onboarding new developers felt like handing them a map of Middle Earth and wishing them good luck. And every new feature was a high-stakes Jenga move: one wrong change, and something deep in the stack would come crashing down.

So the big idea came down from on high: Let’s break the monolith into microservices! In theory, it sounded fantastic. Smaller codebases, independent deployments, faster feature delivery — who wouldn’t want that? But as we sat down and looked at our entangled code, a terrifying question emerged: Where do we even start? This wasn’t a cleanly-separated system where you could draw neat lines around “User Service” and “Payment Service.” This was a ball of spaghetti where every class seemed to import, call, or inherit from every other. If we tried to yank one piece out into a microservice, ten other pieces would scream in pain with compilation errors. Simply put, our monolith had no obvious seams to pick apart.

At this point, it was clear that decomposing this monolith would be a journey through fire. We couldn’t just slice off one chunk and deploy it as a service without breaking everything. Before any microservice magic could happen, we needed to do some serious refactoring and re-architecting within the monolith itself. In other words, we had to turn our Big Ball of Mud into a well-structured modular monolith first. Only then could we even think about splitting it into services.

What follows isn’t a fairy tale of instantly modernizing a legacy app. It’s the gritty story of how to wrestle a monolith into a shape that can be safely chipped apart. I’ll talk about discovering hidden boundaries in the code, untangling interdependencies, leveraging tools (and even a little AI) to map out the mess, and convincing the business to give us the time for this unglamorous prep work. By the end, you’ll see why the real art of decomposing a monolith isn’t in the microservices patterns themselves — it’s in getting your monolith ready for them.

When a Monolith Becomes a Monster

Our monolith didn’t start as a tangled beast. It began as a simple, well-intentioned project. But as new features were bolted on release after release, it slowly morphed into a mutation that only its creators understood — and many of those creators had since left the company. The codebase tried to follow best practices at first. We had a notion of layers: a presentation layer, a business logic layer, an adapter layer for database and external API calls. We had some domain separation in theory. But in practice, years of quick fixes and half-implemented redesigns had blurred almost every line. Utility classes used everywhere like global god objects? Oh yeah. Copy-pasted logic forked into two slightly-different versions because no one noticed the original function? You bet.

In meetings, people started referring to the codebase as the “big ball of mud,” only half-joking. It’s a known term in software architecture for a system with no discernible structure — just a grab-bag of everything. That was us. Still, the system worked (mostly). It handled millions of users and hefty transaction loads. But each day it got a little harder to add new things or change old things without breaking something else. The cracks were showing, and business was feeling the pain through slower feature rollouts.

When the idea of microservices came up, it was like the promise of a clean slate. The phrase “decompose the monolith” makes it sound so simple — like the monolith is a loaf of bread you can slice into neat pieces. The reality? More like a big pot of spaghetti and meatballs that you’re somehow supposed to separate into individual bowls without making a mess. We were looking at a classic dilemma: If we do nothing, the monolith slows us down further. If we try to rip it apart recklessly, we could break the system (and the team) outright.

We had to acknowledge a hard truth up front: moving to microservices would not magically fix our problems unless we fixed the monolith’s internal problems first. Plenty of companies have jumped on the microservice bandwagon only to end up with a distributed big ball of mud — a set of microservices that are just as tightly coupled and confusing as the monolith ever was, only now with network calls in between. No way were we going to let that happen here. If we were going to do this, we would do it the right way, even if it meant lots of dirty work on the monolithic codebase before writing a single line of new service code.

Microservices Won’t Save You (Until You Fix Your Code)

It’s worth emphasizing this again: microservices themselves weren’t the hard part. The hard part was untangling the existing code so that microservices would actually make things better, not worse. We had to find the “seams” in our application — the logical boundaries where we could split functionality — despite those seams being buried under layers of mud and spaghetti.

Our first step wasn’t to pick a random feature and start coding a new service. It was to understand what the heck we already had. That meant going back to basics and reverse-engineering our own system. We interviewed veteran engineers and product folks to piece together what the core domains of the application really were. What were the primary functions of the system? Could we map out areas like User Management, Orders, Payments, Notifications, etc., even if the code wasn’t cleanly organized that way? We needed a target vision: a rough idea of which subdomains might become independent services someday.

In parallel, we dug into the codebase using every tool at our disposal. Modern IDEs can be a lifesaver here. We used IntelliJ’s find usages, VSCode’s references search, and anything else to trace where classes and functions were being used. Pretty soon we had spider-web diagrams on whiteboards (yes, actual whiteboards with markers and stickies), showing modules and their knotted interdependencies. It wasn’t pretty. One core library, meant to be an “utility” module, was basically imported everywhere. A so-called “Auth” component not only handled authentication, but also leaked into session management, user profiles, and half a dozen other areas. No wonder we couldn’t just carve out an “Auth Service” — it had hooks into everything.

At this point I’ll admit, the thought “maybe we should just rewrite from scratch” crossed our minds more than once. But a full rewrite was out of the question (the business wasn’t going to freeze development for a year or more). We had to do this evolutionary style: incrementally, safely, and with minimal downtime. So, we committed to refactoring in place. Before writing new microservices, we would renovate the monolith from the inside out.

Finding the Seams in the Code

To split a monolith, you need to find its natural joints — those places where the code can be separated with minimal pain. Imagine trying to split a giant rock: you look for cracks to hammer your chisel into. In code, those “cracks” are places where one part of the system doesn’t overly depend on the others. The challenge is finding them in a codebase that’s all tangled up.

This is where good tooling (and a bit of creativity) comes in. We leveraged our compiler and static analysis tools to map out dependencies between classes and modules. In fact, we realized the compiler itself knows all the relationships — if you move a piece of code and hit compile, the errors will tell you exactly what broke. Instead of doing that manually for thousands of classes (and pulling our hair out), we wrote some scripts to automate the process. A quick-and-dirty Python script can scan your project for import statements or reference chains and produce a dependency graph. We did exactly that: traversed our code to see who uses whom.

Picture a big directed graph where each node is a class or module, and an edge means “calls or references.” At first, ours looked like a plate of spaghetti thrown against the wall. But by identifying clusters and degrees, some structure emerged. We found a few pockets of the code that, surprisingly, had little or no incoming dependencies — meaning other parts of the system didn’t really know about them, even if they invoked others. These were our leaf nodes in the dependency graph (nodes with in-degree zero, if you fancy graphs). For example, a little utility for email notifications was surprisingly self-contained; nothing critical depended on its internals. Aha! That could be a candidate to split out early, or at least to isolate more.

We also identified the opposite: the super-connected god classes that everything depended on. Those would be trouble. If we ever wanted to split out, say, the “Orders” domain, but we had a class OrderManager that was called from everywhere, we’d have to untangle that first. At least now we knew which classes were spidering into multiple domains. This guided our refactoring: we either had to break those classes apart by responsibility or introduce clearer APIs to hide their tentacles.

It’s worth noting that today’s tech gives us some new superpowers here. We even toyed with the idea of feeding chunks of our code into an AI just to get a summary of what it’s doing or how pieces connect. Large language models (LLMs) are getting crazy context windows (hundreds of thousands of tokens, even millions in some cutting-edge cases), meaning in theory an AI could ingest your whole codebase and answer questions about it. We weren’t quite at the point of trusting an AI to do our architecture homework, but it’s a space to watch. We did use simpler AI-assisted tools to, say, generate dependency lists or UML diagrams from code automatically. Anything to help visualize the beast is worth a shot.

The output of all this analysis was a better mental (and physical) map of the monolith. We had diagrams of how data flowed, which components were tightly coupled, and where the logical domain boundaries should be. For instance, we realized that “Products” and “Orders” were conceptually separate domains, but our code had them interwoven in the same classes. That was a flag: we should separate that logic inside the monolith first, before even thinking of separate services.

Modularize Before You Microservice

Armed with our newfound understanding, we set out to modularize the monolith. The idea was to transform the codebase into a collection of well-defined modules or layers that could eventually stand on their own as services. Think of it as training wheels for microservices: each module in the monolith would be a mini-service in terms of boundaries, just not deployed separately (yet).

We started by enforcing clearer layer separation. Presentation logic (HTTP controllers, CLI commands, whatever was interfacing with the outside) had to be cleanly separated from business logic, which in turn had to be decoupled from data access. In a perfect world, we’d already done that from day one, but as I mentioned, reality was messy. So we went through and refactored bits that violated the layering. If a UI class was directly querying the database, we introduced a proper service class in between. If business logic was scattered in UI classes, we pulled it down into the domain layer. This was painstaking work, but necessary. Without well-defined layers, any attempt to pull out a microservice would drag along stray pieces of UI or database code into places they didn’t belong.

Next, we tackled those “god classes” and cross-cutting concerns. For example, we had an ApplicationContext singleton (left over from early design) that basically everyone and their mother was using to get configuration, log in users, you name it. It was a huge source of coupling. We spent time carving that up: configuration management went into a smaller Config module, user session stuff went into Auth, etc. We gradually taught parts of the code to stop talking to the giant singleton and talk to smaller, domain-specific modules instead.

During this phase, we also revisited the domain model with the business experts. Over years of rushed changes, some business rules in the code had become outdated or contradictory. We found cases where two different modules enforced slightly different validations on, say, an Order, because at different times different product managers had different ideas. By sitting down with current domain experts, we clarified what the rules should be in today’s product. In some cases, we were able to delete a bunch of code entirely because the feature it supported was either deprecated or simplified by new requirements. Each bit of simplification made the codebase cleaner and our eventual microservice slice-out easier.

One concept we borrowed from Domain-Driven Design was the idea of bounded contexts. We tried to align our emerging modules with distinct business contexts: Billing, Inventory, Search, Recommendations, etc. Where before there was one nebulous blob, we started to see the outlines of multiple coherent mini-systems inside the monolith. We even introduced something akin to an anti-corruption layer at a few critical boundaries: for instance, the new Payments module didn’t directly call the old User module to get user data (which was a spaghetti call that did a million things). Instead, we made a little translation layer or adapter. It would fetch what it needed in a controlled way, shielding the Payments logic from all the legacy junk in the User module. This way, when we eventually pull Payments out into its own service, we won’t have to drag along all of User’s baggage or vice versa. The idea was to prevent the concepts from one domain leaking into another, even if in code they still lived side by side for now.

Perhaps the biggest effort here was getting the monolith to a state where you could disable one piece and the rest would still run. That’s a great litmus test for modularity. For example, could we stub out the entire Notifications module and run the app without it (minus the notification features)? Initially no way — all sorts of things would NPE or blow up. But we refactored toward that goal. Introduced clear interfaces, made sure other parts of the system handled a missing module gracefully, and so on. Essentially, we were preparing each module to be able to live independently. This phase took a ton of careful refactoring, loads of regression testing, and yes, many late nights. It’s not as sexy as spinning up Kubernetes pods or writing new code, but it’s the foundation that makes the next part possible.

Pulling Threads Without Unraveling the Sweater

With a more modular monolith in hand, we finally reached the moment of truth: extracting the first microservice. The key here was incremental evolution. We weren’t about to do a Big Bang where one day we flip a switch and have 10 microservices. Instead, we planned a step-by-step strangler patter — like a vine slowly growing around the old tree, replacing it bit by bit.

Remember those leaf nodes and self-contained modules we discovered? Those make excellent first candidates. In our case, a couple of modules (let’s call one “NotificationService” and another “Reporting”) had clean boundaries by now and minimal dependencies from other parts. We started with “Notifications.” Because we had already encapsulated it pretty well, extracting it was relatively painless (relatively!). We spun up a new service project, copied over the Notification module code, and gave it a proper external API. Then we changed the monolith: instead of calling the internal notification classes, it would call the new service’s API (initially via a library call or HTTP — whatever made sense as a first step).

Did everything work perfectly on first try? Ha, no. We hit plenty of snags: missing pieces that we hadn’t fully decoupled, subtle bugs where the new service behaved slightly differently. But since we were doing this for a non-critical module, it was low risk. We fixed the issues one by one. Eventually, the monolith was happily delegating notification duties to the Notification microservice. We had our first piece of the monolith strangled off and running on its own.

The next targets were chosen with care. One by one, we pulled out modules, always starting with ones that had the fewest tentacles back into the monolith. This strategy of removing the files with the fewest dependencies first paid off. It meant each extraction was a smaller, more manageable pull request rather than one giant upheaval. After extracting something, we’d pause, monitor, ensure no performance hits or new failures, and then move on to the next. Each service we peeled off gave us more confidence (and frankly, more motivation — because this process is marathon-level exhausting).

I won’t sugarcoat it: even with a modular monolith, each extraction required touching a lot of surrounding code. One team that went through a similar exercise noted that each file they moved out required edits to about 15 other files on average, and our experience was in the same ballpark. There’s a ton of find-replace, fixing imports, shuffling data schemas, and whatnot. We wrote automation scripts whenever possible (for example, to update import paths, or to stub out calls to the new service). Still, code reviews for these changes were massive and hairy. We learned to minimize the pain by doing things in very small bites: removing one class at a time, or one function at a time, if needed, just to keep diffs readable.

A crucial part of this phase was also setting up proper integration points. We often started by having the monolith call the new service in-process (like via a library call or function) to keep things simple, then later switched that to an HTTP call or messaging once the new service stabilized. This way, we didn’t immediately introduce network complexity until we were sure the functionality was solid on its own. In other cases, we ran the new service in parallel with the old code for a bit (dual running), just to verify it produces the same results. Feature flags and toggles were our friends: we could route a small percentage of requests to the new service and the rest to the monolith as a safety net, gradually increasing as our confidence grew.

Throughout it all, we were mindful of not creating a distributed mess. If any new service started needing too much from the monolith (like constantly querying data from an untouched part of the monolith), we pumped the brakes. That signaled we hadn’t drawn the boundary correctly or hadn’t moved enough logic over. In such cases, we went back, refactored some more, or considered splitting a different way. The goal was that once a service was extracted, it owned its piece of the puzzle entirely, with minimal back-and-forth to the old monolith. If we ever found ourselves effectively making a call from Service A to Service B to Service C to accomplish one user request, we knew we were on the path to the dreaded distributed big ball of mud again. Better to adjust boundaries now than suffer later. As Ben Morris put it, a bunch of haphazardly-connected microservices can be just as impossible to understand as a monolith. We kept that warning taped to our monitors (figuratively).

Bringing Everyone on Board

Let’s switch gears for a moment: all this technical heavy lifting wouldn’t have been possible without getting buy-in from the powers that be. Refactoring a monolith and extracting microservices is expensive — in time, in focus, in sheer mental energy. During this period, our team’s velocity on new features was almost zero. We were basically in an “internal investment” mode, paying down years of tech debt. Convincing product managers and executives to let us do this was an exercise in storytelling and trust.

We made the case that without this work, we’d soon be dead in the water. Engineers were spending more time fixing bugs and fighting fires in the monolith than building new stuff. We showed some metrics: how our compile times had increased, how incident frequency was creeping up, how each new feature seemed to take longer than the last because of the code complexity. We didn’t just harp on the negatives; we painted the picture of the positive future too. In a modular, microservices world, teams could own features end-to-end without stepping on each other’s toes. We could scale parts of the system independently (right now, scaling meant duplicating the whole monolith, which was expensive). Deployments would be less risky — no more whole-system deploys late on Friday night, dreading that any small error takes everything down.

In short, we sold it as an investment that would pay off in faster delivery and greater stability. But we also promised to do it in stages and show results at each stage (which we did — after the first service came out, we demonstrated how that team was now deploying independently twice a week, whereas before we deployed the monolith once every two weeks). Those wins helped maintain buy-in to continue the journey.

It was equally important to get the whole team on board. A project like this can be demoralizing if folks don’t see the endgame. Some newer engineers were understandably frustrated: they joined to build cool features, and here we had them combing through legacy code and drawing diagrams for weeks on end. So we made it a learning experience. We rotated people through different parts of the code so they could all broaden their understanding of the system. We held knowledge-sharing sessions — kind of mini architecture guild meetings — where we discussed why we were doing certain refactors and what the end state would look like. This not only spread knowledge (so we wouldn’t end up with only one person understanding a newly refactored module), but it also rekindled some excitement. Developers started to own the transformation, not just follow it.

We also had to coordinate carefully with QA and ops. Breaking things apart can introduce bugs in sneaky ways. Our QA team was incredible — they built regression suites for each domain we were extracting, to ensure the new services didn’t inadvertently change behavior. Ops helped set up the new pipelines, containers, feature flag systems, you name it, to support a hybrid monolith+microservice architecture during the transition. Everyone had a part to play.

And you know what? Bit by bit, the plan started working. Morale actually improved when engineers saw that their refactoring was making a difference — build times dropped, the code felt cleaner, incidents went down. It’s addictive to work on a codebase that’s improving rather than degrading for a change. That energy became self-reinforcing.

Conclusion

Decomposing a gnarly monolith isn’t the glamorous stuff of keynotes and tech blogs that brag “We moved to 100 microservices and it was awesome.” It’s more like archaeology mixed with heart surgery: you’re digging through layers of history, trying not to break the beating heart of your system as you gradually remodel it. The crucial lesson we learned is that microservices are the reward, not the first step. First you need to get your monolith in shape — establish boundaries, clean up the entropy, and make it a modular monolith. That’s an achievement on its own, regardless of whether you even proceed to microservices.

In our case, that modularization phase was where the real magic (and sweat, and tears) happened. It gave us the confidence and clarity to then apply all those well-known patterns for microservice migration (strangler figs, event streams, database splitting, you name it) with much less drama. By the time we officially pulled the plug on the last piece of the monolith, it almost felt anti-climactic — because we had essentially been living in a modular world already.

If you’re staring at a big ball of mud and dreaming of microservices, my advice is to fall in love with the boring stuff first. Map out your codebase, talk to the old timers (or read old commit messages like detective novels), refactor mercilessly to enforce structure, and chip away one piece at a time. Yes, it’s tedious. Yes, it’s time-consuming. And no, your product team won’t throw you a parade for refactoring (though they’ll definitely notice more bugs getting caught and faster releases down the line). But this is the way. The payoff is a system that’s easier to work on, easier to understand, and ready for whatever new scale or features the business throws at it.

In the end, we didn’t just decompose a monolith; we evolved our architecture and our team. We turned chaos into order, and then we turned that order into microservices. It’s the long road, but it’s the right road. When you finally arrive, you’ll know that your new services rest on solid ground, not on the shaky foundation of a leftover mud-ball. And trust me, that feeling of a codebase that makes sense again — where you can add a feature without summoning the demons of unintended side-effects — is absolutely worth the journey.

Discussion about this episode

User's avatar

Ready for more?