How Zügel keeps agent-written code inside your intended architecture — and helps you dig out of the debt you already have.
This article covers the version 26.3.4 (released on Aug 11th 2026) of Zügel.
AI coding agents have changed the economics of writing code. They have not changed the economics of structure. An agent will happily add the fifteenth dependency from your persistence layer back into your UI, import an internal class from a subsystem that was never meant to expose it, or close a dependency cycle that fuses two modules into one inseparable blob — all while the tests stay green. Architecture erosion used to happen at human speed. Now it happens at machine speed. Zügel – the German word for rein – is designed to stop that from happening. It puts you back in control and directs the coding agent to write architecturally and structurally sound code.
The traditional answer — periodic architecture reviews, static-analysis gates in CI — catches the damage after it is written. That is too late for agent workflows: by the time CI complains, the agent has already built three features on top of the illegal dependency.
Zügel moves the check to the moment that matters: before the agent writes the dependency. It is an MCP (Model Context Protocol) server that any MCP-capable agent — Claude Code, or anything else that speaks the protocol — connects to like any other tool provider. Under the hood it:
- parses your sources itself (Java only Today with more languages coming soon),
- maintains architecture rules based on Sonargraph’s architecture DSL (optional),
- computes violations and dependency cycles from the resolved model,
- tracks every change against baselines, so progress and regressions are visible per edit, and
- answers precise dependency questions the agent would otherwise (badly) approximate with grep.
Zügel is a companion to Sonargraph, and can be used independently from it. Using it together with Sonargraph will still provide extra benefits like CI integration, dependency visualization and a powerful environment to design architecture rules based on the Sonargraph architecture DSL.
Setup in 5 Minutes
The server is a single shaded jar, and there is exactly one file you have to write yourself: .mcp.json, which tells your agent how to launch the server (shown here for Claude Code):
{
"mcpServers": {
"zugel": {
"command": "java",
"args": [
"-jar",
".mcp/zugel-launcher.jar",
"<your-activation-code>",
"--project_root", "relative/path/to/your/project"
]
}
}
}
That assumes that you downloaded the launcher from www.hello2morrow.com and stored it in the .mcp directory. If you commit the launcher and .mcp.json to you version control repository, every developer of your project will benefit from Zügel.
The single positional argument is your license activation code (or a path to a license file) — always required. Everything else is an optional named flag:
--project_root <dir>— pass it explicitly. It is tempting to omit it and let the server fall back to its working directory, but a stdio MCP server does not inherit the project directory: it inherits the agent’s working directory, which is often somewhere else entirely (a home directory, for instance), and thecwdfield some clients accept in this file is ignored. Giving the root as an argument is the only dependable way.--license_server_url <url>— only for an on-prem license server; defaults tohttps://www.hello2morrow.com, so most users never set it.--config_dir <dir>— see below.
Please note, that Zügel requires Java 21 or higher. It also must have the same or a higher version compared to the Java version used by the project. In other words, Zügel cannot analyze a Java 25 project when it runs on a Java 21 runtime.
Now start your agent. The server also needs a project configuration — zugel.json, in the project root by default — but you usually don’t write that one: if it is missing, the server starts in bootstrap mode, looks at the project root for a build it recognizes, tells the agent what it found, and offers to generate the configuration itself.
If your environment does not allow new files at the project root (some security policies don’t), add --config_dir <dir> — e.g. "--config_dir", "config/mcp". The configuration file and the .baselines/ directory then live in that subdirectory instead, and every relative path inside the configuration resolves against it; generate_config writes the paths accordingly. Build-system detection is unaffected — your pom.xml / Gradle files are always read from the project root, never the config directory — so bootstrap and generate_config behave exactly the same. Everything else works unchanged.
Three cases:
Case 1 — Maven. The agent calls the generate_config tool. The server walks your pom tree for the module structure (names, source roots, generated-source roots, inter-module dependencies) and asks Maven itself for each module’s resolved classpath — only the build tool can resolve versions, dependency management, and profiles correctly. Sibling modules become dependsOn entries so internal code resolves to source, and jars are written home-relative (~/.m2/...), so the generated file works on every developer’s machine and can be committed. The server then initializes in place; all tools work immediately, no restart.
Case 2 — Gradle. Same single tool call, different machinery: because a Gradle build is a program only Gradle can evaluate, the server injects a small init script via --init-script — your build files are never touched — and lets Gradle report every JVM project’s source roots, project dependencies, and resolved external classpath (without building a single subproject). Progress streams live while Gradle configures, and the result is the same portable, committable configuration. This is the path we validated on the gradle/gradle build itself: 214 modules, one tool call.
In both cases the invocation runs your project’s own wrapper (./mvnw, ./gradlew). If it fails — the classic cause is that the server process lacks your shell environment, say a pinned JDK — the error hands the agent the exact command to run in a real terminal, and a second generate_config call picks up the result. And when your build structure changes later (modules added or removed, dependencies bumped), just run generate_config again: it regenerates only the machine-derived project section, preserves everything you wrote by hand, and reports which modules appeared or disappeared.
One thing is worth setting before you go far, because it decides what everything else measures: a build reactor is not the same thing as an architecture. Real projects carry modules that have code but no design intent worth checking — documentation builds, samples, tooling, vendored third-party sources — and a generator faithfully writes every one of them into your configuration. moduleFilter is where you record which modules actually are the system:
"moduleFilter": {
"includes": ["com.example.**"],
"excludes": ["com.example.docs"]
}
Both lists are optional, and an absent or empty includes means everything the build reports. Two lists rather than one, because naming what belongs is usually shorter than naming what does not: a large reactor buries test and sample modules deep in the tree under names with nothing in common, and a single include — com.example.** — states the rule your project already follows without naming any of them. Excludes are checked first, so an exclude always beats an include. Patterns use the same wildcard syntax as the .arc files (** for anything, * within one dot-separated segment), anchored to the whole module name.
It sits outside the project section deliberately, and that is what makes it survive: deleting a module from project.modules by hand does not, because the next generate_config rebuilds that section from your build files and puts it straight back. The filter shapes what a generator writes rather than how the file is read, so an edit takes effect on the next generate_config — which also accepts includeModules / excludeModules arguments if you would rather ask the agent to adjust it for you. Do treat it as a trade and not a free action: a module you leave out is not parsed, holds no components, and appears in no cycle or violation, so other modules’ references to it stop resolving as internal code.
Case 3 — anything else. No Maven, no Gradle, or a setup too exotic for extraction: write zugel.json by hand. If your project is already imported into Sonargraph, you can use File / Export Zügel Configuration... to generate a skeleton configuration file where you only need to add the class path details for your modules. This is what the file looks like — and also what the generators produce, since it is the same file:
{
"serverVersion": "latest",
"language": "java",
"project": {
"javaRelease": "21",
"modules": [
{
"name": "MyApp",
"moduleRoot": ".",
"sourceRoots": [
"src/main/java"
],
"generatedSourceRoots": [
"target/generated-sources"
],
"classpath": [
"~/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.17.2/jackson-databind-2.17.2.jar",
"~/.m2/repository/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar"
]
}
]
},
"arcFiles": [
"architecture/MyApp.arc"
],
"cycles": {
"componentCycleTolerance": 3,
"packageCycleTolerance": 0
}
}
serverVersion, arcFiles and cycles are controlled by you in all cases — the generators never touch them. Without any .arc files the server runs in cycles-only mode: no rules yet, but the full dependency model, cycle detection, and metrics are live from the first minute — many teams start exactly there and add rules once they’ve seen the cycle report.
serverVersion defaults to "latest" if not present. If you do not want automatic updates of the server you can pin a version like "26.3.4" or "26.x". In the later case the launcher would never update to "27.x" or higher. The job of the launcher is to keep Zügel up-to-date. It checks for updates on a regular base and automatically downloads newer versions, which will then be activated on the next start of the launcher. On the first start it will just download the latest version and then request a restart. The background download is designed to be secure, it checks checksums and the code signature of the downloaded jar files. The launcher also supports mirroring with the --launcher.repository_url=<your mirror url> parameter. If it is missing downloads are coming from https://eclipse.hello2morrow.com/jenkins/architectureMcp/
Once configured and downloaded, the server parses the sources (about 20 seconds for 10,000 files, cached for ~2-second warm starts after that), compiles any .arc rules, pins a default baseline, and announces its tools.
That’s it. No build integration, no CI changes, no annotations in your code.
The Key Concepts
Five ideas carry the whole system. They are worth two minutes each.
1. Components and filter names
The unit of architecture is the component — one source file, identified by a Sonargraph filter name: <module>/<package-path>/<file-name-without-extension>, for example MyApp/com/acme/shop/service/OrderService. External types are External/..., e.g. External/java/sql/Connection. Every tool speaks this vocabulary; tools that take a component also accept a plain Java FQN and resolve it for you. There are no file-system paths in the model — the identifiers work the same whether the code lives in one module or twenty.
2. Artifacts, interfaces, connectors
An .arc file partitions the components into named artifacts using include/exclude patterns, and declares which artifact may use which. Access always flows through a connector on the consumer side into an interface on the provider side. Every artifact has a default connector and a default interface, and a small set of modifiers (public, hidden, local, unrestricted, …) shapes them. Everything not explicitly allowed is a violation.
You do not need to memorize the DSL — that is the point of the explain_architecture_dsl tool, which serves the agent a complete language reference (semantics, modifiers, and a table translating spoken design intent into DSL constructs). More on that below.
3. Violations
A violation is one concrete illegal dependency: this component, in this file, on these source lines, breaks that rule. Violations are computed by the server from real compiler bindings — no false hits from comments or string literals, no missed usages via star imports. list_violations returns the current queue; every rescan reports exactly which violations your latest edits added or resolved.
4. Cycles and the cyclicity metric
Dependency cycles are the most damaging form of structural debt: a cycle fuses its members into one unit that cannot be understood, tested, or released in isolation — and cycles grow silently. The server detects cycle groups at two granularities (component and package) and scores them with a single number, the cyclicity: the sum of n² over all flagged cycle groups. A 10-node cycle scores 100; split it into two 5-node cycles and the score drops to 50 — the metric rewards partial progress, which makes it ideal as a ratchet: it must only ever go down.
Small cycles can be tolerated by policy, and cycles formed entirely by generated code are excused automatically — there is nothing you can do about those except change the code generator. Same for violations that originate in generated code.
Some cycles are even deliberate. The classic case is an ORM domain model: bidirectional JPA/Hibernate associations (Order holds its OrderLines, each OrderLine references its Order) make entity cycles a design decision, not debt. Carve them out with a tolerance rule so the metric tracks only actionable problems:
{
"cycles": {
"componentCycleTolerance": 3,
"packageCycleTolerance": 0,
"tolerated": [
{
"include": [
"MyApp/com/acme/shop/domain/entity/**"
]
}
]
}
}
A cycle is excused only when every member matches the include — which makes the carve-out self-guarding: the moment a service class gets tangled into the entity cycle, one member fails the include and the whole cycle flips back to flagged. And tolerance hides nothing: excused cycles stay inspectable (list_cycles with includeTolerated), and a newly created cycle — even one small enough to be excused — still shows up in every rescan diff. Tolerance excuses known cycles; it never makes new ones invisible.
5. Baselines and the ratchet
On the first scan the server pins a default baseline — a snapshot of violations and cycles. Every rescan then reports a diff with two views: sinceLastRescan (what your latest edits changed — the fix-loop view) and sinceSessionBaseline (net direction since the anchor point). The default baseline is automatic; the interesting ones are the named baselines you create yourself at moments that matter — more on those in use case 6.
Cycles first, architecture second — the adoption ladder
The tools form a deliberate hierarchy, and you do not have to use them all at once.
Rung one costs nothing: cycle detection needs no architecture at all. The moment generate_config has run, the server knows every dependency and every cycle in your codebase — no .arc file, no design meetings, no modeling session. That is cycles-only mode, and it is not a demo mode; it is where most codebases should start.
Here is why that capability is the foundation for everything else: a well-structured code base is the precondition for having an architecture at all. A layered design is, at bottom, a promise that dependencies flow in one direction — and a cycle is precisely a set of components for which no such direction exists. So for a codebase to be well-structured cyclic dependencies have to be avoided as much as possible. You cannot assign 92 mutually-entangled files to clean layers; they form a big blob that cannot be further divided into separate architectural components. Every cycle the agent avoids or breaks under RULE 2 (see below) doesn’t just tidy the code — it preserves your option to define an architecture later. A team that only ever uses cycles-only mode still gets the single most valuable guarantee: their codebase stays architecturable.
Rung two is the .arc file — and now the results get sharper. Acyclicity says dependencies flow in one direction; the rules say in which direction, between which parts, through which interfaces. That upgrade activates the whole second half of the toolbox: check_proposed_dependency verdicts before code is written, the violation queue, reachability enumeration, intent verification. And the two rungs reinforce each other — violation edges are the first candidates analyze_cycle proposes to cut, so the rules make even the cycle-breaking smarter.
The practical path is exactly the one from use case 5: run cycles-only until the metrics are under control, then let the agent turn an architecture conversation into a first .arc file — which is a far easier conversation to have over an acyclic codebase.
Three Rules For The Agent
When an agent connects, the server’s greeting is not a tool list — it is an operating contract. Three rules, stated bluntly, because agents (like humans) revert to habit under pressure:
RULE 1 — A defined architecture is BINDING. Treat a violation like a failing test: a signal to fix the code, never an obstacle to route around. Before writing a dependency, call check_proposed_dependency; on a denial, find a legal target or escalate to the human. And never, ever make a violation disappear by editing the .arc rules to permit it — that is deleting the alarm rather than fixing the fault. Only the user may relax their own architecture.
RULE 2 — Cyclicity only ever goes DOWN. After any change, the rescan diff must show the cyclicity metrics held or fell. A rising value is a regression to back out, not to leave behind.
RULE 3 — Never grep for a dependency. “Who uses X”, “what breaks if I change X”, “why does A depend on B” — these questions go to query_dependencies and trace_dependency, which walk real compiler bindings and do reverse and transitive lookups grep simply cannot do. Grep is for comments and configuration, not for dependencies.
We added Rule 3 after watching an agent — with all these tools loaded — reach for grep anyway. Old habits die hard, even artificial ones.
Use Case 1: Guardrails While The Agent Codes
This is the bread-and-butter loop. The agent is implementing a feature and is about to make OrderService use InvoiceRenderer. Before writing the import:
check_proposed_dependency
from: MyApp/com/acme/shop/service/OrderService
to: MyApp/com/acme/shop/billing/internal/InvoiceRenderer
The server evaluates the edge against every loaded .arc file and returns a verdict: ALLOWED, UNCONSTRAINED (no rule has an opinion), or a specific denial — DENIED_BY_RULES, DENIED_NO_ROUTE, or a deprecation denial. On a denial the agent doesn’t negotiate; it asks list_reachable_components for the from side:
Given this component, list every component it may legally depend on.
That returns the legal substitute targets — maybe billing‘s public InvoiceApi instead of its internals. The agent uses the legal target, the feature ships, the architecture holds.
After edits, the agent calls rescan_sources. The result includes the change diff, and every added dependency carries an isViolation flag — so a new illegal edge is caught even if the agent forgot to pre-check. addedViolations must be empty; new cycles show up as cyclicity deltas. The contract is checkable after every single edit, not once per pull request.
Use Case 2: Ask The Model, Not The Text
Even outside enforcement, the resolved dependency model answers questions that otherwise cost an agent dozens of file reads:
- “Who uses X?” →
query_dependencies(X, incoming)— exact, including transitive closure if asked. - “Who subclasses or implements X?” → incoming with kind filter
[EXTENDS, IMPLEMENTS]. - “What breaks if I change X?” → incoming, transitive.
- “Why on earth does A depend on B?” →
trace_dependency(A, B)returns the shortest concrete dependency path — the chain of components and the edges (with source lines) along it. For an unexpected coupling this is the “aha” tool: you see the exact three hops that connect two things that should have nothing to do with each other, and each hop is flagged if it is itself a violation.
The answers come with a completeness marker, so the agent knows when a result is provably complete rather than best-effort. This is the difference between facts about the dependency graph and guesses about text.
Use Case 3: Working Down The Violation Queue
For a codebase with existing debt, the fixing workflow is deliberately simple:
list_violationsonce at the start of the session — the full queue, each entry with from/to components, the broken rule’s.arcfile, the verdict, the dependency kinds (CALLS, EXTENDS, …), and the source lines.- For each violation:
list_reachable_componentsfrom the from component to find a legal replacement target,describe_artifactto understand the shape of the rules around it. - Fix,
rescan_sources, watch the diff: the violation moves toresolvedViolations, nothing appears inaddedViolations, cyclicity holds. - Repeat until
list_violationsreturns empty.
Because every step is verified by the server, this workflow is safe to delegate to an agent wholesale: “work down the violation queue” is a well-defined, self-checking task.
Use case 4: Untangling cycles — move first, then cut
Breaking a big dependency cycle by hand is genuinely hard: which of the hundred edges do you cut? But the first question is whether you need to cut anything at all.
Sometimes the file is just in the wrong package
A surprising share of package cycles are not coupling problems. They are a file sitting in the wrong package, with the dependencies pointing perfectly sensibly in every other respect. suggest_relocations finds those and names the file to move — no dependency broken, no interface introduced, just a relocation and its import updates.
It works because of an invariant worth stating plainly: moving a file re-labels a node in the dependency graph and changes no edge. Component cyclicity therefore cannot move, whatever you relocate. Only the package view changes. That also bounds what moves can achieve — if a component cycle straddles two packages, those packages are mutually dependent under every possible assignment, and no amount of relocation will separate them. The tool says so, with the verdict CUTS_REQUIRED and a node to hand straight to analyze_cycle.
Every proposal is pre-checked against four rules, and the reply reports how many candidates each one rejected:
- it introduces no architecture violation — the candidate is evaluated against your
.arcrules at its prospective path, including its dependencies on external libraries; - it creates or enlarges no package cycle anywhere in the project — not just in the cycle being fixed;
- it overwrites no existing file, and never crosses a module boundary;
- it never empties its source package. Package cyclicity counts packages, so it can always be lowered by merging them — the degenerate optimum is one package for the whole project. A move that removes a label instead of decoupling anything is not a fix.
One warning the tool gives about itself: the suggested destination is a hint. Destinations are chosen by graph topology, so some are semantically wrong — it will cheerfully offer to move an AbstractTestTask into a filter package. What the analysis identifies reliably is which file welds the packages together. Judge the destination yourself, or ask.
When you do have to cut — analyze_cycle
Point it at any node of a cycle group and it returns one step:
- Violations first. If the cycle group contains edges that are also architecture violations, removing those is always the first recommendation — one fix serves two goals. If that alone improves cyclicity by 20% or more, the plan stops there.
- For a component cycle that spans several packages — and most big ones do — the cycle is condensed to its package quotient and that is solved instead. One node per package, one edge per package relation. A 92-component tangle across 16 packages is far past the reach of an exact solver; its 16-node quotient is not. The step comes back as a handful of package relations to remove rather than a list of unrelated edges — which is to say, the layering your packages nearly have, and something you can write into an
.arcfile. Each relation names the concrete component edges and source lines beneath it, so it stays actionable. - Two ways to take that step, and the tool measures rather than assumes: shear the package cycle at its cheapest seam (
SPLIT_PACKAGES) or remove the exact minimum set that leaves the package graph acyclic (BREAK_PACKAGES), a complete layering paid for at once. The seam wins only when it removes clearly more cyclicity per site; below a handful of packages the full break already is the small step. Whichever loses is returned alongside the winner, fully costed, so you can overrule the default. - Exact minimum cut for a small group that lies inside one package, and a spectral split for a large one — the original component-level machinery, now the second tier rather than the first.
ballOfMud: true is the honest warning: this step is real work and still will not finish the job — more than fifty dependency sites to apply, with a residue left behind. Either alone is fine; an expensive step that reaches zero solved its group, and a cheap step leaving a residue is just the next small step. It is the combination that says plan for a session, and probably ask the user first.
Each proposed cut lists the concrete component-to-component dependencies with their source lines, so the agent can go break them — typically by introducing an interface, moving a class, or inverting a dependency. Then rescan, watch the cyclicity fall, call again for the next step. Stepwise, measurable, ratcheted.
Use Case 5: Architecture By Conversation
The newest capability turns the direction around: instead of checking code against rules, the agent helps you write the rules.
The intended workflow is a conversation:
You: “The shop has a web layer, a service layer, and persistence. Web talks to services, services to persistence. The domain model is shared by everything. Oh, and JDBC should only ever be used from persistence.”
Agent: (calls
explain_architecture_dsl, receives the full DSL reference, and translates:)
artifact Web
{
include "Shop/com/acme/shop/web/**"
connect to Services
}
artifact Services
{
include "Shop/com/acme/shop/service/**"
connect to Persistence
}
artifact Persistence
{
include "Shop/com/acme/shop/persistence/**"
connect to Jdbc
}// shared: public artifacts go LAST, below their consumers
public artifact Model
{
include "Shop/com/acme/shop/model/**"
}// external classes: assigning them constrains who may use them
artifact Jdbc
{
include "**/java/sql/**"
}
The agent wires the file into zugel.json, calls reload_all, and reports what the rules found: “Two violations — web/CartController uses java.sql.ResultSet directly on lines 88 and 104.” Now you are having exactly the conversation you should be having: is that code debt to fix, or did we forget a legitimate rule?
The explain_architecture_dsl reference is what makes this reliable. The .arc DSL’s semantics are not guessable from syntax — sibling order is meaningful, public grants access only to siblings above it, nested artifacts share their parent’s connections unless marked local, the default interface excludes hidden nested artifacts. The reference encodes all of it, including a translation table from spoken intent (“implementation detail”, “shared by everything”, “only persistence may use JDBC”) to DSL constructs — and it ships inside the server jar, so it is always in sync with the engine that enforces it.
Two rules from the reference deserve highlighting because they embody the philosophy:
- “Read violations as information, not noise… never silently widen a rule to make a violation disappear. The architecture belongs to the user; you translate it, you do not weaken it.”
- “Verify intent, not just absence of violations” — a model that allows everything also has zero violations. The agent is instructed to use
check_proposed_dependencyto confirm that dependencies you want forbidden actually are.
Use Case 6: Keeping Score With Baselines
Architecture work is a long game, and long games need scoreboards. The default baseline (pinned automatically on the first scan) keeps the everyday ratchet honest — but the real power is in named baselines you create at moments that matter. Two workflows cover most of what teams need:
First, a word on ergonomics: you never call these tools yourself. Baselines are managed in plain language — you say what you want, the agent picks the tool. The whole lifecycle is conversational:
“We’re starting the payment feature — snapshot the architecture first.” → the agent calls
create_baseline("feature-payment-api").“Which baselines do we have?” →
list_baselines— every saved name, and which one is currently active.“Compare against the state before the billing refactoring again.” →
switch_baseline("before-extract-billing")— the ratchet now measures against that anchor.“The branch is merged, clean up its baseline.” →
remove_baseline("feature-payment-api").
One thing to be clear about: a baseline is a measuring stick, not a restore point. Switching to an old baseline changes what the diffs compare against — it does not (and cannot) change any code. Your git history restores code; baselines answer “how does today compare to that moment?”
Every feature branch gets a baseline. The habit: branch, then tell the agent to snapshot. From that moment, sinceSessionBaseline in every rescan is the branch’s net architectural footprint — not the noise of individual edits, but the sum: which violations the branch would merge, which components it pulled into cycles, how the cyclicity moved. Intermediate churn cancels out; a violation introduced on Tuesday and fixed on Thursday never shows. Before the merge, ask “what would this branch do to the architecture?” — the agent reads that one diff and answers the question code review rarely asks. If the answer is “no new violations, cyclicity flat”, merge with confidence.
Every major refactoring gets one too — as proof of progress. Before extracting the billing subsystem: “baseline this as before-extract-billing.” A refactoring that runs over days produces dozens of intermediate states, some of which legitimately look worse mid-flight; the named anchor keeps the goal measurable while sinceLastRescan guards each individual step. When the diff finally reads “12 violations resolved, cyclicity 340 → 80”, the refactoring has a receipt — numbers for the team, not vibes. Weeks later, one sentence — “compare against before-extract-billing” — brings the anchor back for a retrospective.
Three properties make this cheap enough to be habitual: baselines persist on disk (a branch abandoned for two weeks resumes its ratchet where it stopped — and a returning agent finds it via list_baselines), creating one is a single sentence the agent can even do unprompted at branch start, and the diff views come free with every rescan the agent runs anyway.
We Eat Our Own Dog Food
Zügel develops itself under its own supervision — and its own .arc file defines the architecture of the analyzer, the DSL engine, and the parser.
That loop has already paid for itself several times over. The server caught a package cycle between its own analyzer and analyzer/tools packages, introduced during a refactor (cyclicity 4 — fixed by extracting a shared enum, verified back to 0 by the tool itself). It surfaced the need for generated-code handling when its own parser-generator output formed cycles no one can fix. And in the most satisfying episode, a discrepancy — our server reported 62 violations where Sonargraph showed zero — exposed a genuine engine bug in how nested artifacts inherited their ancestors’ interfaces. The bug was fixed, and the fixture that now guards it was mutation-tested: we verified the tests fail against both plausible-but-wrong implementations, not just pass against the right one.
There is no better test of an architecture tool than making it police its own architecture, with the vendor’s flagship product as the referee.
Testing it on Gradle
A 275-file dogfood proves correctness; it doesn’t prove scale. So we aimed the server at a large well known project: Gradle v9.5.0. No configuration existed — the whole run started from the bootstrap greeting.
Two tool calls later:
generate_config injected its init script into Gradle (no build file touched), let Gradle evaluate its own 200+-project build, and wrote the configuration: 214 modules, 4,212 external classpath entries — every single one home-relative and machine-portable, 2,689 inter-project dependsOn edges. Test harnesses and documentation projects without Java sources were skipped, each with a named warning. The server then initialized in place and parsed 10,148 Java files. End to end, greeting to queryable model: about six minutes, only 15 seconds of that were used by Zügel for parsing the code. The remaining time was needed for the initial build performed by Gradle.
analyze_cycle then went through the five biggest tangles the cycle report found — one call per cycle, one proposed first step each:
| Cycle (module) | Size | Verdict | One-step improvement | Price of the step |
|---|---|---|---|---|
| dependency-management | 92 | shear the package cycle | 72% (8,464 → 2,378) | 3 relations / 12 sites |
| core | 59 | full package layering | 85% (3,481 → 514) | 12 relations / 34 sites |
| core-api | 55 | shear the package cycle | 46% (3,025 → 1,620) | 3 relations / 3 sites |
| model-core | 36 | ball of mud | 92% (1,296 → 98) | 140 sites |
| file-collections | 29 | full package layering | 59% (841 → 349) | 1 relation / 19 sites |
Four of the five are attacked through their package quotient, and they are cheap. dependency-management — the resolution engine, 92 files across 16 packages — gives up 72% of its tangle for three package relations and twelve edit sites. core-api gives up 46% for three sites. core is the tidiest result of all: 34 sites buys a complete package layering, package cyclicity to zero, and every one of the six residual cycles then sits inside a single package. That is what an acyclic package graph guarantees, and it is the difference between “here are 91 edges, good luck” and “here are twelve relations; your packages nearly form a hierarchy already”.
Read the residues before celebrating, though. dependency-management‘s cheap step leaves 43 components in a cycle across seven packages and 23 in a cycle across three — still multi-package tangles, to be condensed again on the next call. This is still a big improvement from a 16 package cycle with 92 components. But the tool also returned the full breakup of the package cycle as an alternative solution. In that case 102 sites would have to be touched and the component cyclicity would go from 8,464 to 254, a 97% improvement while the package cycle would have disappeared.
And one cycle is honestly hard work: model-core, 36 files in a single package. There is no package structure to exploit — nothing to condense, no layering to discover — so the component-level machinery is all that is left, and its best offer costs 140 edit sites and still leaves 98 cyclicity behind. That is the one the tool flags ballOfMud: true. For scale: of gradle’s 173 component cycles, 140 have a first step costing under five sites, 172 cost 34 or fewer, and nothing at all falls between 40 and 100. model-core sits alone above the gap. Nineteen sites is minutes of work with a coding agent; 140 is a refactoring session.
Here is the part that should keep you up at night: we have seen the cycle in the dependency-management module before, when it was easier to fix. In a 2022 version of Gradle the cycle had 69 elements instead of 92, so it grew by about 30%. But the effort to fix it grew from 44 sites to 102 sites, almost 150% more. This should serve as a reminder that avoiding cyclic dependencies has an incredible ROI. And by using Zügel you can virtually guarantee, that this problem will never affect your system.
What it is not
The server is deliberately scoped:
- It is not a CI gate. Sonargraph-Build is. Zügel is the fast inner loop; the authoritative check on the main branch stays where it is.
- It is not a visualization tool. When you want to see the dependency structure, open Sonargraph. Zügel answers questions; it does not draw.
- Java first. The parser SPI is language-agnostic by design — component identity is FQN + module, never file paths, precisely so that C#, TypeScript, Python, or Go can join without remodeling — but today the implemented parser is Java (via ecj).
- Deprecated-dependency warnings (as distinct from violations) are specified in the DSL but not yet implemented in the server — currently they report as violations.
Closing thought
Agents don’t have architectural taste, and pretending otherwise is how codebases rot at machine speed. But agents are excellent at following explicit, checkable contracts — better than humans, in fact, when every edit is verified by a tool rather than a code review.
That is the bet behind Zügel: make the architecture machine-checkable at edit time, teach the agent the three rules, and the same force that erodes structure becomes the force that maintains it. Your architecture stops being a diagram on a wiki page that new dependencies quietly ignore, and becomes a living contract — one that your fastest developer, the one that never sleeps, actually honors.