Guard Your Code: Enforcing Architecture Boundaries in Quarkus with ArchUnit
How BCE/ECB keeps Java applications clean, testable, and future-proof. With rules that fail the build when you break them.
Architecture is a constraint. Good constraints free teams to move fast without breaking the system. This tutorial shows how to codify architectural boundaries in a Quarkus app using ArchUnit, borrowing Boundary–Control–Entity (BCE) ideas popularized by Adam Bien. We’ll build a tiny “orders” service, express a few pragmatic rules, and enforce them with tests that fail the build when someone violates the boundaries.
Why bother? Because boundary erosion is a silent killer in Java projects. A single “quick” import from the web layer into persistence turns into a pattern. ArchUnit makes those mistakes visible in CI. BCE gives simple language for where things belong.
ArchUnit is an architecture test library for Java. It analyzes bytecode and lets you write rules in plain Java that run as JUnit tests. (ArchUnit, GitHub)
BCE groups code by business component and distinguishes boundaries (I/O), control (logic), and entities (data). See Adam Bien’s BCE quickstarter and posts for background. (adam-bien.com, bce.design)
Prerequisites
Java 17+
Maven 3.9+
Quarkus CLI (optional)
Check versions:
java -version
mvn -v
Bootstrap the project
Create a minimal REST API:
quarkus create app com.example:archunit-bce:1.0.0 \
-x rest-jackson,hibernate-orm-panache,jdbc-postgres --no-code
cd archunit-bce
The rest-jackson
alias brings Quarkus REST with Jackson. Hibernate-orm-panache gives us the Quarkus uptimized Hibernate integration, and jdbc-postgres uses Postgresql as the local database.
If you just want to give this a quick testdrive, feel free to grab it from my Github repository.
Add ArchUnit
Update pom.xml
to use the current Quarkus platform and ArchUnit JUnit 5 support.
<!-- ArchUnit JUnit 5 -->
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.4.1</version>
<scope>test</scope>
</dependency>
archunit-junit5
is the simple, recommended artifact for JUnit 5.
Add one line to the
src/main/resources/application.properties
. So Hibernate can initialize the database for us.
quarkus.hibernate-orm.schema-management.strategy=drop-and-create
Also create a new file: src/main/resources/import.sql.
This file is seeding the database with some example entries.
-- Sample data for OrderEntity table
INSERT INTO OrderEntity (id,customer, item, quantity) VALUES ('1','John Doe', 'Laptop', 1);
INSERT INTO OrderEntity (id,customer, item, quantity) VALUES ('2','Jane Smith', 'Smartphone', 2);
INSERT INTO OrderEntity (id,customer, item, quantity) VALUES ('3','Robert Johnson', 'Headphones', 3);
INSERT INTO OrderEntity (id,customer, item, quantity) VALUES ('4','Emily Davis', 'Monitor', 1);
INSERT INTO OrderEntity (id,customer, item, quantity) VALUES ('5','Michael Brown', 'Keyboard', 2);
INSERT INTO OrderEntity (id,customer, item, quantity) VALUES ('6','Sarah Wilson', 'Mouse', 5);
INSERT INTO OrderEntity (id,customer, item, quantity) VALUES ('7','David Miller', 'Printer', 1);
INSERT INTO OrderEntity (id,customer, item, quantity) VALUES ('8','Jennifer Taylor', 'External Hard Drive', 2);
INSERT INTO OrderEntity (id,customer, item, quantity) VALUES ('9','Thomas Anderson', 'USB Cable', 10);
INSERT INTO OrderEntity (id,customer, item, quantity) VALUES ('10','Lisa Garcia', 'Webcam', 1);
-- Restart primary key sequence
ALTER SEQUENCE orderentity_seq RESTART WITH 11;
Note that we are restarting the orderentity_seq
with 11, so we don’t confuse Hibernate when inserting new entities into the database. Otherwise it wouldn’t know that we already seeded the first 10 values.
Implement BCE structure
We’ll build one business component: orders
.
Package layout:
com.example.orders
├─ boundary // I/O: HTTP resources
├─ control // application logic
└─ entity // domain model + persistence annotations
Entity
src/main/java/com/example/orders/entity/OrderEntity.java
package com.example.orders.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
@Entity
public class OrderEntity {
@Id
@GeneratedValue
private Long id;
@Column(nullable = false)
private String customer;
@Column(nullable = false)
private String item;
private int quantity;
public OrderEntity() {
}
public OrderEntity(String customer, String item, int quantity) {
this.customer = customer;
this.item = item;
this.quantity = quantity;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getCustomer() {
return customer;
}
public String getItem() {
return item;
}
public int getQuantity() {
return quantity;
}
public void setCustomer(String customer) {
this.customer = customer;
}
public void setItem(String item) {
this.item = item;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
}
Control
src/main/java/com/example/orders/control/OrderService.java
package com.example.orders.control;
import java.util.List;
import com.example.orders.entity.OrderEntity;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class OrderService implements PanacheRepository<OrderEntity> {
@Transactional
public OrderEntity create(String customer, String item, int quantity) {
OrderEntity e = new OrderEntity(customer, item, quantity);
persist(e);
return e;
}
public java.util.Optional<OrderEntity> findById(String id) {
return find("id", id).firstResultOptional();
}
public List<OrderEntity> list() {
return listAll();
}
}
Boundary
src/main/java/com/example/orders/boundary/OrderResource.java
package com.example.orders.boundary;
import java.net.URI;
import java.util.List;
import com.example.orders.control.OrderService;
import com.example.orders.entity.OrderEntity;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
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("/orders")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OrderResource {
@Inject
OrderService service;
public record CreateOrder(String customer, String item, int quantity) {
}
@POST
public Response create(CreateOrder req) {
if (req == null || req.customer() == null || req.item() == null) {
throw new BadRequestException("customer and item required");
}
OrderEntity created = service.create(req.customer(), req.item(), req.quantity());
return Response.created(URI.create("/orders/" + created.getId()))
.entity(created)
.build();
}
@GET
public List<OrderEntity> list() {
return service.list();
}
@GET
@Path("{id}")
public OrderEntity get(@PathParam("id") String id) {
return service.findById(id).orElseThrow(NotFoundException::new);
}
}
Run and verify:
./mvnw quarkus:dev
Run the application:
# new terminal
curl -s localhost:8080/orders | jq
curl -s -X POST localhost:8080/orders \
-H 'content-type: application/json' \
-d '{"customer":"alice","item":"notebook","quantity":2}' | jq
Architecture rules with ArchUnit
The terms Boundary–Control–Entity (BCE) and Entity–Control–Boundary (ECB) describe a lightweight architectural pattern. It dates back to Ivar Jacobson’s Object-Oriented Software Engineering and was later popularized in the Java ecosystem by Adam Bien, who uses it as a pragmatic way to structure enterprise applications.
Instead of separating code horizontally (controllers in one package, services in another, entities in yet another), BCE organizes it vertically by business capability. Each component (for example, orders
) has its own boundary, control, and entity. This vertical slice is easy to reason about, test, and eventually extract as a microservice if needed.
The Three Roles
Boundary
Interfaces with the outside world (HTTP, messaging, CLI).
In Quarkus: JAX-RS resources, WebSocket endpoints, or messaging consumers.
Focus: translate protocols into domain calls.
Should not contain business rules or persistence concerns.
Control
Implements use cases and orchestrates domain operations.
In Quarkus: CDI beans annotated with
@ApplicationScoped
.Free of HTTP and persistence details.
Entity
Encapsulates core domain state and invariants.
Typically JPA entities (
@Entity
) or domain objects.Must not depend on boundary or control.
Adam Bien summarizes it simply: “Boundary talks to Control, Control talks to Entity, Entity talks to no one.”
BCE vs. traditional layering
It’s easy to confuse BCE with three-tier layering. The difference is intent:
Horizontal layering: all controllers in one package, all services in another, all repositories elsewhere. This often leads to a “big ball of mud.”
BCE: each business capability is self-contained and vertical. Inside
orders
you find everything related: boundary, control, entity. This promotes cohesion and keeps coupling localized.
Let’s implement this as an architecture test:
BCE package boundaries
Only boundary can talk to control.
Control can talk to entity.
Entity has no outgoing deps to boundary or control.
Naming conventions
..boundary..
classes end withResource
...control..
classes end withService
...entity..
classes end withEntity
.
No cycles across slices (e.g.,
orders
vs future components).JAX-RS annotations live in boundary only.
Entities must be annotated with
@Entity
and live in..entity..
.
Create the test class: src/test/java/com/example/architecture/ArchitectureTest.java
package com.example.architecture;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.lang.ArchRule;
import jakarta.persistence.Entity;
import jakarta.ws.rs.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
/**
* Architecture tests enforcing BCE (Boundary-Control-Entity) pattern rules.
*
* These tests ensure:
* - Proper layer separation and dependencies
* - Consistent naming conventions across layers
* - No circular dependencies between business components
* - Correct placement of framework annotations
*/
public class ArchitectureTest {
private static JavaClasses classes;
@BeforeAll
static void loadClasses() {
classes = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("com.example");
}
@Test
void bce_layers_are_respected() {
// BCE Package Boundaries Rule:
// - Only boundary can talk to control
// - Control can talk to entity
// - Entity has no outgoing deps to boundary or control
ArchRule rule = layeredArchitecture()
.consideringOnlyDependenciesInAnyPackage("com.example..")
.layer("Boundary").definedBy("..orders.boundary..")
.layer("Control").definedBy("..orders.control..")
.layer("Entity").definedBy("..orders.entity..")
// BCE layer access rules:
.whereLayer("Boundary").mayOnlyAccessLayers("Control", "Entity")
.whereLayer("Control").mayOnlyAccessLayers("Entity")
.whereLayer("Entity").mayNotAccessAnyLayer();
rule.check(classes);
}
@Test
void naming_conventions_match_packages() {
// Naming Conventions Rule:
// - ..boundary.. classes end with Resource
// - ..control.. classes end with Service
// - ..entity.. classes end with Entity
classes().that().resideInAnyPackage("..orders.boundary..")
.and().areNotInnerClasses()
.and().areNotNestedClasses()
.should().haveSimpleNameEndingWith("Resource")
.check(classes);
classes().that().resideInAnyPackage("..orders.control..")
.and().areNotInnerClasses()
.and().areNotNestedClasses()
.should().haveSimpleNameEndingWith("Service")
.check(classes);
classes().that().resideInAnyPackage("..orders.entity..")
.and().areNotInnerClasses()
.and().areNotNestedClasses()
.should().haveSimpleNameEndingWith("Entity")
.check(classes);
}
@Test
void no_cycles_between_business_components() {
// No Cycles Rule:
// - No cycles across slices (e.g., orders vs future components)
slices().matching("com.example.(*)..")
.should().beFreeOfCycles()
.check(classes);
}
@Test
void jaxrs_annotations_only_in_boundary() {
// JAX-RS Annotations Rule:
// - JAX-RS annotations live in boundary only
classes().that().areAnnotatedWith(Path.class)
.and().areNotInnerClasses()
.and().areNotNestedClasses()
.should().resideInAnyPackage("..orders.boundary..")
.check(classes);
}
@Test
void entities_are_annotated_and_in_entity_package() {
// Entity Annotation Rule:
// - Entities must be annotated with @Entity and live in ..entity..
classes().that().areAnnotatedWith(Entity.class)
.and().areNotInnerClasses()
.and().areNotNestedClasses()
.should().resideInAnyPackage("..orders.entity..")
.check(classes);
}
}
Run the tests:
./mvnw test
ArchUnit loads compiled bytecode and enforces the rules as JUnit tests. Break a rule and the build fails. (ArchUnit)
Wait: Entities as response types?
This is where BCE meets pragmatism.
Purist BCE says: never expose entities directly in boundaries. Entities are internal, and exposing them risks schema drift, leaking persistence annotations, or revealing sensitive fields. Boundaries should return DTOs instead.
Pragmatic BCE says: for small services or internal APIs, returning entities directly can be fine. It saves boilerplate, and Quarkus REST with Jackson can serialize JPA entities out of the box.
Risks of exposing entities:
Changing an entity field breaks API clients.
Lazy-loading can throw runtime exceptions.
Sensitive fields may leak unintentionally.
Recommended practice:
For public or cross-team APIs, always use DTOs.
For prototypes or internal tools, returning entities can be acceptable.
Example with DTO mapping:
public record OrderResponse(String id, String customer, String item, int quantity) {}
@GET
public List<OrderResponse> list() {
return service.list().stream()
.map(o -> new OrderResponse(o.getId(), o.getCustomer(), o.getItem(), o.getQuantity()))
.toList();
}
ArchUnit rules can enforce whichever style you choose. In pragmatic mode, boundaries may access entities. In strict mode, they must not.
Why BCE/ECB matters in Quarkus
Cognitive simplicity: everyone knows where a class belongs.
Testability: control and entity classes are free of HTTP and persistence noise.
Evolution: vertical slices can later become independent microservices.
Executable rules: with ArchUnit, BCE boundaries aren’t just guidelines—they’re verified in every build.
Extending the rules (pragmatic vs strict)
The updated ArchitectureTest
reflects this flexibility. In pragmatic mode, the boundary may access both control and entity. In strict mode, it may only access control. Here’s how you can toggle:
Strict enforcement example:
.whereLayer("Boundary").mayOnlyAccessLayers("Control")
Pragmatic enforcement (default in our test):
.whereLayer("Boundary").mayOnlyAccessLayers("Control", "Entity")
You can even control this with Maven profiles:
<profiles>
<profile>
<id>pragmatic</id>
<activation><activeByDefault>true</activeByDefault></activation>
<properties>
<arch.boundary.may.access.entity>true</arch.boundary.may.access.entity>
</properties>
</profile>
<profile>
<id>strict</id>
<properties>
<arch.boundary.may.access.entity>false</arch.boundary.may.access.entity>
</properties>
</profile>
</profiles>
Switch profiles in CI to enforce DTO-only boundaries:
./mvnw -Pstrict test
Other extensions you may add:
Forbid field injection, enforce constructor injection.
Disallow
java.util.logging
, enforce Quarkus Logging.Block
@Transactional
on boundaries.Restrict DTO packages to boundaries.
Production notes
Start pragmatic. Return entities directly in prototypes or internal APIs.
Switch to strict once the API stabilizes or becomes public.
Keep rules small. Only enforce what prevents real-world erosion.
Fail fast in CI. Treat rule violations as early warnings.
Document exceptions. If you must break a rule, annotate and time-box the decision.
What-ifs and variations
Multiple business components: Add
payments
,inventory
, etc. Each gets its own boundary, control, entity. ArchUnit’sslices()
rule ensures no cycles between them.Hexagonal naming: Some teams prefer
adapter.in
,application
,domain
instead of boundary/control/entity. The principle is the same; only package names differ.Database-backed control: Replace the in-memory store in control with a Panache repository, but keep persistence details out of the boundary.
Stricter compliance: Add rules for DTO-only responses once APIs are exposed to third parties.
Troubleshooting
“No tests found”: ensure
archunit-junit5
is on the test classpath, tests use JUnit 5, and the importer points at the correct root (com.example
).Unexpected violations: check
consideringOnlyDependenciesInAnyPackage("com.example..")
is set, so you don’t get noise from third-party frameworks.Lazy-loading exceptions: if you still return entities, make sure to eagerly fetch required associations or switch to DTOs.
Further reading
ArchUnit Getting Started and User Guide. (ArchUnit)
ArchUnit GitHub. (GitHub)
BCE overview and examples by Adam Bien. (adam-bien.com, bce.design)
Keep boundaries crisp. Let tests hold the line.