Architecture checks
The Architecture panel runs a fixed, zero-config ArchUnit ruleset against the host application's own classes. This page lists every rule that ships with BootUI today, what it inspects, when it fires, and what to do about it.
Each rule is a small class registered in ArchitectureRuleRegistry and implemented in ArchitectureRules.java. The list intentionally stays compact and reviewable; adding a new rule means adding one focused class plus a registry entry. The rules, the scanner, and the base-package-discovery seam all live in the framework-neutral bootui-engine module, so the exact same ruleset runs unmodified on both the Spring and Quarkus adapters — see docs/QUARKUS-SUPPORT.md for how base-package discovery differs per adapter.
What BootUI does
The scanner detects the host application's base package(s) — via the Spring adapter's @SpringBootApplication configuration (AutoConfigurationPackages) or, on Quarkus, via a build-time BasePackageProvider seam that reduces the Jandex application index to a package root antichain (see docs/QUARKUS-SUPPORT.md) — imports the compiled .class files from those packages with ArchUnit's ClassFileImporter, and evaluates every registered rule against the imported classes. Importing is bounded to the application's own base package(s) — never the entire classpath — and runs only on demand when the scan action is invoked, caching the last report in the controller. When several base packages are detected, all of them are imported and analyzed together. ArchUnit still resolves the external types those classes reference (super-classes, interfaces) from the classpath so hierarchy-aware checks work; BootUI keeps that resolution enabled but quietly skips any referenced class whose resource location uses a URL scheme the JVM cannot open — such as the Quarkus runtime classloader's quarkus: scheme — so a scan never floods the console with per-class resolution warnings.
When BootUI is installed through bootui-spring-boot-starter, ArchUnit is included transitively so the panel works without an extra application dependency; the Quarkus adapter bundles ArchUnit itself. The panel is available only when:
- ArchUnit is on the classpath, and
- a base package is resolvable from the running application.
If no classes can be imported (for example in some fat-jar or DevTools restart-classloader situations), the panel degrades to a stable, empty report with an explanatory reason rather than failing.
The exact same rules, including the SPRING_STEREOTYPES category below, run unmodified against Quarkus/CDI applications: rules keyed on Spring-only annotations (@Autowired, @Component, @Service, …) simply match zero classes and degrade to a no-op pass — never a false positive — while a handful of rules are deliberately dual-framework because they also key on the shared jakarta.* annotations (jakarta.transaction.Transactional, jakarta.annotation.PostConstruct/PreDestroy) that both Spring and CDI containers recognize. See each rule's entry below for which category it falls into, and ArchitectureCdiNeutralityTests for the automated check that pins this property across every SPRING_STEREOTYPES rule against a pure-CDI fixture set.
Kotlin applications
The rules read compiled bytecode, so they run unchanged on Kotlin classes, and the engine recognizes Kotlin constructs by bytecode name only — BootUI never adds a kotlin-stdlib dependency to your application.
- Compiler-generated members and classes are never reported. Synthetic and bridge members,
$suspendImpl/$default/$annotationshelpers,componentNandcopyaccessors ondata classes,CompanionandDefaultImplsholders,WhenMappingstables, and top-levelFooKtfile facades are filtered out before any rule sees them. This matters in practice: anopen suspend funis compiled into the declared function plus a static synthetic$suspendImplthat carries a copy of the original annotations, which would otherwise produce duplicate and outright false findings. - Suspending functions are judged on their declared signature. A
suspendfunction is compiled with a trailingkotlin.coroutines.Continuationparameter and an erased return type; BootUI hides that parameter and reads the real result type fromContinuation<? super T>.kotlin.Unitis treated asvoid. - Final-by-default is respected in the advice, not the detection. Kotlin classes and members are final unless marked
open, but thekotlin-spring(all-open) andno-argcompiler plugins change the emitted bytecode, so proxyability and entity rules stay accurate. Where a recommendation would otherwise say "removefinal", it offers the Kotlin equivalent instead.
What BootUI does not do
- It does not run project-specific layered-architecture rules — BootUI cannot know the host app's intended layering, so it ships only universally-sensible heuristics.
- It does not modify, compile, or instrument application code; it reads already-compiled bytecode.
- It is not a replacement for a project-authored ArchUnit test suite. Generic rules are necessarily weaker than rules written with knowledge of the application's design. Treat the panel as a starting point and review aid, and consider writing your own ArchUnit tests for project-specific invariants.
Severity scale
Severity reflects the worst plausible impact if the finding is real, not the likelihood:
- CRITICAL — supported for the most severe correctness or safety problems. No active check currently emits this severity.
- HIGH — a serious structural problem with clear maintenance impact (e.g. package cycles — see ARCH-PKG-001 — or forcibly terminating the JVM).
- MEDIUM — weakens maintainability or layering and usually warrants a fix (e.g. field injection, layering inversions).
- LOW — defense-in-depth / hygiene gap (e.g. standard-stream use, generic exceptions,
java.util.logging). - INFO — informational convention prompt (e.g. legacy library use, deprecated APIs).
The scan evaluates every registered rule, but the Rule results panel only lists rules that found violations. Violations are ordered by importance (CRITICAL, HIGH, MEDIUM, LOW, INFO), then by the number of violating instances, and include up to a handful of sample detail lines from ArchUnit.
The advisor score applies the shared severity penalty to every concrete violating instance, not just once per rule. Dismissed rules remove all of their instances from the score.
Filter the list, then jump to a check — the detail below narrows to match.
ARCH-PKG-001HIGHPackages should be free of cyclesARCH-MOD-001HIGHInternal packages should not be accessed from other modulesARCH-CODE-001LOWClasses should not access standard streamsARCH-CODE-002LOWClasses should not throw generic exceptionsARCH-CODE-003LOWClasses should not use java.util.loggingARCH-CODE-004INFOClasses should not use Joda-TimeARCH-CODE-005LOWClasses should not call Throwable.printStackTrace(PrintStream/PrintWriter)ARCH-CODE-006HIGHClasses should not forcibly terminate the JVMARCH-CODE-007LOWClasses should not access JDK-internal APIsARCH-CODE-008INFOClasses should not use legacy date and time classesARCH-CODE-009INFOClasses should not use deprecated APIsARCH-CODE-010LOWExceptions should be named ending with ExceptionARCH-CODE-011LOWInterfaces should not have names ending with 'Interface'ARCH-CODE-012LOWLoggers should be private static finalARCH-CODE-013MEDIUMApplication classes should not depend on test frameworksARCH-CODE-014MEDIUMClasses should not have public mutable static fieldsARCH-CODE-015LOWUtility classes should be final with a private constructorARCH-CODE-016MEDIUMClasses should not use standard-annotation field injectionARCH-CODE-017MEDIUMClasses should not directly instantiate ThreadARCH-CODE-018INFOAssertions should have a detail messageARCH-SPRING-001MEDIUMClasses should not use field injectionARCH-SPRING-002MEDIUMControllers should not depend on repositoriesARCH-SPRING-003MEDIUMRepositories should not depend on controllersARCH-SPRING-007MEDIUMRepositories should not depend on servicesARCH-SPRING-006MEDIUMServices should not depend on controllersARCH-SPRING-004HIGHBeans should not self-invoke their own proxied methodsARCH-SPRING-005MEDIUMSpring stereotypes should not reside in the default packageARCH-SPRING-008MEDIUMServices and repositories should not depend on web request typesARCH-SPRING-009MEDIUMTransactional annotations should not be declared on interfacesARCH-SPRING-010MEDIUMProxy-driven methods should be interceptableARCH-SPRING-011MEDIUMAsync methods should return void or FutureARCH-SPRING-012MEDIUMScheduled methods should have supported signaturesARCH-SPRING-013MEDIUMAsync should not be used in configuration classesARCH-SPRING-014LOWClasses should not call AopContext.currentProxyARCH-SPRING-015INFOConfiguration properties classes should be immutableARCH-SPRING-017HIGHLite-mode @Bean methods should not call sibling @Bean methodsARCH-SPRING-018HIGHLifecycle callbacks should not be proxy-drivenARCH-SPRING-019MEDIUMAsync and transactional semantics on one method should be reviewedARCH-SPRING-020MEDIUMAsync event listeners should return voidARCH-SPRING-021MEDIUMBeanPostProcessor and BeanFactoryPostProcessor @Bean methods should be staticARCH-SPRING-022HIGHLegacy javax.transaction.Transactional should be migrated
Package structure
ARCH-PKG-001 - Packages should be free of cycles
- Severity: HIGH
- Inspects: cyclic dependencies between the top-level package slices under the application base package (
<basePackage>.(*)..). - Fires when: two or more slices depend on each other directly or transitively, forming a cycle. Evaluated per detected base package and aggregated. The violation count is the number of cycles ArchUnit reports, not the number of dependency edges shown inside those cycle reports.
- Why it matters: package cycles make code hard to understand, test, and modularize, and they block clean extraction of modules.
- Recommendation: break the dependency cycle by extracting shared types or inverting one of the dependencies so packages form a directed acyclic graph.
ARCH-MOD-001 - Internal packages should not be accessed from other modules
- Severity: HIGH
- Inspects: direct dependencies from application classes to packages under a literal
internalsegment within the detected application base packages. - Fires when: a class outside the owning module prefix accesses a type in another module's
internalpackage (for example,base.orderaccessingbase.inventory.internal). - Why it matters:
internalmarks an encapsulation boundary; crossing it couples modules to each other's implementation details. - Recommendation: depend only on a module's public API (the packages outside its
internalsubpackage), or move the shared type into a published package.
Coding practices
ARCH-CODE-001 - Classes should not access standard streams
- Severity: LOW
- Inspects: direct use of
System.outorSystem.err(via ArchUnit'sGeneralCodingRules). - Fires when: any class writes to a standard stream instead of using a logging framework.
- Recommendation: replace
System.out/System.errcalls with a logger (e.g. SLF4J) so output is structured and configurable.
ARCH-CODE-002 - Classes should not throw generic exceptions
- Severity: LOW
- Inspects: throwing of generic exception types such as
Exception,RuntimeException, orThrowable. - Fires when: a class throws one of the generic types instead of a specific exception.
- Recommendation: throw specific, meaningful exception types so callers can handle failures precisely.
ARCH-CODE-003 - Classes should not use java.util.logging
- Severity: LOW
- Inspects: direct use of
java.util.logging. - Fires when: a class references
java.util.logginginstead of the project logging facade. - Recommendation: use the project logging facade (SLF4J over Logback by default in Spring Boot) for consistent logging.
ARCH-CODE-004 - Classes should not use Joda-Time
- Severity: INFO
- Inspects: use of the legacy Joda-Time library.
- Fires when: a class references Joda-Time types instead of
java.time. - Recommendation: migrate Joda-Time usage to the standard
java.timeAPI.
ARCH-CODE-005 - Classes should not call Throwable.printStackTrace(PrintStream/PrintWriter)
- Severity: LOW
- Inspects: calls to the
Throwable.printStackTrace(PrintStream)orprintStackTrace(PrintWriter)overloads. - Fires when: a class calls one of the arg-taking
printStackTraceoverloads instead of logging the exception. The no-argprintStackTrace()overload is deliberately not matched here: it is already covered by ARCH-CODE-001 (ArchUnit's built-in standard-streams check matches the no-arg overload directly), so this rule only reports the overloads ARCH-CODE-001 does not, instead of double-reporting the same no-arg call site under two rule IDs. - Recommendation: log the exception through the project logging facade (e.g. SLF4J) so the stack trace is structured and configurable.
ARCH-CODE-006 - Classes should not forcibly terminate the JVM
- Severity: HIGH
- Inspects: calls to
System.exit(int),Runtime.exit(int), orRuntime.halt(int). - Fires when: a class abruptly terminates the JVM instead of letting the framework manage shutdown.
System.exit(int)is exempt when called directly from a canonicalpublic static void main(String[])entry point: this is Spring Boot's own officially documented pattern for propagating anExitCodeGeneratorresult from CLI/batch applications,System.exit(SpringApplication.exit(context, ...))— see the Spring Boot reference docs, "Application Exit". ASystem.exitcall from anywhere else — a service, controller, or other business-logic class — is still flagged, as are allRuntime.exit/Runtime.haltcalls regardless of origin. - Recommendation: let the container or application framework manage the lifecycle instead of calling
System.exit(),Runtime.exit(), orRuntime.halt(). If you do need to propagate a process exit code from a CLI/batch application, callSystem.exit(SpringApplication.exit(context, ...))from the staticmainmethod only.
ARCH-CODE-007 - Classes should not access JDK-internal APIs
- Severity: LOW
- Inspects: dependencies on unsupported JDK-internal packages such as
sun..,jdk.internal.., orcom.sun..internal..subtrees. - Fires when: a class depends on a non-public JDK-internal type.
- Recommendation: depend only on public, supported APIs so the code stays portable across JDK versions.
ARCH-CODE-008 - Classes should not use legacy date and time classes
- Severity: INFO
- Inspects: use of legacy date/time classes such as
java.util.Date,Calendar,GregorianCalendar, orjava.sqldate types (via ArchUnit'sGeneralCodingRules). - Fires when: a class references one of the legacy date/time types instead of
java.time. - Recommendation: prefer the
java.timeAPI (LocalDate,Instant,ZonedDateTime, ...) for clearer, immutable date/time handling.
ARCH-CODE-009 - Classes should not use deprecated APIs
- Severity: INFO
- Inspects: access to members or types annotated with
@Deprecated(via ArchUnit'sGeneralCodingRules). - Fires when: a class references a deprecated API.
- Recommendation: migrate to the recommended replacement API; deprecated members may be removed in future releases.
ARCH-CODE-010 - Exceptions should be named ending with Exception
- Severity: LOW
- Inspects: classes that extend
ExceptionorRuntimeException. - Fires when: an exception type's simple class name does not end with
Exception, and no enclosing class does either. - Recommendation: rename exception classes to end with
Exceptionso their purpose is immediately clear, or nest them inside the exception type they specialise. - Kotlin note: the variants of a
sealed classhierarchy are nested inside their parent so the compiler can close the hierarchy, which leaves them with names likeClaimException.AlreadyAssigned. Those are exempt: the enclosing name already says what the type is at every call site, and adding the suffix would only make it stutter. The same applies to a nested Java exception hierarchy.
ARCH-CODE-011 - Interfaces should not have names ending with 'Interface'
- Severity: LOW
- Inspects: Java interfaces.
- Fires when: an interface simple name ends with
Interface. - Recommendation: name interfaces after the role or behaviour they expose instead of appending an
Interfacesuffix.
ARCH-CODE-012 - Loggers should be private static final
- Severity: LOW
- Inspects: logger fields whose raw type is SLF4J, Log4j2, Commons Logging, JBoss Logging,
java.util.logging, or Logback. - Fires when: a logger field is not
private,static, andfinal— with two recognized alternate patterns. Container-managed injection points (@Inject,@Autowired, orjakarta.annotation.Resource, e.g. Quarkus's idiomatic@Inject Logger log;) are exempt entirely, since a field wired by the container is non-static by construction — see the Quarkus Logging guide's "logging with injection" section. Legacyjavax.annotation.Resourceis deliberately not exempt: Spring Framework 7 removed support forjavax.annotationannotations, and Quarkus 3 uses the Jakarta namespace, so it is not a container-managed injection point on either supported baseline. Aprotected, non-static,finallogger declared in an abstract base class and initialized viaLoggerFactory.getLogger(getClass())is also accepted: subclasses inherit the field and each logs under its own runtime class name, which requires the field to be an instance member; the SLF4J FAQ explicitly declines to recommend static over instance loggers ("we no longer recommend one approach over the other") and documents instance loggers as IOC-friendly — see the SLF4J FAQ. A plain non-final, non-static, non-injected, non-abstract-base-class logger field (e.g. a mutable public field) still fails. - Recommendation: make logger fields
private static finalto avoid accidental external access and per-instance logger allocations. For a logger shared with subclasses, declare itprotected, non-static, andfinalin an abstract base class, initialized withLoggerFactory.getLogger(getClass()). Container-managed logger injection points are exempt because the container wires them, not the class itself.
ARCH-CODE-013 - Application classes should not depend on test frameworks
- Severity: MEDIUM
- Inspects: dependencies on common test-only APIs such as JUnit, Mockito, AssertJ, Hamcrest, Spring Test, Spring Boot Test, Testcontainers, Quarkus's
@QuarkusTest(io.quarkus.test..), or RestAssured (io.restassured..). - Fires when: an application class references a test framework type.
- Why it matters: production code that depends on test frameworks is usually an accidental source-set leak and can pull unnecessary or unavailable test libraries into runtime code.
- Recommendation: move assertions, fixtures, containers, and test helpers to test sources; keep production classes independent of test APIs.
ARCH-CODE-014 - Classes should not have public mutable static fields
- Severity: MEDIUM
- Inspects:
public staticfields that are notfinal. - Fires when: a class exposes a public static field that can be reassigned, creating shared, globally reachable mutable state.
- Why it matters: public mutable static state is hard to reason about, is not thread-safe by default, and couples unrelated code through a hidden global.
- Recommendation: make the field
finalso it cannot be reassigned, reduce its visibility, or move the mutable state into a managed bean.
ARCH-CODE-015 - Utility classes should be final with a private constructor
- Severity: LOW
- Inspects: classes that expose only static members (at least one static method, no instance methods, and no instance fields), excluding interfaces, enums, records, abstract classes, and Spring stereotypes.
- Fires when: such a utility class is not
final, or it can be instantiated through a non-private constructor. - Recommendation: make utility classes
finaland give them a single private constructor so they cannot be instantiated or subclassed. - Kotlin note: compiler-generated holders — top-level
FooKtfile facades,Companion,DefaultImplsandWhenMappingsclasses — are skipped, since their shape is not under the author's control. Idiomatic Kotlinobjectdeclarations are not utility classes (their members are instance members onINSTANCE) and never fire.
ARCH-CODE-016 - Classes should not use standard-annotation field injection
- Severity: MEDIUM
- Inspects:
jakarta.inject.Inject,javax.inject.Inject,jakarta.annotation.Resource,javax.annotation.Resource, orcom.google.inject.Injectannotations on fields — the standard JSR-330 / Jakarta / Guice injection annotations a CDI container such as Quarkus' Arc (or plain Guice) uses. - Fires when: a dependency is injected directly into a field via one of these standard annotations instead of through a constructor.
- Why it matters: field injection hides required dependencies, prevents
finalfields, and makes classes harder to instantiate in tests — the same rationale as ARCH-SPRING-001, just for the framework-neutral annotation set. Kept as a separate rule (rather than folded into ARCH-SPRING-001) so it fires correctly on a Quarkus/CDI application that has no Spring annotations anywhere on its classpath. - Recommendation: prefer constructor injection so dependencies are explicit, final, and easy to test; CDI containers such as Quarkus' Arc inject constructor parameters just as readily as fields.
- Kotlin note: an injected
lateinit varis a true positive; take the dependency as a constructorvalinstead.
ARCH-CODE-017 - Classes should not directly instantiate Thread
- Severity: MEDIUM
- Inspects:
new Thread(...)constructor calls, including instantiating a class that extendsThread. - Fires when: application code directly constructs a
Thread(or aThreadsubclass) instead of using a managed executor. - Why it matters: an unmanaged thread bypasses pool sizing, naming, and uncaught-exception handling, and sits outside both frameworks' managed-concurrency story — Spring's
TaskExecutor/@Async(andspring.threads.virtual.enabledon Java 21+), or Quarkus'sManagedExecutor/@RunOnVirtualThread. This mirrors Effective Java Item 80, "Prefer executors, tasks, and streams to threads", and the JDKjava.util.concurrent.ExecutorJavadoc. See the Quarkus context-propagation guide. - Recommendation: use a managed executor instead of instantiating
Threaddirectly:java.util.concurrent.ExecutorService/Executors, Spring'sTaskExecutoror@Async, or Quarkus'sManagedExecutoror@RunOnVirtualThread.
ARCH-CODE-018 - Assertions should have a detail message
- Severity: INFO
- Inspects:
assertstatements, which compile to a no-argnew AssertionError()when they have no detail message (via ArchUnit's built-inGeneralCodingRules.ASSERTIONS_SHOULD_HAVE_DETAIL_MESSAGE). - Fires when: a class contains an
assertstatement with no detail message (assert x > 0;), which produces a near-useless failure diagnostic. Anassertwith a message (assert x > 0 : "x must be positive";) compiles to the message-taking overload and is not matched. - Recommendation: add a detail message, e.g.
assert x > 0 : "x must be positive";, so a failure explains what was expected.
Spring stereotypes
ARCH-SPRING-001 - Classes should not use field injection
- Severity: MEDIUM
- Inspects:
@Autowiredor@Value(Spring's own field-injection annotations) on fields. - Fires when: a dependency is injected directly into a field instead of through a constructor.
- Why it matters: field injection hides required dependencies, prevents
finalfields, and makes classes harder to instantiate in tests. - Recommendation: prefer constructor injection so dependencies are explicit, final, and easy to test.
- Kotlin note: an
@Autowired lateinit varis a true positive; take the dependency as a constructorvalinstead. - Quarkus/CDI note: deliberately scoped to Spring's own annotations only, so it never fires on plain
jakarta.inject.Inject/@Resourcefield injection — the idiomatic style on a CDI/Quarkus application. See ARCH-CODE-016 for the framework-neutral equivalent that covers those standard annotations instead.
ARCH-SPRING-002 - Controllers should not depend on repositories
- Severity: MEDIUM
- Inspects:
@Controller/@RestControllerclasses that depend directly on@Repositorybeans. - Fires when: a controller references a repository, bypassing a service layer.
- Recommendation: introduce a service layer between controllers and repositories to keep web and persistence concerns separated.
ARCH-SPRING-003 - Repositories should not depend on controllers
- Severity: MEDIUM
- Inspects:
@Repositorybeans that depend on@Controller/@RestControllerclasses. - Fires when: persistence code references web-layer classes, inverting the expected layering.
- Recommendation: keep persistence code free of web concerns; dependencies should flow from controllers toward repositories, not back.
ARCH-SPRING-007 - Repositories should not depend on services
- Severity: MEDIUM
- Inspects:
@Repositorybeans that depend directly on@Servicebeans. - Fires when: persistence code references business services, inverting the usual service-to-repository dependency direction.
- Recommendation: keep repository beans focused on persistence concerns; dependencies should flow from services toward repositories, not back.
ARCH-SPRING-006 - Services should not depend on controllers
- Severity: MEDIUM
- Inspects:
@Servicebeans that depend directly on@Controller/@RestControllerclasses. - Fires when: service-layer code references web-layer classes, coupling business logic back to HTTP concerns.
- Recommendation: keep service beans free of controller dependencies; web dependencies should flow from controllers toward services, not back.
ARCH-SPRING-004 - Beans should not self-invoke their own proxied methods
- Severity: HIGH
- Inspects: direct self-invocation (
this.method()) of methods proxied through@Transactional(Spring's own or the portablejakarta.transaction.Transactional),@Async, or any Spring cache operation (@Cacheable,@CachePut,@CacheEvict, or@Caching) on the method, or@Async/ a cache operation on the declaring class. - Fires when: a bean calls one of its own proxied methods directly, bypassing the Spring proxy.
- Why it matters: the transaction, async execution, or caching behaviour is silently lost because the call never passes through the proxy — a real correctness bug, not just a style issue.
- Recommendation: refactor so the call goes through the Spring proxy: move the proxied method to a separate bean, or, only if necessary, inject a
@Lazyself-reference and call through it. - Kotlin note: Kotlin behaves identically — marking a function
opendoes not make athis-call go through the proxy. Two compiler-generated shapes are read as what the developer wrote rather than as extra self-invocations. Calls a function makes into its own helpers (such as the$suspendImplof anopen suspend fun, or the$defaultbridge that fills in default arguments) are not reported, because nobody can refactor a call the compiler makes. Conversely, a call that omits a default argument is compiled into a call to that$defaultbridge, and it is followed through to the function it dispatches to — so a genuine self-invocation stays reported, named after the function you can actually change, instead of disappearing the moment a proxied function gains a default parameter value. A self-invocation written inside a lambda is still reported: the compiler puts the body in a synthetic method, but the code is yours and the behaviour really is lost. - Quarkus/CDI note: this rule is skipped on Quarkus. Arc deliberately supports intercepted self-invocation, unlike standard proxy-based Spring AOP, so reporting the Spring limitation there would be a false positive. See the Quarkus CDI reference.
ARCH-SPRING-005 - Spring stereotypes should not reside in the default package
- Severity: MEDIUM
- Inspects:
@Component/@Service/@Repository/@Controller/@Configurationclasses in the default (unnamed) package. - Fires when: a stereotype-annotated class has no package declaration.
- Recommendation: move Spring stereotype beans into a named package so component scanning and proxying work as expected.
ARCH-SPRING-008 - Services and repositories should not depend on web request types
- Severity: MEDIUM
- Inspects:
@Serviceand@Repositorybeans that depend onjakarta.servlet,javax.servlet, or Spring web request types, including WebFlux functional request/response types (ServerRequest/ServerResponse), reactive server exchange/session types (ServerWebExchange,WebSession, and their families), and low-level reactive HTTP server types. - Fires when: business or persistence code accepts, stores, or otherwise references servlet or reactive web infrastructure.
- Why it matters: service and repository code should be transport-agnostic so it can be reused from HTTP controllers, CLI runners, scheduled jobs, tests, and message consumers.
- Recommendation: extract request data in the controller and pass plain application values into services and repositories.
ARCH-SPRING-009 - Transactional annotations should not be declared on interfaces
- Severity: MEDIUM
- Inspects: Spring or Jakarta
@Transactionalannotations on interfaces and interface methods. - Fires when: an interface or one of its methods declares transaction metadata.
- Why it matters: Spring recommends annotating concrete classes or methods because interface-declared annotations can behave differently across proxy modes and may be silently ignored with AspectJ weaving.
- Recommendation: move transaction annotations to concrete implementation classes or methods.
ARCH-SPRING-010 - Proxy-driven methods should be interceptable
- Severity: MEDIUM
- Inspects: methods annotated with
@Transactional(Spring's own or the portablejakarta.transaction.Transactional),@Async, or a Spring cache operation (@Cacheable,@CachePut,@CacheEvict, or@Caching). - Fires on Spring when: a proxy-driven annotation is applied to a private, static, or final method. Spring Framework 6+ supports protected and package-private transactional and cache methods on class-based proxies, which Spring Boot uses by default. Applications that explicitly select interface-based JDK proxies should keep annotated methods public.
- Fires on Quarkus when:
jakarta.transaction.Transactionalis applied to a private method. Arc supports intercepted static methods and transforms final intercepted methods by default, so applying Spring's modifier bar would create false positives. - Why it matters: an annotation on a method the active runtime cannot intercept silently loses its transaction, asynchronous, or caching behavior.
- Recommendation: for portable Spring proxy behavior, use a public, non-static, non-final method. On Quarkus, avoid private interceptor-bound methods.
- Kotlin note: classes and members are final by default — mark them
open, or apply thekotlin-springcompiler plugin, which opens Spring-annotated classes for you.
ARCH-SPRING-011 - Async methods should return void or Future
- Severity: MEDIUM
- Inspects: methods annotated with
@Async, and methods declared on@Asyncclasses. - Fires when: an async method returns a value type that is neither
voidnor assignable tojava.util.concurrent.Future, or a Kotlin suspending function is annotated with@Async. - Why it matters: Spring supports async methods with
voidreturn values orFuture/CompletableFuturehandles; other return values do not provide the caller a valid asynchronous result. Spring's async interceptor does not support suspending functions at all, so the annotation is silently ineffective there. - Recommendation: use
voidfor fire-and-forget async work, or returnFuture/CompletableFuturewhen callers need a result. - Kotlin note: launch the work in a coroutine (for example
withContext(Dispatchers.IO)) rather than annotating a suspending function with@Async.
ARCH-SPRING-012 - Scheduled methods should have supported signatures
- Severity: MEDIUM
- Inspects: methods annotated with
@Scheduled. - Fires when: a scheduled method declares parameters, or returns a non-
void, non-reactive value type whose result Spring will ignore. - Why it matters: Spring invokes scheduled methods without arguments; synchronous return values are discarded, which often indicates a misunderstood job contract. Spring's
ScheduledAnnotationReactiveSupportrecognizes a fixed, evolving set of deferred reactive return types viaReactiveAdapterRegistry: anyorg.reactivestreams.Publisher(Reactor'sMono/Fluxincluded), the JDK's ownjava.util.concurrent.Flow.Publisher, Kotlin'sFlow/Deferred, RxJava 3 types (io.reactivex.rxjava3.*), and SmallRye Mutiny'sUni/Multiwhen on the classpath — but never RxJava 2 (io.reactivex.*, without therxjava3segment) orCompletionStage/CompletableFuture, both of which Spring registers as non-deferred and so discards exactly like any other synchronous return value. - Recommendation: declare scheduled methods without parameters and return
voidunless using a supported deferred reactive type (a Reactor/Reactive StreamsPublisher,java.util.concurrent.Flow.Publisher, RxJava 3, or SmallRye MutinyUni/Multi). - Kotlin note: suspending scheduled functions are supported — Spring bridges them through the coroutine-reactor adapter. They are judged on their declared signature, so the implicit
Continuationparameter is not counted and aUnitresult is treated asvoid; a suspending function that declares a real result type still reports the ignored return value.
ARCH-SPRING-013 - Async should not be used in configuration classes
- Severity: MEDIUM
- Inspects:
@Asyncon@Configurationclasses or methods declared inside@Configurationclasses. - Fires when: configuration code is annotated for asynchronous execution.
- Why it matters: Spring's
@AsyncJavadoc explicitly states that it is not supported on methods declared within@Configurationclasses. - Recommendation: move asynchronous work to a regular Spring bean and call it through that bean's proxy.
ARCH-SPRING-014 - Classes should not call AopContext.currentProxy
- Severity: LOW
- Inspects: calls to
org.springframework.aop.framework.AopContext.currentProxy(). - Fires when: application code looks up the current Spring AOP proxy directly.
- Why it matters: Spring documents this as a discouraged last resort because it couples application code to Spring AOP internals and requires proxy exposure.
- Recommendation: refactor to avoid self-invocation, or inject a self-reference when a proxy call is truly required.
ARCH-SPRING-015 - Configuration properties classes should be immutable
- Severity: INFO
- Inspects: non-static instance fields declared in classes annotated with
@ConfigurationProperties. - Fires when: a
@ConfigurationPropertiesclass has a non-finalinstance field, i.e. it relies on mutable setter binding instead of immutable constructor binding. - Why it matters: Spring Boot favors immutable configuration bound through records or constructors; mutable configuration state can be changed after binding and is harder to reason about.
- Recommendation: bind configuration through a record or a constructor with
finalfields so configuration state is immutable.
Removed: ARCH-SPRING-016 ("Layered architecture dependencies should flow from web to service to repository"). This holistic rule used ArchUnit's
layeredArchitecture()over the same three stereotype layers (web/service/persistence) that ARCH-SPRING-002 (controllers → repositories), ARCH-SPRING-003 (repositories → controllers), ARCH-SPRING-006 (services → controllers), and ARCH-SPRING-007 (repositories → services) already check individually. Its violation set was verified to be the exact union of what those four pairwise rules already catch, so every real violation was being reported twice — once under its specific pairwise rule ID, once under ARCH-SPRING-016 — inflating the panel's violation and severity counts. The rule was removed and the four granular pairwise rules were kept, since they give clearer, more specific per-pair messages (e.g. "Controller X depends on Repository Y" is more actionable than a generic layer-violation message).
ARCH-SPRING-017 - Lite-mode @Bean methods should not call sibling @Bean methods
- Severity: HIGH
- Inspects: direct calls between
@Beanmethods declared in the same class when that class is not a full@Configuration(proxyBeanMethods=true). - Fires when: a
@Beanmethod directly calls a different sibling@Beanmethod in lite mode, where Spring treats each factory method with ordinary Java semantics rather than intercepting inter-bean calls. - Why it matters: the call bypasses container resolution and directly creates whatever the sibling factory method returns. For the common singleton case this is a duplicate unmanaged instance, but the exact consequence depends on the factory method's scope and implementation.
- Recommendation: declare the class as
@Configuration(the defaultproxyBeanMethods=true), or pass the dependency as a@Beanmethod parameter instead of calling the sibling@Beanmethod directly.
ARCH-SPRING-018 - Lifecycle callbacks should not be proxy-driven
- Severity: HIGH
- Inspects:
@PostConstructor@PreDestroymethods that are also annotated with@Transactional(Spring's own or the portablejakarta.transaction.Transactional),@Async, or a Spring cache operation (@Cacheable,@CachePut,@CacheEvict, or@Caching). - Fires when: a lifecycle callback is annotated with a proxy-driven annotation.
- Why it matters: Spring invokes lifecycle callbacks before the bean is wrapped in its proxy, and after it is unwrapped at destruction, so the proxy behaviour never applies.
- Recommendation: move the transactional, asynchronous, or cached work to a separate proxied bean method and invoke it after initialization rather than annotating the lifecycle callback itself.
- Quarkus/CDI note: also a deliberate dual-framework true positive. Per the
jakarta.transaction.TransactionalJavadoc (Jakarta Transactions specification): "The Transactional interceptor interposes on business method invocations only and not on lifecycle events. Lifecycle methods are invoked in an unspecified transaction context." So a@PostConstruct/@PreDestroymethod combined with the portable@Transactionalsilently runs without a transaction on Quarkus/CDI exactly as it does on Spring. SeeArchitectureCdiNeutralityTestsfor the pinned true-positive case.
ARCH-SPRING-019 - Async and transactional semantics on one method should be reviewed
- Severity: MEDIUM
- Inspects: methods annotated with both
@Asyncand Spring or Jakarta@Transactional. - Fires when: one method combines asynchronous execution with transactional semantics.
- Why it matters: the transaction runs on the async worker thread, so the caller's transaction and security context do not propagate.
- Recommendation: review the design; usually the transactional work belongs in a separate bean method that the
@Asyncmethod calls, so the transaction is scoped correctly on the async thread. - Does not fire when: the method is a transactional event listener that runs after the publishing transaction completed —
@TransactionalEventListenerin its defaultAFTER_COMMITphase, or inAFTER_ROLLBACK/AFTER_COMPLETION. Spring Modulith's@ApplicationModuleListeneris exactly that shape: it composes@Async,@Transactional(propagation = REQUIRES_NEW)and@TransactionalEventListener, and the whole point is that the listener does not join the publisher's transaction, which has already committed by the time the listener runs. The listener annotation is recognised on the method itself and through a composed annotation, so a project's own meta-annotation is exempt too, and@ApplicationModuleListeneris additionally matched by name (bothorg.springframework.modulith.eventsand the Spring Modulith 1.xorg.springframework.modulithpackage) so the exemption holds even when that annotation type cannot be resolved. - Still fires for:
@TransactionalEventListener(phase = BEFORE_COMMIT)combined with@Async. There the publishing transaction really is still open while the listener runs on another thread, so the listener's own transaction observes state the publisher has not committed. The message names the phase; move the listener toAFTER_COMMITor drop@Async.
ARCH-SPRING-020 - Async event listeners should return void
- Severity: MEDIUM
- Inspects:
@EventListenermethods that run asynchronously because@Asyncis declared on the method or its class. - Fires when: an asynchronous event listener declares a non-
voidreturn type. - Why it matters: Spring supports return values from synchronous event listeners by publishing them as follow-up events, but its
@EventListenercontract explicitly states that asynchronous listeners cannot publish a subsequent event through their return value. - Recommendation: return
void; when a follow-up event is needed, injectApplicationEventPublisherand publish it explicitly from the listener.
ARCH-SPRING-021 - BeanPostProcessor and BeanFactoryPostProcessor @Bean methods should be static
- Severity: MEDIUM
- Inspects: non-static
@Beanmethods that return aBeanPostProcessororBeanFactoryPostProcessor. - Fires when: a post-processor factory method is declared as a non-static
@Beanmethod. - Why it matters: a non-static post-processor factory method forces its configuration class to be instantiated before bean post-processing is fully set up, which can disable post-processing of other beans.
- Recommendation: declare these
@Beanmethodsstaticso the post-processor can be created without instantiating the surrounding configuration class.
ARCH-SPRING-022 - Legacy javax.transaction.Transactional should be migrated
- Severity: HIGH
- Inspects:
javax.transaction.Transactionalon classes and methods. - Fires when: application bytecode still uses the legacy Java EE transaction annotation.
- Why it matters: Spring Framework 7 uses a Jakarta EE 11 baseline. Its
AnnotationTransactionAttributeSourceregisters parsers for Spring's own annotation andjakarta.transaction.Transactional, not the oldjavax.transaction.Transactional. On BootUI's Spring Boot 4 baseline, the legacy annotation therefore does not create the intended transaction boundary. - Recommendation: replace it with Spring's
org.springframework.transaction.annotation.Transactionalorjakarta.transaction.Transactional, and replace the legacy Java EE API dependency with its Jakarta equivalent.