Get in touch
Gabs
Blog

How to move to a modular monolith without a rewrite

A tangled codebase can become versioned modules without a rewrite: cut import cycles in place first. How SharpOS got to 53.

Gabriel EspinheiraSenior Full-Stack Engineer
  • Published
  • 10 minReading time
  • EngineeringCategory

Listen to this post· AI-narrated

0:00 / 12:13

By late August 2026, SharpOS was knotted. 27 of its backend domains sat in one strongly connected component of imports, and 31 of its web feature folders sat in another, so each feature could reach almost every other, and none of them could be tested or versioned on its own. On 04/09/2026 it shipped as a modular monolith: 53 versioned modules on the same deployment. I didn't rewrite anything, and I didn't move a single folder into a package until its part of the knot was cut.

I'm Gabriel Espinheira, a senior full-stack engineer. SharpOS is the client platform my web studio, SharpHaw, runs on: a TypeScript monorepo with a Next.js web app, macOS and iOS apps, and one Convex backend, which is a hosted database with server functions. I've built it since March 2026 as its only engineer, directing coding agents. This is how the modular monolith migration went: the measurement, the options I turned down, the order that worked, and what it cost.

TL;DR: To turn a tangled codebase into a modular monolith without a rewrite, measure the import cycles, cut them in place in small revertable pull requests, and move code into packages last. SharpOS cut 137 cycle-closing imports to zero and shipped 53 versioned modules on one deployment, and CI keeps the cycles out.

Why not split a tangled monolith into microservices?

Microservices were the first thing on the table, and the runtime ruled them out. SharpOS runs on one Convex deployment, with one schema, one auth system and one transactional database. Two versions of a feature can't run side by side there, and a feature can't own its own datastore.

The first architecture decision record (ADR), ADR-0001, says it plainly: "Naming them microservices would set an expectation of independent deploy and independent rollback that the runtime cannot honour." So the goal became modules that are owned, versioned and tested on their own, and deployed together. That buys a version and a changelog per package, CI that tests only the packages a change affects, and a quick answer to "what breaks if I touch this?" It doesn't buy independent deploys. Anyone who needs those needs a second deployment, and that's a different decision.

If your features already share one database and one deploy, the same argument applies to you. Most products don't need a microservice. Mine needed boundaries a build tool can check, and a modular monolith gives those without a network hop between features that share a database anyway.

How do you measure how tangled a codebase is?

Count the cycles before you plan anything. On 27/08/2026 I had a script build the import graph twice: backend files collapsed to their Convex domain, web files collapsed to their feature folder. Tarjan's algorithm then finds the strongly connected components, the groups of nodes that can all reach each other.

Of 41 backend domains, 27 formed one component. Of 45 web feature folders, 31 formed another. But the knot was thin. Most mutual pairs were lopsided, with one strong direction and a single stray import going back:

PairImports one wayImports back
customers ↔ properties (web)241
boards ↔ properties (web)201
hub ↔ support (web)221
customers ↔ lib (backend)181

Those minority-direction imports are the back edges. Across both graphs there were 137 of them in 76 files, 54 in the backend and 83 in the web app. The plan's definition of done was a check that finds no component larger than one node in either graph, rerun after every cut.

That number changed the plan. 137 imports is a cut list, and a cut list can be worked one pull request at a time, by one engineer or split across a team.

Why move folders into packages last?

Packaging first is the obvious order, and it fails. Turborepo refuses a cyclic package graph with "Invalid package dependency graph: cyclic dependency detected". If you move folders into packages while the imports still loop, you hit that error around the second or third feature, with half the tree already moved and nothing you can cleanly revert. ADR-0002 made the rule: no feature becomes a package until a check proves its part of the graph acyclic.

I also turned down fewer, coarser packages. The obvious merge was the documents group: pages, editor, content and properties, which had a reputation as the most tangled cluster. Measured, the tangle between them was small: 6 cuts between editor and pages, 4 between content and pages, 1 between pages and properties. Merging the four would have saved 11 of the 137 cuts, 8%, and put that coupling inside a package boundary where nothing measures it. ADR-0003 settled on one package per feature, about 26 of them.

How do you cut import cycles without a rewrite?

You cut them in place, in the tree you already have, as small pull requests that each ship and revert on their own. When I sorted the cut list by what each import pointed at, 135 of the 137 fell into two groups (ADR-0005).

The mechanical half was 74 imports covered by 13 grouped extractions. Route builders such as board-routes and customer-routes moved into a shared contracts package, which alone broke about ten web pairs. Direct imports of the automations event module from seven features became a backend event bus. A pure string function, search_normalize, moved down a level and broke four pairs by itself. This half was worth shipping whatever came next, because it removed the worst hubs and made the graph readable.

