When building Spring Boot applications, it’s crucial to maintain proper encapsulation by returning DTOs (Data Transfer Objects) instead of entities directly from your endpoints. This article demonstrates how to enforce this practice using ArchUnit.
The Problem
In Spring Boot applications, we typically define endpoints using @RestController that return either DTOs or ResponseEntity<DTO>. While we might carefully design our endpoint return types, nested relationships within DTOs can accidentally expose entities:
@Entity
class Person {
@OneToMany
List<Profession> professions;
}
// Bad practice: DTO containing an entity
class PersonWrongDto {
@OneToMany
List<Profession> professions; // Still using the entity!
}
// Good practice: DTO containing another DTO
class PersonCorrectDto {
@OneToMany
List<ProfessionDto> professions; // Using DTOs consistently
}The Solution: ArchUnit Testing
We can enforce proper DTO encapsulation using ArchUnit, a library for testing architectural rules in Java applications. Here’s how to implement a test that ensures all hibernate relationship fields within DTOs are also DTOs:
@ActiveProfiles(value = "test")
class ViewFieldsTest {
private static final JavaClasses CLASSES = new ClassFileImporter()
.importPackages("your.package.name");
@Test
void fieldsInViews_withHibernateRelationshipMappings_shouldBeAlsoViews() {
FieldsShouldConjunction rule = fields()
.that()
.areDeclaredInClassesThat().implement(View.class)
.or().areDeclaredInClassesThat().implement(AbstractView.class)
.and(areAnnotatedWithAny(Set.of(
OneToOne.class,
OneToMany.class,
ManyToOne.class,
ManyToMany.class
)))
.should(beAViewAndNotAnEntity());
rule.check(CLASSES);
}
private static @NotNull DescribedPredicate<JavaField> areAnnotatedWithAny(
final Set<Class<? extends Annotation>> annotations) {
return new DescribedPredicate<>("are annotated with any annotation from " + annotations) {
@Override
public boolean test(JavaField field) {
return annotations.stream().anyMatch(field::isAnnotatedWith);
}
};
}
private static @NotNull ArchCondition<JavaField> beAViewAndNotAnEntity() {
return new ArchCondition<>("be an entity view and not an entity") {
@Override
public void check(JavaField item, ConditionEvents events) {
for (JavaClass type : item.getAllInvolvedRawTypes()) {
boolean isEntity = type.isAnnotatedWith(Entity.class);
boolean isView = type.isAssignableTo(View.class)
|| type.isAssignableTo(AbstractView.class);
if (isEntity && !isView) {
events.add(SimpleConditionEvent.violated(item, String.format(
"Field %s in %s is not a view",
item.getName(), item.getOwner().getName()
)));
}
}
}
};
}
}How It Works
The test performs three main checks:
- Identifies classes implementing the
Viewinterface (our DTO marker interface) - Locates fields annotated with Hibernate relationship annotations (
@OneToMany,@ManyToOne, etc.) - Verifies that these relationship fields are also DTOs (implement
View) and not entities
When violations are found, the test produces clear error messages identifying exactly where entities are being incorrectly used within DTOs.
Benefits
This approach offers several advantages:
- Maintains clean separation between entities and DTOs
- Prevents accidental entity exposure through nested relationships
- Supports JPQL projections for optimized database queries
- Provides immediate feedback during development
- Produces clear, actionable error messages
By enforcing these rules at compile-time, we can catch potential encapsulation breaches before they make it to production.