TL;DR. Uncle Bob released uml-viewer, a clickable architecture diagram that draws Dependency Rule violations in red and is meant to be driven together with a coding agent. It only parses Clojure, so I wrote a 200-line script that turns my Rust crate into its input file. The first picture showed 167 red arrows and 35 dependency cycles. Six commits and 315 files later: 109 red arrows, 3 cycles, and
app.rsdown from 1,087 lines to 642. No behavior changed. The tool matters less than the loop it forces on you: look, point at one arrow, let the agent move code, look again. The original post has the same text with a collapsible FAQ and the source list at the end.
Uncle Bob's argument for the tool is short: agents still need supervision, and reading every line they write is the bottleneck. So supervise the structure instead of the text: draw the system, find the shape that is wrong, tell the agent to fix that shape, and check the new drawing.
I wanted to know if that works on a real codebase, so I pointed it at Uptimepage, about 146,000 lines of Rust in one crate. It does, with one condition: the picture is only as honest as the input file you feed it.
Five of the 167 red arrows and two of the 35 cycles, and where the code went. Every arrow that pointed up now points down.
What uml-viewer is
uml-viewer is an open-source desktop tool by Uncle Bob (Robert Martin) that draws a codebase as a UML-like diagram you can click. It is written in Clojure and needs the Clojure CLI and Java 21 or newer.
Namespaces are components. The modules inside a namespace are the component's elements. Nesting can go as deep as your source tree does. You double-click a component to open the next level, double-click a module to see its class card, and click a function to open the source file at that line.
Color comes from CRAP and mutation scores, so a red box is code with high complexity and weak tests, or code with no metrics at all, which the README counts as the worst grade. Red arrows come from the Dependency Rule: you tell the tool which namespaces sit at which architectural level, and every dependency that points from an inner level to an outer level is drawn red.
The tool is built to run next to an agent. By default it opens a tmux window with Grok in the examined project and the two talk through a small mailbox directory. You can also drive it by hand from any agent session: regenerate the input file, press R in the viewer, and the diagram reloads.
Getting a Rust crate into it
The only parser it ships reads Clojure. That sounded like the end of the experiment, until I read what the viewer actually consumes. It never sees source code. It reads one EDN file: a list of classes with a namespace and a level, a list of edges with a from, a to and a kind, and a list of levels. That is a format any script can emit.
So I asked the agent to write a generator for Rust. It came out at about 200 lines of Python and does four things:
- Every
.rsfile is one class.mod.rsstands for its directory andlib.rsis skipped. - Every
use crate::…,super::…andself::…path, plus every inlinecrate::a::bpath in a function body, becomes a dependency edge to the nearest enclosing module that exists as a file. - Comments are stripped first, so a path mentioned in a doc comment does not count as a dependency.
- A hand-written
LEVELSlist gives each top-level module a rank.
The last item is the only opinion in the script:
# inner (high level) first, matching the Dependency Rule ranks
LEVELS = [
["domain", "error", "text", "metric_names"],
["storage", "security", "net", "http_client", "config",
"quotas", "observability", "pagination"],
["pipeline", "escalation", "notifier", "email", "telegram",
"whatsapp", "billing", "auth", "analytics",
"jobs", "http_outbound", "targets"],
["api", "web", "mcp", "marketing", "agent", "worker", "scheduler",
"request", "public_status", "templates", "oauth",
"ad_hoc_dispatch", "channels"],
["app", "router", "bootstrap", "main", "bin"],
]
Rank 0 is vocabulary that everyone may use: domain types, error codes, text helpers. Rank 1 is infrastructure: storage, security primitives, config. Rank 2 is the services that do the work: probing, escalation, notification, billing. Rank 3 is every way into the system: HTTP handlers, HTML views, the MCP server, the marketing site. Rank 4 is assembly: the app state, the router, main.
The rule is then mechanical. An edge is a violation when the module it comes from has a smaller rank than the module it points to. Same rank is allowed. Foreign crates like axum and sqlx are ovals outside the diagram and are never compared.
The picture is only as honest as the levels list
The generator does not decide the architecture. I do, in that list. If you put
apiin the same group asdomain, the diagram turns green and you have learned nothing. Write the list you want to be true, then let the red arrows show how far the code is from it.
What the first picture showed
The first diagram had 167 red arrows, 35 pairs of modules that imported each other, 17 modules importing api, and an app.rs of 1,087 lines.
Behind the numbers were shapes I half knew about and had never seen drawn:
-
storagereached up intoapifor error codes and dashboard read models, and intoworkerfor heartbeat state. -
apiandwebimported each other. The JSON side took session and token extractors, cookies and client IP from the HTML side, and the HTML side took the heartbeat read model from the JSON handlers. -
quotasread accounts and organizations fromstorage, whilestorageembedded quota SQL fragments and read plan types fromquotas. A cycle in both directions. -
marketingandoauthimportedwebfor three template filters. -
configimportedauthandbillingfor two enums.
None of this was visible in the review of any single pull request. Every one of those imports was reasonable on the day it was written. A diff shows one import at a time, so the sum of them never appeared in any review.
The loop
Every round had the same six steps:
- Regenerate the EDN file and reload the viewer.
- Pick one red arrow. Hover it to see which module pairs it bundles.
- Tell the agent one thing: what must not import what, and where the shared piece should live.
- The agent moves the code and runs the compiler and the tests.
- Regenerate. Check that the arrow is gone and count the new ones.
- Run the full suite, review the diff, commit.
The instruction in step 3 is the part that makes this work. It looks like this:
storage must not import api. The error codes and the dashboard read models
it takes from there are crate-wide vocabulary. Move the codes to error::codes
and the read models to domain::metrics, and point every reader at the new
path. No behavior changes.
That is one rule, one violation, and one place to put the result. The agent does not have to guess what "cleaner" means. It has a named arrow to remove, and the next diagram says whether it did.
I ran this with Claude Code, but nothing in the loop depends on it. Codex, Cursor, Grok, or any agent that can edit files and run a test suite gets the same instruction and the same picture afterwards.
Six rounds took the crate from 167 red arrows to 109:
| Round | Red arrows I pointed at | Where the code went |
|---|---|---|
| 1 |
storage into api and worker, config into auth
|
error codes to error::codes, 12 read models to domain::metrics, provider enums to domain::credential
|
| 2 |
storage into auth and api
|
token hashing, HMAC and SHA-256 helpers to security, redaction scrubbers to security::redaction
|
| 3 |
api and web into each other |
extractors, cookies, client IP and host resolution to a new request module that imports neither |
| 4 |
storage and quotas into each other |
SQL fragments to storage::count_sql, plan types to domain::quota
|
| 5 |
marketing, oauth and api into web
|
template filters and formatters to templates, app.rs split into config::boot, observability::readiness, targets::status and net
|
| 6 |
public_status and web into api, security into http_client
|
page envelopes to pagination, Cipher::from_config into security
|
And the totals:
Six commits, 315 files, same test suite before and after. Violations down 35 percent, cycles down 91 percent.
| Before | After | |
|---|---|---|
| Dependency Rule violations | 167 | 109 |
| Module pairs importing each other | 35 | 3 |
Modules importing api
|
17 | 4 |
Lines in app.rs
|
1,087 | 642 |
| Files touched | 315 | |
| Behavior changes | 0 |
I regenerated the graph at every one of the six commits to see what each round removed:
Cycles and api importers per round. The first two rounds did most of the work on the red count; rounds three to six were about the cycles.
The shape surprised me. The red arrow count stopped moving after round two. Rounds three to six barely touched it, but they took the cycles from 23 down to 3, because a cycle between two modules at the same level is not a Dependency Rule violation at all. The rule catches arrows that point up and says nothing about two modules on the same level that import each other, so you need both counts.
Not every move survived. In a later round the agent moved the health-check paths into observability, and a coupling test on the marketing module said no, because that module is only allowed to reach a short list of leaf modules. Another move put the strict JSON parser under request, then had to come back because that parser reads the OpenAPI document. Both reverts took minutes, because the diagram and the test said so before the commit did.
Why I stopped at 91
After the six commits, 109 red arrows were left. Most pointed into app, but ten did not: a sampler and a silence job that lived under observability but reached into the scheduler and the notifier, two background jobs reading public_status, the HTTP metrics layer, and the rate-limit middleware in quotas reading the app state. That middleware was also the third of the three cycles. One more round moved each of those into the module that owns it and left 91 red arrows and 2 cycles.
Every one of the 91 is the same shape. Handlers read the app state, and the app state is built from the modules those handlers live in. app imports api and web to mount them; api and web import app to get the state.
Fixing it is possible. Split the state into per-handler sub-states and hand each handler only its slice. I counted what that would touch: about 90 files of plumbing that make the code harder to read, not easier. I stopped because I could name every remaining arrow, and at that point the diagram had told me everything it was going to.
Stop when every arrow has a name
Drive the count down until every arrow that is left has a name and a reason, then stop. The 91 arrows left in my crate are all "this handler reads the app state", and a diagram that shows them is more honest than one that hides them behind 90 files of indirection.
Keeping it fixed
Two things stop the graph from drifting back.
The marketing module has a test that is an allow-list: the exact set of leaf modules it may import. A new reach into the app fails the build. It used to be a deny-list, which only catches the mistakes you already thought of.
The other is a habit, not a test. Regressions arrive with feature commits, not with refactors. After every push I regenerate the graph and read the list of red arrows that do not point into app. If the list is empty, the feature stayed in its layer. If it is not, the offending edge is one instruction away from gone, before the next feature builds on it.
Why this beats "refactor the codebase"
Two months ago I wrote about mapping this codebase for humans and AI agents. The lesson then was that a model is good at shape and bad at numbers, so I had to check every count it produced by hand.
This is the same lesson, applied to refactoring. Here the agent never counts anything; the generator does. The agent gets one rule with one violation and a picture that says pass or fail after every change. It is the same reason a failing test is a better instruction than a paragraph of requirements: the acceptance criterion exists before the work starts, and it is not the agent that judges it.
What I did not get is the color. CRAP and mutation scores come from Uncle Bob's Clojure tooling, and there is no Rust equivalent wired in yet. The README is clear that a box with no metrics is painted as the worst grade, so the fill on my boxes means nothing until coverage and mutation numbers exist for Rust. The arrows alone were worth the setup.
Do it yourself
- Install the Clojure CLI and Java 21 or newer, then clone uml-viewer.
- Write a generator for your language. One class per module, one dependency edge per import, output as EDN with
:hierarchical true. The generatedexamples/uml-viewer.ednin the repo is a complete example of the format. In Rust, imports areusepaths; in TypeScript they areimportstatements; in Python,importandfrom. A regex over each file is enough to start. - Write the
:levelslist by hand, inner layer first. Describe the architecture you want; the red arrows will show where the code differs. - Run the viewer against your file. On a fresh start it waits for its companion agent; the
--restartflag restores the last view without spawning one, which is what you want when you drive it from your own agent session. - Pick one red arrow. Write one instruction that names the two modules and the new home. Regenerate. Repeat.
Key takeaways
- uml-viewer reads a plain EDN file, not source code. A 200-line script gets any language in.
- The levels list is the only opinion in the input, so write the architecture you want and let the red show the distance.
- Give the agent one rule, one violation and one destination per instruction. The next diagram is the acceptance test.
- Rust will not tell you two modules import each other. Build the graph and count the mutual pairs yourself.
- Stop when every remaining red arrow has a name. Mine are all "handler reads app state", and that is fine.
- Turn the result into an allow-list test, and regenerate the graph after every feature push, because that is when regressions arrive.
The six commits are public in the Uptimepage repository if you want to read what moved and why.
Sources
- Robert C. Martin, uml-viewer on GitHub. The README documents the EDN format, the
:levelsrule and the companion agent mailbox. - Robert C. Martin, The Clean Architecture, The Clean Code Blog, August 2012. The Dependency Rule.
- Uptimepage, source repository, AGPL. Commits
bda59342throughcee12c44are the six rounds described here.
I build Uptimepage because I wanted uptime checks that say why something failed instead of "transport error": HTTP with per-phase timing, TLS and domain expiry, ping and TCP, heartbeats for background jobs, and browser flows, from several regions. It is AGPL-3.0 open source, which is why the six commits above are public and you can check every number in this post against them.
Now the question for the comments: what draws the module graph for your language, and does it show the cycles or only the layer violations? My rounds three to six only moved the cycle count, and I would have missed that with a tool that colors arrows but never counts pairs.



Top comments (0)