Your Quarkus Architecture Is Drifting. JQAssistant Can Prove It.
Using graph-based analysis to detect and prevent architectural decay in Java applications
I ran into JQAssistant the first time on a large Java system where the architecture looked clean on slides but not in the code.
Layers existed in theory. In practice, everything depended on everything.
Code reviews did not catch it. Static analyzers did not care.
The system worked. Until it didn’t.
This is exactly the gap JQAssistant fills.
JQAssistant does not lint your code.
It models your codebase as a graph and lets you ask architectural questions using Cypher.
In this tutorial, we wire JQAssistant into a Quarkus application and use it to:
Enforce a layered architecture
Validate CDI usage
Detect structural code smells
Fail the build when architecture rules are violated
This is not about academic architecture diagrams.
This is about keeping real systems honest over time.
What You Will Build
We will create a small but realistic Quarkus application:
Domain, repository, service, and REST layers
CDI-based wiring
In-memory persistence for simplicity
Then we will:
Scan the code with JQAssistant
Define architectural rules in AsciiDoc
Run Cypher-based constraints
Inspect violations in reports and Neo4j
Integrate everything into the Maven build
No mock examples. No pseudocode.
Prerequisites
Before starting, make sure you have:
JDK 17 or later
Maven 3.8+
Basic Quarkus knowledge
Any Java IDE
You do not need Neo4j installed. JQAssistant ships with an embedded server.
Project Setup
Create a New Quarkus Project
We start with a standard Quarkus REST application. Either follow along or grab the code from my repository.
mvn io.quarkus:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=quarkus-jqassistant-demo \
-DclassName="com.example.GreetingResource" \
-Dpath="/hello"
-Dextensions="rest-jackson"
cd quarkus-jqassistant-demoThis gives us a working Quarkus application we can extend.
Before touching JQAssistant, verify the app builds:
mvn clean testA green build here avoids confusion later.
Add JQAssistant to the Build
JQAssistant integrates via a Maven plugin.
It scans bytecode and sources after compilation.
Open pom.xml and add the plugin inside <build>:
<build>
<plugins>
<plugin>
<groupId>com.buschmais.jqassistant</groupId>
<artifactId>jqassistant-maven-plugin</artifactId>
<version>2.8.0</version>
<executions>
<execution>
<goals>
<goal>scan</goal>
<goal>analyze</goal>
</goals>
</execution>
</executions>
<configuration>
<failOnSeverity>MAJOR</failOnSeverity>
<warnOnSeverity>MINOR</warnOnSeverity>
</configuration>
</plugin>
</plugins>
</build>
This configuration tells Maven:
Scan the project
Apply rules
Fail the build on MAJOR violations
Nothing breaks yet. We have no rules.
Building a Sample Application
Before enforcing architecture, we need something to enforce.
Domain Model
Create src/main/java/com/example/domain/Customer.java:
package com.example.domain;
public class Customer {
private Long id;
private String name;
private String email;
public Customer() {
}
public Customer(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}This is a simple POJO.
No annotations. No framework coupling.
That is intentional.
Repository Layer
Create src/main/java/com/example/repository/CustomerRepository.java:
package com.example.repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import com.example.domain.Customer;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class CustomerRepository {
private final ConcurrentHashMap<Long, Customer> store = new ConcurrentHashMap<>();
private final AtomicLong idGenerator = new AtomicLong(1);
public Customer save(Customer customer) {
if (customer.getId() == null) {
customer.setId(idGenerator.getAndIncrement());
}
store.put(customer.getId(), customer);
return customer;
}
public Optional<Customer> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
public List<Customer> findAll() {
return new ArrayList<>(store.values());
}
public void deleteById(Long id) {
store.remove(id);
}
}This repository:
Is CDI-managed
Has no Quarkus-specific APIs
Keeps dependencies clean
This will matter later.
Service Layer
Create src/main/java/com/example/service/CustomerService.java:
package com.example.service;
import java.util.List;
import java.util.Optional;
import com.example.domain.Customer;
import com.example.repository.CustomerRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class CustomerService {
@Inject
CustomerRepository repository;
public Customer createCustomer(Customer customer) {
validate(customer);
return repository.save(customer);
}
public Optional<Customer> getCustomer(Long id) {
return repository.findById(id);
}
public List<Customer> getAllCustomers() {
return repository.findAll();
}
public void deleteCustomer(Long id) {
repository.deleteById(id);
}
private void validate(Customer customer) {
if (customer.getName() == null || customer.getName().isBlank()) {
throw new IllegalArgumentException(”Customer name is required”);
}
if (customer.getEmail() == null || !customer.getEmail().contains(”@”)) {
throw new IllegalArgumentException(”Valid email is required”);
}
}
}This service:
Depends only on repository and domain
Is correctly scoped
Contains business logic
Exactly the pattern we want to enforce.
REST Resource
Move and rename the scaffolded GreetingResource to src/main/java/com/example/resource/CustomerResource.java and replace the content:
package com.example.resource;
import java.util.List;
import com.example.domain.Customer;
import com.example.service.CustomerService;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/customers”)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CustomerResource {
@Inject
CustomerService customerService;
@GET
public List<Customer> getAllCustomers() {
return customerService.getAllCustomers();
}
@GET
@Path(”/{id}”)
public Response getCustomer(@PathParam(”id”) Long id) {
return customerService.getCustomer(id)
.map(c -> Response.ok(c).build())
.orElse(Response.status(Response.Status.NOT_FOUND).build());
}
@POST
public Response createCustomer(Customer customer) {
try {
Customer created = customerService.createCustomer(customer);
return Response.status(Response.Status.CREATED).entity(created).build();
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.BAD_REQUEST).entity(e.getMessage()).build();
}
}
@DELETE
@Path(”/{id}”)
public Response deleteCustomer(@PathParam(”id”) Long id) {
customerService.deleteCustomer(id);
return Response.noContent().build();
}
}At this point, the application is complete.
Run it once:
mvn quarkus:devConfirm /customers works.
curl -X POST http://localhost:8080/customers \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "john.doe@example.com"
}'Expected output:
{
“id”: 1,
“name”: “John Doe”,
“email”: “john.doe@example.com”
}Then stop the server.
Defining Architecture Rules
Now we get to the reason we are here.
Create JQAssistant Directory
JQAssistant looks for rules in a top-level jqassistant directory.
mkdir jqassistantThis directory is versioned.
Your architecture becomes part of your source control.
Architecture Rules
Create jqassistant/architecture-rules.xml.
This file defines constraints.
Constraints can fail the build.
The rules below enforce:
Layered dependencies
Naming conventions
CDI scope usage
Basic code quality signals
A group is a set of rules (i.e. concepts, constraints or other groups) that shall be executed together by including them with the option to overwrite their default severity.
<group id=”default”>
<includeConcept refId=”layer-*”/>
<includeConstraint refId=”*”/>
</group>Architectural Constraints
<!-- ========================================================= -->
<!-- Architecture Constraints -->
<!-- ========================================================= -->
<constraint id=”resource-depends-on-service”
severity=”major”>
<requiresConcept refId=”layer-resource”/>
<requiresConcept refId=”layer-service”/>
<requiresConcept refId=”layer-domain”/>
<description>
REST resources must only depend on services or domain classes
</description>
<cypher><![CDATA[
MATCH (r:Resource)-[:DEPENDS_ON]->(d:Type)
WHERE NOT d:Service
AND NOT d:Domain
AND NOT d.fqn STARTS WITH ‘java.’
AND NOT d.fqn STARTS WITH ‘jakarta.’
RETURN r.fqn AS Resource, d.fqn AS IllegalDependency
]]></cypher>
</constraint>
<constraint id=”service-depends-on-repository-or-domain”
severity=”major”>
<requiresConcept refId=”layer-service”/>
<requiresConcept refId=”layer-repository”/>
<requiresConcept refId=”layer-domain”/>
<description>
Services must only depend on repositories or domain classes
</description>
<cypher><![CDATA[
MATCH (s:Service)-[:DEPENDS_ON]->(d:Type)
WHERE NOT d:Repository
AND NOT d:Domain
AND NOT d.fqn STARTS WITH ‘java.’
AND NOT d.fqn STARTS WITH ‘jakarta.’
RETURN s.fqn AS Service, d.fqn AS IllegalDependency
]]></cypher>
</constraint>If a REST resource talks directly to a repository, the build fails.
No discussion. No review comment. Just failure.
Naming and CDI Rules
<!-- ========================================================= -->
<!-- Naming Conventions -->
<!-- ========================================================= -->
<constraint id=”repository-naming”
severity=”minor”>
<requiresConcept refId=”layer-repository”/>
<description>
Repository classes must end with ‘Repository’
</description>
<cypher><![CDATA[
MATCH (r:Repository)
WHERE NOT r.name ENDS WITH ‘Repository’
RETURN r.fqn
]]></cypher>
</constraint>
<constraint id=”service-naming”
severity=”minor”>
<requiresConcept refId=”layer-service”/>
<description>
Service classes must end with ‘Service’
</description>
<cypher><![CDATA[
MATCH (s:Service)
WHERE NOT s.name ENDS WITH ‘Service’
RETURN s.fqn
]]></cypher>
</constraint>
<!-- ========================================================= -->
<!-- CDI Rules -->
<!-- ========================================================= -->
<constraint id=”service-must-be-applicationscoped”
severity=”major”>
<requiresConcept refId=”layer-service”/>
<description>
Services must be annotated with @ApplicationScoped
</description>
<cypher><![CDATA[
MATCH (s:Service)
WHERE NOT EXISTS {
MATCH (s)-[:ANNOTATED_BY]->(ann:Type)
WHERE ann.fqn = ‘jakarta.enterprise.context.ApplicationScoped’
}
RETURN s.fqn
]]></cypher>
</constraint>This catches forgotten scopes early.
Before load tests or production.
Code Quality Signals
These are not absolute truths.
They are early warnings.
<!-- ========================================================= -->
<!-- Code Quality Rules -->
<!-- ========================================================= -->
<constraint id=”unused-private-methods”
severity=”minor”>
<description>
Private methods should be invoked at least once
</description>
<cypher><![CDATA[
MATCH (t:Type)-[:DECLARES]->(m:Method)
WHERE m.visibility = ‘private’
AND NOT EXISTS {
MATCH (m)<-[:INVOKES]-()
}
RETURN t.fqn AS Type, m.name AS Method
]]></cypher>
</constraint>
<constraint id=”long-methods”
severity=”minor”>
<description>
Methods should not exceed 50 effective lines
</description>
<cypher><![CDATA[
MATCH (t:Type)-[:DECLARES]->(m:Method)
WHERE m.effectiveLineCount > 50
RETURN t.fqn AS Type, m.name AS Method, m.effectiveLineCount AS Lines
]]></cypher>
</constraint>You can tune or remove these later.
Concepts
The information created by the scanner represents the structure of a software project on a raw level. Concept rules allow enriching the database with higher level information to ease the process of writing queries that check for violations (i.e. constraints) . This typically means adding labels, properties or relations.
Define Concepts
<!-- ========================================================= -->
<!-- Concepts -->
<!-- ========================================================= -->
<concept id=”layer-resource”>
<description>Marks REST resource classes</description>
<cypher><![CDATA[
MATCH (t:Type)
WHERE t.fqn =~ ‘.*\\.resource\\..*’
SET t:Resource
RETURN t.fqn
]]></cypher>
</concept>
<concept id=”layer-service”>
<description>Marks service classes</description>
<cypher><![CDATA[
MATCH (t:Type)
WHERE t.fqn =~ ‘.*\\.service\\..*’
SET t:Service
RETURN t.fqn
]]></cypher>
</concept>
<concept id=”layer-repository”>
<description>Marks repository classes</description>
<cypher><![CDATA[
MATCH (t:Type)
WHERE t.fqn =~ ‘.*\\.repository\\..*’
SET t:Repository
RETURN t.fqn
]]></cypher>
</concept>
<concept id=”layer-domain”>
<description>Marks domain classes</description>
<cypher><![CDATA[
MATCH (t:Type)
WHERE t.fqn =~ ‘.*\\.domain\\..*’
SET t:Domain
RETURN t.fqn
]]></cypher>
</concept>Now your architecture is explicit and queryable.
Running JQAssistant
Step 10: Scan the Codebase
mvn clean compile jqassistant:scanThis populates the Neo4j graph.
Then run analysis:
mvn jqassistant:analyzeIf rules are violated, the build fails here.
Start the Neo4j Server
mvn jqassistant:serverOpen http://localhost:7474/?dbms=bolt://localhost:7687&preselectAuthMethod=NO_AUTH
Now you can explore your code as a graph. Here for example the maven dependencies:
You learned how to:
Model a Quarkus application as a graph
Define architecture as executable rules
Enforce layering and CDI usage
Detect structural issues early
Integrate architecture checks into CI
JQAssistant does not replace reviews.
It removes arguments about facts.
Architecture stops being a guideline and becomes code.
That is the point.