The long tail was 61 imports across 55 targets, and 49 of those targets appeared exactly once. Each one was a judgement call about where a single import belongs. In this plan the tail only paid off if Turborepo would accept the package graph, so that half waited on a spike: a short experiment that answers one question before you commit.

The question was narrow. The backend already deployed 36 imports from workspace packages, but all of them were plain helpers. Nobody had checked whether a backend function defined inside a package would still register at its old API path. Answering it took about a day. ADR-0006 wrote down the fallback before the spike ran: if it failed, the migration stopped after the mechanical half, the tree kept its shape, and nothing was wasted. It passed, so the tail ran, and each feature moved into its own package once its part of the graph was free of cycles.

What does the modular monolith look like now?

SharpOS has shipped as 53 versioned modules in four tiers since v3.0.0 on 04/09/2026: core (13), shared (11), features (26) and gateway (3). They deploy together on one Convex deployment. The dependency rule fits in one line:

core  ->  shared  ->  features  ->  gateway  ->  apps

A tier may import anything to its left and nothing to its right. CI checks the cycles today, not the direction, and I come back to that below. By rule, features don't reach into each other: a feature that needs another's data takes a port, one that renders another's UI takes a prop or a slot, and anything two features both need moves left. Nothing imports an app. The backend folder is the one place that wires it all together. It mounts every package's functions at unchanged API paths, and its schema imports each table from the package that owns it, so it's the only place that sees the whole data model.

The SharpOS case study has the module map, drawn from the same report CI reads.

What did the migration cost?

In calendar time it ran from the plan on 27/08/2026 to v3.0.0 on 04/09/2026, and the last stray files moved by 08/09/2026. In code, v2.5.5 to v3.0.0 took 329 commits and changed 6,129 files, at least 2,438 of them renames. Git's rename detection hit its file limit, so the real figure is higher. 299 of those commits landed in the week of 31/08/2026. Counting the cleanup after v3.0.0 that same week, it had 358 commits. That's the busiest week the repository has had.

Every cut was a small pull request that could ship or revert on its own, and a move only moved, so a regression had one small diff to hide in. I directed coding agents through it, and 67 of the 329 commits carry a co-author line for Claude, Anthropic's coding agent. My job was the cut list, the order, and turning down anything that changed behaviour inside a move.

The cost that stays is tooling. The module report is a 3,019-line script that assigns every file in the repo to exactly one module, and somebody has to maintain it. Today that's me.

How do you stop a modular monolith from tangling again?

Two checks in CI hold the boundaries.

The first is a unit test that reads every workspace's imports. It fails if a package imports a workspace it doesn't declare, declares one it doesn't import, or closes a cycle in the declared graph. The second is a ratchet, a limit that may only go down. The report counts files a module owns that still live outside its package, and the ratchet fails the build if that count goes above a baseline:

/**
 * How many files a module still owns outside its package and could move into
 * it. [...]
 * **It may only ever be lowered.**
 */
export const OUTSTANDING_BASELINE = 0

The baseline has been 0 since 08/09/2026. I ran both today, 15/10/2026. The acyclic test passed, and the ratchet printed modules ratchet: 0 outstanding, baseline 0.

One gap is still open. Tier direction and feature-to-feature imports aren't machine-checked yet. A rightward import that doesn't close a cycle passes CI today, and most feature packages still declare another feature as a dependency. A scheduled agent review keeps a ledger of those edges until I decide which ones are legitimate. I'd rather say that than claim the rule is enforced.

Boundaries matter more when agents write the code. An agent changing one feature package has that package and its tests to read, and a change that reaches into another package has to declare the dependency, so it shows up in the diff. That's the same reason I write the failing test before the agent writes the code, and why agents use the same API I do.

A checklist for untangling your own codebase

You don't need Convex or Turborepo for this. You need an import graph and the patience to cut it in order.

  1. Build the import graph at the level you want modules at: collapse each file path to its feature folder, then find the strongly connected components with Tarjan's algorithm. The open-source tool madge --circular lists file-level cycles if you want a quick first look.
  2. For every mutual pair, count imports in each direction. The minority direction is your cut list.
  3. Group the cut list by target. In SharpOS, shared route builders, an event bus and small pure helpers covered about half of it.
  4. Ship the grouped cuts first, in place, one small pull request each.
  5. Find the one technical question that could stop the migration and spike it before the long tail. Write the fallback down first.
  6. Move a feature into a package only when its part of the graph is acyclic.
  7. Add a cycle check and a ratchet to CI, with a baseline that may only go down.

Write each decision as a short record before you act on it. Mine have four fields, status, date, context and decision. Seven ADRs carried this migration: five on the architecture and two on where each feature's docs live. Seven weeks later, they're why I can tell you what each number was.

If your team is stuck in a codebase like this and wants a second pair of hands on the cut list, get in touch.

More writing

All posts