Imagine I am reviewing a Quarkus service with a customer. The architecture diagram puts a clean domain model in the center, so we open Product.java first. It is a small record with four fields and three validation checks. It looks exactly as I expect. Then I reach the imports and find this line:
import io.quarkus.runtime.annotations.RegisterForReflection;“Why is this here?” I ask.
“The native build needed reflection. This fixed it. The tests pass.”
Nobody decided to redesign the domain around Quarkus. A developer removed an immediate error and continued with the feature.
I would not stop the review to declare an architecture emergency. One annotation will not cause an outage. I would ask what prevents the next framework import. The Java compiler accepts this one because Quarkus is already on the classpath. The test suite checks behavior, not package direction. A reviewer can miss one line in a large pull request. The architecture diagram is now the only place where the domain is still plain Java.
For a single-module service, I want the build to answer that question. Checkstyle’s ImportControl check can reject framework imports from the domain package during Maven’s validate phase. I use a small Quarkus catalog API to show the complete loop: define the package policy, add the reflection annotation on purpose, capture the failure, remove it, run the API tests, and send the same finding to CI as SARIF.
What You Will Build
The application exposes GET /products/{sku}. Its packages have these responsibilities:
com.acme.catalog
├── domain
│ └── Product.java
├── application
│ ├── CatalogService.java
│ └── ProductCatalog.java
└── adapter
├── catalog
│ └── InMemoryProductCatalog.java
└── rest
├── ProductResource.java
└── ProductResponse.javaThe adapters may use the application and domain packages. The application package may use the domain. The domain may use only the JDK and its own types.
I keep the example in one Maven module on purpose. If I needed compiler-level isolation, I would split these packages into modules and enforce the direction through Maven dependencies. For an existing service, that changes the build structure and dependency declarations. I do not need either change to reject one forbidden import.
What You Need
I pinned the example to Quarkus 3.39.1, Checkstyle 14.0.0, the Maven Checkstyle Plugin 3.6.0, and Java 21.
Java 21 or newer
The Quarkus CLI
curlAbout ☕️☕️
Checkstyle 14 requires Java 21 to run. That is independent of which Java language level Checkstyle can parse; the Checkstyle release notes and runtime requirements are the source of truth when you change versions.
Create the Quarkus Application
Generate an empty application with Quarkus REST and Jackson or start from my Github repository:
quarkus create app -P io.quarkus.platform:quarkus-bom:3.39.1 \
--maven \
--java=21 \
--extension=rest-jackson \
--no-code \
com.acme:quarkus-checkstyle-boundaries
cd quarkus-checkstyle-boundaries
I chose rest-jackson so the HTTP adapter has a typed JSON boundary. The domain will not carry Jackson annotations or double as the wire format. Quarkus documents the extension and its current platform compatibility in the Quarkus REST Jackson extension catalog.
Start with a Plain Java Domain
Create src/main/java/com/acme/catalog/domain/Product.java:
package com.acme.catalog.domain;
import java.math.BigDecimal;
import java.util.Currency;
import java.util.Objects;
public record Product(String sku, String name, BigDecimal price, Currency currency) {
public Product {
Objects.requireNonNull(sku, "sku must not be null");
Objects.requireNonNull(name, "name must not be null");
Objects.requireNonNull(price, "price must not be null");
Objects.requireNonNull(currency, "currency must not be null");
if (sku.isBlank()) {
throw new IllegalArgumentException("sku must not be blank");
}
if (name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
if (price.signum() < 0) {
throw new IllegalArgumentException("price must not be negative");
}
}
}The boundary is visible in the imports: Product owns business invariants and uses JDK value types. It does not depend on CDI creation, Jackson serialization, or native-image reflection configuration.
I am not trying to remove every framework API from the service. Quarkus and Jakarta belong in the HTTP, persistence, and dependency-injection adapters. I want those dependencies to remain in those packages instead of appearing in the model.
Add the Application Port and Use Case
Create src/main/java/com/acme/catalog/application/ProductCatalog.java:
package com.acme.catalog.application;
import java.util.Optional;
import com.acme.catalog.domain.Product;
public interface ProductCatalog {
Optional<Product> findBySku(String sku);
}Create src/main/java/com/acme/catalog/application/CatalogService.java:
package com.acme.catalog.application;
import java.util.Optional;
import com.acme.catalog.domain.Product;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class CatalogService {
private final ProductCatalog productCatalog;
public CatalogService(ProductCatalog productCatalog) {
this.productCatalog = productCatalog;
}
public Optional<Product> findProduct(String sku) {
return productCatalog.findBySku(sku);
}
}ProductCatalog is the inward-facing port, so the service does not know whether the implementation uses a map, a database, or a remote client. I allow CDI in the application layer for this example, which is why CatalogService carries @ApplicationScoped. The rule file will permit that dependency explicitly.
You may require a stricter boundary. If your application layer must stay free of CDI, move the annotation to an adapter or producer and encode that rule. Copying somebody else’s package diagram is easy; deciding which dependencies your own code may take requires review.
Implement the Catalog Adapter
Create src/main/java/com/acme/catalog/adapter/catalog/InMemoryProductCatalog.java:
package com.acme.catalog.adapter.catalog;
import java.math.BigDecimal;
import java.util.Currency;
import java.util.Map;
import java.util.Optional;
import com.acme.catalog.application.ProductCatalog;
import com.acme.catalog.domain.Product;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class InMemoryProductCatalog implements ProductCatalog {
private static final Currency EUR = Currency.getInstance("EUR");
private final Map<String, Product> products = Map.of(
"sku-1", new Product("sku-1", "Mechanical Keyboard", new BigDecimal("129.00"), EUR),
"sku-2", new Product("sku-2", "USB-C Dock", new BigDecimal("89.00"), EUR));
@Override
public Optional<Product> findBySku(String sku) {
return Optional.ofNullable(products.get(sku));
}
}Because the application package owns ProductCatalog, the in-memory adapter depends inward. No application class imports InMemoryProductCatalog.
Put JSON at the HTTP Boundary
Create src/main/java/com/acme/catalog/adapter/rest/ProductResponse.java:
package com.acme.catalog.adapter.rest;
import java.math.BigDecimal;
import com.acme.catalog.domain.Product;
public record ProductResponse(String sku, String name, BigDecimal price, String currency) {
static ProductResponse from(Product product) {
return new ProductResponse(
product.sku(), product.name(), product.price(), product.currency().getCurrencyCode());
}
}Create src/main/java/com/acme/catalog/adapter/rest/ProductResource.java:
package com.acme.catalog.adapter.rest;
import com.acme.catalog.application.CatalogService;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/products")
@Produces(MediaType.APPLICATION_JSON)
public class ProductResource {
private final CatalogService catalogService;
public ProductResource(CatalogService catalogService) {
this.catalogService = catalogService;
}
@GET
@Path("/{sku}")
public ProductResponse getProduct(@PathParam("sku") String sku) {
return catalogService.findProduct(sku)
.map(ProductResponse::from)
.orElseThrow(() -> new NotFoundException("Unknown product: " + sku));
}
}I do not return Product directly. The extra ProductResponse record is intentional: it keeps the JSON shape in the REST adapter, so a serializer change does not require Jackson or Quarkus annotations on the domain record.
Run Checkstyle Before Compilation
Add these properties to the existing <properties> section in pom.xml:
<checkstyle.version>14.0.0</checkstyle.version>
<maven-checkstyle-plugin.version>3.6.0</maven-checkstyle-plugin.version>Then add the plugin inside <build><plugins>:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>${maven-checkstyle-plugin.version}</version>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>${checkstyle.version}</version>
</dependency>
</dependencies>
<executions>
<execution>
<id>enforce-architecture</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
<configuration>
<configLocation>config/checkstyle/checkstyle.xml</configLocation>
<consoleOutput>true</consoleOutput>
<excludeGeneratedSources>true</excludeGeneratedSources>
<failOnViolation>true</failOnViolation>
<includeResources>false</includeResources>
<includeTestResources>false</includeTestResources>
<includeTestSourceDirectory>false</includeTestSourceDirectory>
<outputFile>${project.build.directory}/checkstyle-result.sarif</outputFile>
<outputFileFormat>sarif</outputFileFormat>
<propertyExpansion>checkstyle.config.dir=${project.basedir}/config/checkstyle</propertyExpansion>
</configuration>
</plugin>I pin two versions because the Maven plugin and the Checkstyle engine are separate artifacts. The plugin does not automatically select the newest engine. Declaring Checkstyle as a plugin dependency follows the Maven plugin’s documented engine upgrade mechanism, and it makes parser upgrades visible in the POM.
I bind check to validate because a forbidden dependency should fail before compilation and tests consume more time. Generated sources stay outside the check; their imports follow generator constraints rather than this package policy. I also exclude test sources because integration tests in this example may cross layers. To enforce a separate test policy, set includeTestSourceDirectory to true and add explicit rules for the test packages.
outputFile and outputFileFormat write a SARIF report beside the console error. I upload that file in the CI section.
Declare the Package Policy
Create config/checkstyle/checkstyle.xml:
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
<property name="charset" value="UTF-8"/>
<module name="TreeWalker">
<module name="ImportControl">
<property name="file" value="${checkstyle.config.dir}/import-control.xml"/>
</module>
</module>
</module>The main configuration activates one check. Create config/checkstyle/import-control.xml to describe the allowed dependencies:
<?xml version="1.0"?>
<!DOCTYPE import-control PUBLIC
"-//Checkstyle//DTD ImportControl Configuration 1.5//EN"
"https://checkstyle.org/dtds/import_control_1_5.dtd">
<import-control pkg="com.acme.catalog" strategyOnMismatch="disallowed">
<allow pkg="java"/>
<allow pkg="com.acme.catalog.domain"/>
<subpackage name="application">
<allow pkg="jakarta.enterprise.context"/>
<allow pkg="com.acme.catalog.application"/>
</subpackage>
<subpackage name="adapter">
<subpackage name="catalog">
<allow pkg="jakarta.enterprise.context"/>
<allow pkg="com.acme.catalog.application"/>
<allow pkg="com.acme.catalog.adapter.catalog"/>
</subpackage>
<subpackage name="rest">
<allow pkg="jakarta.ws.rs"/>
<allow pkg="com.acme.catalog.application"/>
<allow pkg="com.acme.catalog.adapter.rest"/>
</subpackage>
</subpackage>
</import-control>I read this file from the outside in:
With
strategyOnMismatch="disallowed", Checkstyle rejects every import that no rule permits. New dependencies need an explicit decision.The root permits the JDK and domain types. Those rules apply to the domain package too, so a Quarkus, Jakarta, Jackson, or adapter import is rejected there.
The application package additionally permits CDI and its own types. It still cannot import either adapter.
Each adapter gets only its relevant framework API, the inward-facing application package, and its own package.
Child packages inherit the domain allowance, so the adapters can create or map Product without repeating the rule. For exact class rules, regular expressions, and deeper package policies, use the ImportControl configuration reference.
Run the gate:
./mvnw validateMaven reports zero violations:
[INFO] --- checkstyle:3.6.0:check (enforce-architecture) @ quarkus-checkstyle-boundaries ---
[INFO] Starting audit...
Audit done.
[INFO] You have 0 Checkstyle violations.
[INFO] BUILD SUCCESSZero violations only proves that the current sources satisfy the XML. I still want to see the XML reject the dependency from the opening.
Make the Boundary Fail on Purpose
Now I recreate the change from the opening. Assume a native-image issue leads us to register the domain record for reflection. Add this import and annotation to Product.java:
import java.util.Objects;
+import io.quarkus.runtime.annotations.RegisterForReflection;
+@RegisterForReflection
public record Product(String sku, String name, BigDecimal price, Currency currency) {Before I run Maven, I ask one question: which layer should own this reflection configuration? The annotation fixes a framework problem, but that does not make the domain its home.
Run the same command again:
./mvnw validateThe build now stops in validate:
[INFO] Starting audit...
[ERROR] .../domain/Product.java:7:1: Disallowed import - io.quarkus.runtime.annotations.RegisterForReflection. [ImportControl]
Audit done.
[INFO] There is 1 error reported by Checkstyle 14.0.0 with config/checkstyle/checkstyle.xml ruleset.
[INFO] BUILD FAILUREThe gate stops on the exact import I added. It names the file, line, import, and rule before I have to infer a reversed dependency from a later test failure.
Remove the annotation and import. If reflection registration is required, put it in Quarkus-facing code instead. Quarkus supports external reflection registration through a class that carries @RegisterForReflection(targets = Product.class), so an adapter can hold the framework hint while Product remains plain Java. First confirm that the code path uses reflection for this type; I would not add registration in advance.
Run ./mvnw validate once more and confirm the build returns to zero violations.
Test Behavior and Boundaries Separately
I do not treat an import check as a behavior test. The domain still needs unit tests, and the HTTP adapter still needs a test that starts Quarkus.
Create src/test/java/com/acme/catalog/domain/ProductTest.java:
package com.acme.catalog.domain;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigDecimal;
import java.util.Currency;
import org.junit.jupiter.api.Test;
class ProductTest {
@Test
void keepsValidValues() {
Product product = new Product(
"sku-1", "Mechanical Keyboard", new BigDecimal("129.00"), Currency.getInstance("EUR"));
assertEquals("sku-1", product.sku());
assertEquals(new BigDecimal("129.00"), product.price());
}
@Test
void rejectsNegativePrices() {
IllegalArgumentException failure = assertThrows(
IllegalArgumentException.class,
() -> new Product(
"sku-1", "Mechanical Keyboard", new BigDecimal("-0.01"), Currency.getInstance("EUR")));
assertEquals("price must not be negative", failure.getMessage());
}
}Create src/test/java/com/acme/catalog/adapter/rest/ProductResourceTest.java:
package com.acme.catalog.adapter.rest;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class ProductResourceTest {
@Test
void returnsKnownProduct() {
given()
.when().get("/products/sku-1")
.then()
.statusCode(200)
.body("sku", equalTo("sku-1"))
.body("name", equalTo("Mechanical Keyboard"))
.body("price", equalTo(129.00F))
.body("currency", equalTo("EUR"));
}
@Test
void returnsNotFoundForUnknownProduct() {
given()
.when().get("/products/missing")
.then()
.statusCode(404);
}
}Run all four tests:
./mvnw testMaven runs Checkstyle first, starts Quarkus for the resource test, and reports:
[INFO] You have 0 Checkstyle violations.
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESSThe four tests confirm the product invariants and HTTP responses. Checkstyle confirms that the implementation did not reverse the package dependencies. Putting both in the same build prevents a green HTTP test from hiding a forbidden import.
Publish Violations to Code Scanning
The plugin writes target/checkstyle-result.sarif on every check. I do not want the violation to appear only in a Maven log, so the CI job uploads that file to the code-scanning interface. Checkstyle documents its SARIF output support, and GitHub documents the upload-sarif action.
For GitHub Actions, the core job can look like this:
name: Architecture boundary
on:
push:
pull_request:
permissions:
contents: read
security-events: write
jobs:
checkstyle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
cache: maven
- name: Enforce architecture
run: ./mvnw --batch-mode validate
- name: Upload Checkstyle SARIF
if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: target/checkstyle-result.sarif
category: checkstyle-architectureif: always() runs the upload step even after Checkstyle fails Maven. The Maven step still leaves the job in a failed state. GitHub code-scanning availability and permissions depend on the repository, so I treat the console failure as the portable contract. On a supported repository, SARIF puts the finding on the affected file and line instead of asking the reviewer to search a Maven log.
Know What the Rule Cannot Prove
I would not present ImportControl as a complete architecture test. It is fast and easy to read because it checks a narrow part of the source, and that boundary has several gaps:
It inspects import declarations. A fully qualified reference such as
io.quarkus.runtime...in the middle of code can bypass an import rule.Checkstyle operates one source file at a time and does not resolve a complete Java type graph. Its documented cross-file and type-resolution limitations mean it cannot prove every semantic dependency.
String-based class names, reflection, generated bytecode, service loading, and runtime wiring are outside this check.
A broad suppression can bypass the package policy without review. Keep suppressions narrow and review them like dependency changes.
For a single-module service, this gate catches the ordinary accident: adding the wrong import in the wrong package. If the boundary separates independently released code, security-sensitive components, or a platform API used by many teams, I would split the layers into Maven modules. The Java compiler and Maven dependency graph can then reject dependencies that a source check cannot see.
Add a Persistence Adapter Without Reversing the Dependency
Imagine replacing the in-memory catalog with adapter.persistence. Before I edit the XML, I would answer three questions:
Which inward-facing port will the persistence adapter implement?
Which Jakarta Persistence or Hibernate packages does that adapter genuinely need?
Should the REST adapter ever import the persistence adapter directly?
I would permit the new adapter to use the application and domain packages plus its persistence APIs. I would not give the domain, application, or REST packages access to persistence. Adding the package and its policy in the same change makes that decision visible before other code starts depending on the adapter.
Checkstyle does not choose an architecture for the team. It records a decision we already made and rejects the import that contradicts it. Quarkus stays in the adapter packages, and the domain remains ordinary Java that I can test, move, and read without starting a runtime.