The graph-based approach to architectural enforcement is way more powerful than traditional static analysis. What I really like is how you've made architecture violations objectively detectable rather than subjectively debatable. The Cypher queries for layering constraints are clean too, though I wonder how this scales when you hit polyglot codebases where some boundries cross JVM languages. Definetly trying this on a legacy system where "layers" are more aspirational than actual.
Hi Markus,
thanks a lot for the great write‑up—this was super helpful to get started with jQAssistant in a Quarkus app! 🙌
While applying the tutorial, I ran into two small issues (tested with your Quarkus/jQAssistant version):
1. Annotation check path
The annotation match needs the intermediate node between ANNOTATED_BY and the annotation Type. Using OF_TYPE fixed it for me:
MATCH (s)-[:ANNOTATED_BY]->()-[:OF_TYPE]->(ann:Type)
Without [:OF_TYPE], the pattern won’t reach the annotation’s Type node.
2. Dependency constraint
In the “... must not depend on …” rule, I needed one extra guard to avoid a false positive on the synthetic void type:
AND d.fqn <> 'void'
If this was on purpose (e.g., simplified for the tutorial), please disregard; otherwise, hopefully this helps someone using the code base - it definitely helped me.
Thanks again for sharing your knowledge and for all your work in the Quarkus community! 🚀