Fail your PR when someone adds a bean cycle
WireDoctor’s Architectural Regression Guard turns your Spring context into an architectural fitness function: you commit a baseline snapshot of the bean graph, and CI fails any pull request that introduces a new dependency cycle.
No annotation scanning, no ArchUnit rule maintenance — the guard diffs the graph that Spring actually resolved at startup.
How it works
- Baseline — you commit
wiredoctor-baseline.json(a snapshot of beans, edges, and cycles) to your repo, like a lockfile for your architecture. - Diff — on every startup with
wiredoctor.baselineset, WireDoctor diffs the live graph against the baseline and writeswiredoctor-diff.jsonplus a machine-readable verdict filewiredoctor-gate.status(since 0.4.0). - Gate — with
wiredoctor.fail-on=new-cycle, a cycle that is not in the baseline throwsWireDoctorRegressionExceptionafter analysis completes (the diff file is always written first), so the JVM exits non-zero and the CI job goes red.
Everything is opt-in and degrades gracefully: no baseline configured → no diff; baseline file missing → info log, no gate, no error. WireDoctor never fails your app unless you explicitly asked it to via fail-on.
Cycle identity is the exact bean set. If an existing cycle grows by one bean, that counts as a new cycle — growing a tangle is a regression too.
Step 1 — create and commit the baseline
Record the baseline the same way CI will run the app. The graph WireDoctor sees depends on what is on the classpath, so a baseline captured under one launch method and gated under another produces a diff full of differences you did not make.
CI runs the packaged jar (Step 3), so build and record from the jar:
./mvnw -DskipTests package
java -jar target/*.jar \
--wiredoctor.baseline=wiredoctor-baseline.json \
--wiredoctor.baseline-write=true
Do not record the baseline with
./mvnw spring-boot:runif CI gates on the jar.spring-boot:runkeepsspring-boot-devtoolson the classpath whilespring-boot-maven-pluginexcludes it from the repackaged jar. On spring-petclinic that single difference shows up as 12 removed beans (classPathFileSystemWatcher,LocalDevToolsAutoConfiguration,DevToolsDataSourceAutoConfiguration, …) and a 31% startup-time delta — enough to tripstartup-timeon a build where nobody changed a line of code.Same rule for anything else that moves the graph: keep the active profiles and
spring.main.web-application-typeidentical between the baseline run and the gate run.
Then commit the file:
git add wiredoctor-baseline.json
git commit -m "chore: commit WireDoctor architecture baseline"
baseline-write mode never diffs or gates — it just accepts the current architecture as the new truth. Re-run it whenever you intentionally change the architecture and want the baseline to move.
Step 2 — configure the gate for CI
Add a CI-only profile (e.g. application-archcheck.properties):
wiredoctor.baseline=wiredoctor-baseline.json
wiredoctor.fail-on=new-cycle
# keep report noise out of CI logs if you like:
# wiredoctor.output-path=target
Step 3 — the GitHub Actions job
The gate trips at ApplicationReadyEvent, so any way of fully starting the context works. The simplest is to boot the packaged jar and let the exit code speak:
Do not gate through
./mvnw spring-boot:run. With devtools on the classpath it launchesmainon its own restart thread;WireDoctorRegressionExceptionis logged, that thread dies, and the Maven build still reportsBUILD SUCCESSand exits 0. The gate fires and CI goes green anyway:[WireDoctor] REGRESSION GATE TRIPPED (wiredoctor.fail-on=new-cycle): 2 new cycle(s) introduced vs baseline. ... [INFO] BUILD SUCCESSBooting the jar gives the exit code 1 that CI needs. If you must use a Maven goal, gate on the verdict file instead — it is written before the exception:
grep -q '^FAIL' target/wiredoctor-gate.status && exit 1
name: Architecture Gate
on:
pull_request:
branches: [ "main" ]
jobs:
bean-cycle-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v5
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Build
run: ./mvnw -B -DskipTests package
- name: Fail on new bean cycle
run: >
java -jar target/*.jar
--spring.profiles.active=archcheck
--spring.main.web-application-type=none
- name: Upload architecture diff
if: failure()
uses: actions/upload-artifact@v4
with:
name: wiredoctor-diff
path: wiredoctor-diff.json
--spring.main.web-application-type=none makes the app exit right after startup instead of serving forever — the context still fully starts, so the guard still runs. If your app needs a web context to boot, run the gate from a @SpringBootTest smoke test instead; the exception fails the test the same way.
What a red PR looks like
[WireDoctor] Baseline Diff (vs wiredoctor-baseline.json):
- Beans: +3 -0 | Edges: +5 -1 | New cycles: 1 | Resolved cycles: 0
- NEW CYCLE: [orderService, paymentService]
[WireDoctor] Saved baseline diff to: /workspace/wiredoctor-diff.json
[WireDoctor] REGRESSION GATE TRIPPED (wiredoctor.fail-on=new-cycle): 1 new cycle(s) introduced vs baseline. Failing the application as configured.
...
com.wiredoctor.WireDoctorRegressionException
The uploaded wiredoctor-diff.json artifact shows exactly which beans and edges changed, so the author can see the cycle without reproducing locally.
Updating the baseline on purpose
When an architectural change is intentional, regenerate the baseline (Step 1) in the same PR and commit it. Reviewers then see the baseline diff — the architecture change becomes an explicit, reviewable artifact instead of silent drift.
Gating without exit codes: wiredoctor-gate.status (since 0.4.0)
Some build setups can’t (or don’t want to) rely on the JVM exit code — e.g. the app is booted by a wrapper script, runs as a @SpringBootTest, or the team wants a soft gate that reports without failing the build. For these, WireDoctor writes a machine-readable verdict file next to the reports on every completed diff, whether or not fail-on is configured:
FAIL:new-cycle
baseline=wiredoctor-baseline.json
newCycles=1
resolvedCycles=0
addedBeans=3
removedBeans=0
gateArmed=false
The contract is line 1 only: PASS, or FAIL:<gate> (comma-separated if multiple gates ever trip). The remaining key=value lines are informational and may grow in future versions — don’t parse positionally.
Semantics worth relying on:
- Absence means “no verdict.” The file is deleted at the start of every guarded run and only written after a diff completes — a stale verdict from a previous run can never leak into a run that crashed or skipped the diff (missing/corrupt baseline,
baseline-writemode). - Written before the hard gate throws, so CI can read it even after the JVM died red.
- The verdict is independent of
fail-on:FAIL:new-cyclewithgateArmed=falsemeans “a new cycle exists, but you chose not to fail the app over it.”
A Maven/Gradle-friendly gate step becomes a one-liner:
# hard gate, no log parsing:
grep -q '^PASS' wiredoctor-gate.status || { cat wiredoctor-diff.json; exit 1; }
Or as a soft gate in GitHub Actions — warn on the PR without failing it:
- name: Architecture check (soft)
run: |
if ! grep -q '^PASS' wiredoctor-gate.status; then
echo "::warning::WireDoctor: $(head -1 wiredoctor-gate.status) — see wiredoctor-diff.json"
fi
Keep configuration consistent between baseline-write and diff runs. The diff compares the live graph against the snapshot, so any config that changes which beans exist will show up as added/removed beans. A common example: writing the baseline with
management.endpoints.web.exposure.include=wiredoctorset but diffing without it reportsremovedBeans:[wireDoctorEndpoint]— technically correct, practically noise. Write and diff under the same profile and exposure settings.
Available gates
wiredoctor.fail-on | Trips when… | Since |
|---|---|---|
new-cycle | a cycle appears that is not in the baseline (exact bean-set identity) | 0.2.0 |
condition-changed | an autoconfiguration condition outcome flips vs the baseline (e.g. matched → notMatched) — the Upgrade Guard, see upgrade-guard.md | 0.5.0 |
startup-time | startup time regresses beyond both absolute + relative thresholds vs baseline — see performance-gates.md | 0.7.0 |
slow-bean | a bean crosses slow-bean-threshold-ms that was not slow in the baseline — see performance-gates.md | 0.7.0 |
Gates combine via a comma-separated list — wiredoctor.fail-on=new-cycle,condition-changed,startup-time,slow-bean arms all four, and the FAIL: line lists every gate that fired (e.g. FAIL:new-cycle,startup-time). The condition-changed gate never trips against a pre-0.5.0 baseline that has no condition data — the marker shows conditionDiff=skipped and the verdict stays PASS. Similarly, startup-time and slow-bean gates gracefully skip when the baseline has no timing data (e.g., Boot < 2.6 where ApplicationReadyEvent.getTimeTaken() is unavailable). The property stays forward-compatible as new gates are added.