Everyone has seen the branch that starts as "just cleaning things up" and turns into a private alternate universe. Three weeks later it contains 20,000 changed lines, half the team has stopped understanding it, and the merge request gets the most dangerous review comment in software: "looks good to me".

The harder version is a live business system. Not a toy service. Not a side project. A core e-commerce gateway, full of partner integrations, awkward abstractions, seasonal traffic spikes, and enough production history to make every engineer slightly careful when touching it.

Start with the pain

The useful starting point is not "we need microservices" or "we need DDD" or "we need clean architecture everywhere". The useful starting point is much more boring: what hurts?

In a large monolith, the honest pain usually shows up in a few places:

  • The code that changes most often
  • The module everyone avoids
  • The model that makes simple changes feel weirdly risky
  • The integration path breaks under seasonal load
  • The module name that makes new people squint because it describes the implementation history rather than the business

You can find some of this with tools. Git history is brutally useful here. If a file changes all the time and is also complex, it is probably not just "important". It is probably carrying too much. That combination of change frequency and complexity is a nice way to find the spots where refactoring might pay rent.

But tools only get you so far. The better signal often comes from team conversations. Engineers know where the floor creaks. Product people know which flows keep changing. Support and operations know which parts of the system become visible at the worst possible time.

Before drawing boxes, get those pains into the open. If only one person is annoyed, maybe it is preference. If the whole team keeps losing time in the same place, now you have something worth changing.

A modular monolith keeps mistakes cheap

The fashionable answer to a tangled monolith used to be simple: carve it into microservices.

There are real reasons to do that. Failure isolation can matter. Independent scaling can matter. Deployment independence can matter. Team autonomy can matter. But those benefits come with a bill: distributed data, operational overhead, deployment choreography, observability, network contracts, and the painful discovery that the boundary you chose in March is exactly the wrong boundary in October.

A modular monolith gives you a cheaper place to learn:

  • You can create explicit modules inside one repository and one deployable unit
  • You can move code across boundaries when your first guess is wrong
  • You can rename, split, merge, and reshape modules without coordinating infrastructure, service ownership, and database contracts every time
  • You can still keep the runtime simple while making the codebase more honest

That matters because your first boundaries will probably be wrong.

Maybe "orders" looks like one context at first, then later turns out to contain several separate responsibilities. Maybe stock handling is clean and stable, while order processing grows tentacles. Maybe the business language changes after a few months of real work.

Do not disappear into a refactoring branch

The most useful pattern is also the least glamorous one: refactor as part of normal work. When a team is already touching a feature, bug fix, or operational improvement, add a little room for nearby cleanup. Not a secret rewrite, not a purity quest. Just enough space to leave the touched area better than it was:

  • Move one behavior into the new module
  • Untangle one dependency
  • Introduce one stable contract
  • Delete one awkward path after the feature flag proves the replacement works

This changes the social shape of the work. Refactoring is no longer a mysterious side project that competes with product delivery. It becomes part of how the team delivers safely. The trick is consistency - a little refactoring every day looks unimpressive on a single pull request.

There is a useful psychological detail here: make the progress visible. Create a new namespace or directory for the improved code and then keep the old code clearly separate. A "greenfield inside legacy" sounds cheesy, but it helps. Engineers can see that the new shape is not a theory - it is becoming the place where work happens.

Keep rollback boring

If the problem is mostly in the application model, resist the urge to fix the database schema at the same time. You can split concepts in code while still adapting them to the old tables at the edge. It may not be perfectly elegant. That is fine. The point is to make one kind of change at a time.

That style gives you a plan B. If the new code path misbehaves, you can switch back. If the module boundary turns out to be awkward, you can move it. If the business experiment dies, you can remove it without excavating the whole platform.

Feature flags are the obvious companion. In a multi-tenant system, you can enable the new path for small tenants first, then watch real traffic before expanding. If your team has the luxury of deploying when most users are asleep, use it. If not, use whatever rollout unit your system gives you: tenants, accounts, regions, percentage traffic, background workers, or internal users. The important part is not the exact mechanism. The important part is that production rollout is gradual, observable, and reversible.

Enforce boundaries with tools, not hope

Code review helps, but humans are inconsistent. Someone will be tired. Someone will be new, or someone from another team will make a "quick contribution" and accidentally wire module A straight into module B because it was the shortest path through the IDE.

For example in PHP projects, Deptrac is a common option. Other ecosystems have their own tools. The specific tool matters less than the two axes you enforce.

First, enforce the vertical structure inside a module when that structure is useful. If a module uses application, domain, and infrastructure layers, define which layer can depend on which other layer.

Second, and often more importantly, enforce the horizontal structure between modules. Which module can call which module? Can new code depend on legacy code? Can legacy code call into the new module? Do contexts communicate directly, or only through a shared contract area?

That second axis is where many teams get the most value. A class in the wrong layer is annoying, also a business concept in the wrong module can hurt for years.

Baselines are useful too. Sometimes reality wins and you accept a temporary violation, so put it on the list and make the list visible. Treat it as debt, not a new design principle.

Shared kernels need adult supervision

A shared kernel can be a clean place for stable contracts between modules. It can also become the junk drawer of the system. The difference is discipline.

If everything mildly convenient lands in the shared area, the modular monolith quietly turns back into a big ball of mud with nicer folder names. The shared code should be boring, small, and watched carefully. Public interfaces, commands, events, simple shared concepts. Not half the business.

If the shared kernel starts growing faster than the actual modules, stop and ask what it is hiding. Usually it means the boundaries are wrong, the contracts are too concrete, or the team is avoiding a harder modeling conversation.

Clean architecture is not a default setting

One of the easy mistakes in modularization is applying the same internal architecture to every module. Some modules deserve a rich domain model. Some deserve clean separation between application logic, domain rules, and infrastructure. Some are complex enough that the ceremony buys clarity and testability.

Other modules are mostly CRUD. Or integration glue, or a thin workflow around a stable data shape. Forcing those modules through the same layered architecture can create what people sometimes call lasagna code: data passed through layer after layer because the structure demands it, not because the problem does.

That is not design. That is filing.

A modular monolith does not need every module to look identical inside. It needs each module to have an appropriate internal shape and a clear external contract. Spend more energy deciding which building a concept belongs in than which floor it occupies.

Do not finish work that no longer hurts

There is a quiet trap in refactoring: once you start improving the system, finishing everything begins to feel morally correct. It usually is not.

Stable legacy code that earns money and rarely changes may not be worth moving. If it is isolated, understood well enough, and not blocking the team, leave it alone. The goal is not to delete every old file. The goal is to reduce risk, make change cheaper, and keep the business moving.

That takes restraint. Engineers like tidy endings. Systems rarely give us those. A half-refactored monolith can be a success if the painful half got better and the remaining legacy is quiet.

Make the expensive decision last

The best part of a modular monolith is that it delays the irreversible part. You can learn the business boundaries in code before turning them into infrastructure. You can test communication styles before committing to service boundaries. You can discover which modules need isolation and which only needed better names, better contracts, and less accidental coupling.

Then, when a real reason appears, extracting a module into a service is no longer a leap. It is a smaller move from a boundary the team already understands.

That is the real lesson: refactoring a live monolith is not about bravery. It is about making the next step small enough that you can take it during normal work, observe it in production, and still sleep afterward.

Happy modularizing!