← all posts

Ratcheting architecture rules in a modular monolith

How I keep module boundaries honest in a large .NET and React codebase without a big-bang cleanup: a generated module manifest, an import and cycle checker in CI, a recorded baseline of existing violations, and a build that fails only on new ones.

Every modular monolith starts with clean boundaries and a diagram. A year later, a page in one feature imports a private hook from another, a backend service reaches into a different domain’s namespace, and the diagram describes a system that no longer exists. Nobody decided that. It happened one convenient import at a time.

Nibras, the legal platform I lead, is a .NET 10 API and a React app in one repository, around 206,000 lines of application code (the architecture overview has the bigger picture). When I decided to enforce module boundaries, the code already had plenty of violations. Fixing them all first would have meant weeks of refactoring with no feature work. Enforcing nothing meant the count would keep growing.

A ratchet sits between the two: record the violations that exist today, fail the build on any new one, and let the recorded list only shrink.

In short:

First, put every file in a domain

A boundary checker needs to know which module a file belongs to. If the answer is “it depends on the folder name, and the folders are called Legal, Admin and Common”, the checker has nothing to work with.

So the first step was structural, and it came before any rule. On one day, the API’s catch-all feature folders were split into one folder per domain: access, auth, documents, finance, legal knowledge, Lexi, matters, OCR, organization, parties, plans, reference data, tenancy and so on. That commit renamed 276 files and changed no content: every route, permission and table stayed exactly the same. The same day, the web app got the same treatment (306 files, import paths only), and a structure check went into CI. Nine days later, the C# namespaces were renamed to match the new folders.

Group API folders by domain276 files moved, no content changeSame for the web app306 files, import paths onlyStructure check in CIevery feature file sits in a known domainRename namespaces to matchfolder and namespace now agreeRatchet the cross-domain rulesexisting debt recorded, new debt fails
fig. 1 / the regrouping, in order. each step changed one thing, so each was easy to review and to revert.

Two rules from that move are worth stealing:

The structure check itself is strict and has no baseline. Every file under the backend features folder or the web features folder must sit inside a domain folder the registry knows. A loose file at the root, or a folder nobody registered, fails the build immediately. It also needs nothing generated, so a pull request never has to regenerate anything just to pass it.

A registry, and a generated manifest

The registry is a small hand-written JSON file. It lists the domains (twenty of them, each with a one-line description), a set of path prefixes that map files to domains (longest prefix wins, and anything unmatched falls back to a default), and each domain’s public surface: the files other domains are allowed to import.

From that, a generator builds the module manifest, which is committed to the repository. It walks every tracked file in the API, the web app and the shared contracts package, assigns each one an owner, marks it public or private, and pulls out what each domain touches: the permission strings it uses, the tables it maps (from the EF Core context and from migrations), the routes it owns in the shared route contract, and which files are tests. The result is a review map: for any change, you can see which domain owns it and what else it touches.

The manifest is checked for freshness on every build. Its file list must equal the files actually in the repository, every owner must be a known domain, and no route id or table name may appear twice. After adding, moving or deleting files, routes, tests or tables, you regenerate it and commit it with the change.

What the checker looks for

The checker is one Node script with no dependencies. It uses the file list from git and regular expressions, not a compiler, which keeps it fast enough to run on every push. It reports five kinds of finding:

  1. Frontend private imports. It resolves real import targets (alias paths, relative paths, index files) and flags any feature file that imports another feature’s file, unless the target is that feature’s index or a path listed as public in the registry.
  2. Backend cross-domain dependencies. Because namespaces now match domain folders, a using of another domain’s namespace in a C# file is a finding. Project-wide global usings are skipped on purpose; migrations are excluded.
  3. Cycles. It builds a domain-level graph from the frontend’s feature imports, runs Tarjan’s strongly connected components algorithm, and reports every component with more than one domain.
  4. Naming. C# files must be PascalCase; React component files must be PascalCase or index, with tests and the design-system primitives exempt.
  5. Code owners. Every path in the code-owners file must exist, so review rules cannot silently point at nothing.

Each finding gets a stable, path-based id, such as the kind of finding followed by the source file and the target domain. Stable ids are what make the ratchet possible.

The ratchet

The baseline is a committed JSON list of finding ids that existed when the rules were introduced. The check compares the current findings with it.

recorded baselinefindings in this commitBaseline onlyfixed since recordedprints a messageIn bothknown, toleratedpasses, for nowCurrent onlya new violationCI fails
fig. 2 / the ratchet compares two sets of finding ids. only the right-hand region fails the build.
// Simplified: only findings that are not in the recorded baseline fail the build.
const baseline = new Set(JSON.parse(readFileSync(baselinePath, "utf8")).findings);
const current = [
  ...privateCrossModuleImports(),
  ...backendCrossDomainUsings(),
  ...domainCycles(),
  ...namingFindings(),
];

const added = current.filter((id) => !baseline.has(id));
const fixed = [...baseline].filter((id) => !current.includes(id));

for (const id of added) console.error(`NEW ${id}`);
if (fixed.length > 0) console.log(`Ratchet: ${fixed.length} baseline violations removed`);
process.exit(added.length > 0 ? 1 : 0);

Three properties make this work in practice:

Because ids are path-based, moving a file creates a new id, which the check reports as new. That is intended: a move is exactly the moment to fix the import rather than carry it to a new address. A --module flag limits failures to findings that touch one domain, which makes it practical to clean one area at a time.

Where it runs

A governance job runs on every push and pull request, before any build: configuration hygiene first (settings files may hold no secrets or connection strings), then the structure check, then the full architecture check. Skipped jobs elsewhere in the pipeline still let this one run, so the rules apply even to a change that touches only documentation.

The same ratchet idea shows up in other places in the codebase:

Deleting is the cheapest refactor

The fastest way to remove a boundary violation is to delete the code that causes it. Two days after the regrouping, a cleanup pass removed about 25,000 lines of dead code, 20 web packages, 3 database tables, an old OCR runtime, and settings and scripts nothing referenced any more. It was done as a series of small “remove unused” commits (unused endpoints, unused UI primitives, unused API client functions and hooks, unused models and columns, dead tables), each easy to review on its own.

There was no automated dead-code tool in the loop. Each removal was found and checked by reading, and the standing rule in the engineering standards is plain: delete dead code rather than commenting it out, because git remembers. Tests that only exercised removed code went with it, while tests for firm isolation, permissions, money and security stayed.

Deleting first also changes the ratchet’s arithmetic. Every import from a dead file is a finding that no longer has to be fixed by hand.

What I would do the same, and differently

  1. Move files before you write rules. A checker can only enforce boundaries that exist in the folder structure. Regrouping first, as pure renames, made every later step smaller.
  2. Never baseline the structure check. Debt in imports can be paid down gradually. A file in an unknown folder is a new hole in the map, and should fail immediately.
  3. Use stable ids, not counts. A count can stay flat while one violation is fixed and another is added. A set of ids cannot.
  4. Treat the baseline like code. It changes in its own reviewed commit, and every exception carries a reason.
  5. Keep the checker boring. A dependency-free script with regular expressions over git’s file list is easy to read, fast on every push, and nobody is afraid to change it.

If you are trying to stop a monolith from turning back into a ball of mud, I’m happy to compare notes. You can reach me through my site.