Architecture is a graph problem. Draw the modules as boxes and imports as arrows. The shape tells you the story.
A clean architecture looks like a layered stack or a hub-and-spoke. Dependencies point downward or inward. Each module knows about its neighbours and nothing else. New developers can point to a layer and say "this is where X lives".
A broken architecture looks like a plate of spaghetti. Every module imports from every other module. Add a feature and you touch twelve files. Change one interface and eight places break. Run a dependency graph tool and the image is a solid mass with no visible structure.
The difference is measurable.
Symptoms that tell you architecture is failing
Not opinions. Symptoms.
Change coupling. Two files that are frequently changed together are coupled, whether you intended it or not. Git history reveals this. If src/auth/login.ts and src/billing/subscription.ts get touched in the same PR in 40% of commits, you have a concern: either they should share an abstraction, or they are wrongly intertwined.
Cross-layer imports. A UI component imports directly from the database layer. A worker imports from the HTTP router. Layers are a fiction at that point.
Circular dependencies. Module A imports from B, B from C, C back to A. The cycle breaks unit testing (you cannot mock one side without the others) and guarantees that any change to any cycle member risks affecting all of them.
Deep nesting. Five-level conditionals inside a 400-line function. The function is doing many jobs. The "architecture" inside the file is bad even if the module graph looks fine.
Rising file count in one directory. A directory with 40 files is probably doing too many things. A directory with four is probably a coherent module. Directories do not need a hard limit, but drift past 20 and the grouping is usually worth reconsidering.
Where to start
The honest answer: not with a rewrite. Rewrites rarely finish and almost never land cleanly.
A better starting point:
- Generate a dependency graph of the current state. Tools like madge (JavaScript), pydeps (Python) and jdeps (Java) produce this quickly. Read it. Find the module with the most incoming edges, the most outgoing edges, and the cycles. Those are your leverage points.
- Pick one leverage point. Usually a module at the centre of the coupling. Not the most broken place (too scary) or the least broken (not worth it). The one where a focused improvement removes a lot of downstream cruft.
- Extract the shared contract. If modules A, B and C all import from D, and D is doing five jobs, split D into D-auth, D-billing, D-shared. Move callers to the right dependency.
- Rewrite imports only. Not logic. Logic changes create too many moving parts. Pure import refactoring is cheap and low-risk.
- Write a migration note in the docs. Not an architectural diagram. One paragraph explaining why the structure changed. The next engineer needs context.
Enforceable rules
Once the graph is cleaner, the temptation to add a wrong import returns. Without enforcement, you will be back where you started in six months.
Layer boundaries expressed as lint rules. ESLint's no-restricted-imports, dependency-cruiser, or Python's import-linter express "no module in ui/ may import from db/" as a config file. CI fails on violation.
Circular dependency checks in CI. Fail the build when a new cycle appears. Existing cycles stay until explicitly removed.
File size limits. Soft at 300 lines, hard at 500. Does not mean 301-line files are bad; it means each one earns a second look in review.
Module size limits. A directory should not grow past 20 direct files without a review of whether it needs splitting.
These rules are not guard rails against good engineers. They are guard rails against the fact that every team ships under time pressure, and most regressions happen when nobody has capacity to think about architecture in a single PR.
What incremental improvement looks like
Improvement is slow and compounds. A month of discipline produces:
- One less cycle.
- Two modules with cleaner boundaries.
- Three files split from 800-liners into 300-liners.
- A dependency graph a new engineer can read without a tour.
Over a year, the same codebase is qualitatively different. Nobody did a rewrite. Nobody rewrote anything over a weekend.
The mindset
Architecture work is the least visible thing an engineering team does. It does not ship features. It does not fix bugs users reported. The payoff arrives as the absence of problems, which is hard to celebrate.
Make it routine anyway. Every PR should leave the architecture slightly better or the same. Never worse. That is the rule.