
Por qué tests de arquitectura
En cualquier proyecto que crece más allá de un puñado de clases, las decisiones de arquitectura se documentan en wikis, diagramas y conversaciones. Y conforme pasa el tiempo, esa documentación se desincroniza del código real. Un compañero nuevo añade un import "porque era más fácil", un servicio acaba consultando el repositorio de otro contexto, un controller termina inyectando un EntityManager. Las reglas siguen escritas en el README, pero ya no se cumplen.
ArchUnit traduce esas reglas a tests ejecutables. En lugar de "no inyectar repositorios en controllers" como recomendación, escribes una regla que falla la build si alguien lo intenta. La diferencia es enorme: la guía pasa de ser "un consejo" a ser "un check obligatorio" en cada PR.
Las reglas de arquitectura sin tests son aspiracionales. Las reglas con tests son exigibles. La diferencia entre las dos cosas se nota al cabo de seis meses, cuando llegan tres developers nuevos y el proyecto crece a doble velocidad.
graph TD
A[Pull Request] --> B[Tests unitarios]
A --> C[Tests de integración]
A --> D[Tests de arquitectura ArchUnit]
D --> E{Reglas se cumplen?}
E -->|Si| F[Build verde, puede mergear]
E -->|No| G[Build roja, PR bloqueado]
Instalación y primer test
ArchUnit se añade como dependencia de test:
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.4.0</version>
<scope>test</scope>
</dependency>
Para Gradle:
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.0'
A pesar del nombre
junit5, el módulo es compatible con JUnit 6. La librería se mantiene activa y la integración con la nueva plataforma JUnit es transparente.
El primer test verifica una regla simple: ningún paquete del proyecto debe depender directamente de java.util.logging (porque queremos forzar el uso de SLF4J):
@AnalyzeClasses(packages = "com.certidevs.empleados",
importOptions = ImportOption.DoNotIncludeTests.class)
class ArquitecturaLoggingTest {
@ArchTest
static final ArchRule no_usar_java_util_logging =
noClasses()
.should().dependOnClassesThat()
.resideInAPackage("java.util.logging..")
.because("Usamos SLF4J en todo el proyecto");
}
Tres elementos clave:
@AnalyzeClasses(packages = "...")define el alcance del análisis.ImportOption.DoNotIncludeTests.classexcluye el código de test (las reglas se aplican a producción).@ArchTestes la anotación que JUnit 6 reconoce para ejecutar la regla.
LayeredArchitecture: controllers > services > repositories
La regla más común es la arquitectura por capas. ArchUnit incluye una DSL específica para expresarla:
@AnalyzeClasses(packages = "com.certidevs.empleados",
importOptions = ImportOption.DoNotIncludeTests.class)
class ArquitecturaCapasTest {
@ArchTest
static final ArchRule capas_respetadas =
layeredArchitecture()
.consideringAllDependencies()
.layer("Controllers").definedBy("..controller..")
.layer("Services").definedBy("..service..")
.layer("Repositories").definedBy("..repository..")
.layer("Domain").definedBy("..domain..")
.whereLayer("Controllers").mayNotBeAccessedByAnyLayer()
.whereLayer("Services").mayOnlyBeAccessedByLayers("Controllers")
.whereLayer("Repositories").mayOnlyBeAccessedByLayers("Services")
.whereLayer("Domain").mayOnlyBeAccessedByLayers(
"Controllers", "Services", "Repositories");
}
Si un controller intenta inyectar un repositorio (saltándose el servicio), el test falla con un mensaje claro:
Architecture Violation: Layer 'Controllers' should only be accessed by no other layer,
but class 'com.certidevs.empleados.controller.EmpleadoController' depends on
'com.certidevs.empleados.repository.EmpleadoRepository'
El error mencionará la clase y la línea exacta, no un genérico "no cumple la arquitectura". Eso convierte ArchUnit en una herramienta práctica para revisar PRs.
Reglas custom con classes().that()...should()...
Más allá de las arquitecturas estándar, la DSL classes().that()...should()... permite expresar reglas específicas del proyecto. Aquí van los tres casos más útiles en Spring Boot.
Regla 1: los repositorios extienden JpaRepository
@ArchTest
static final ArchRule repositorios_extienden_jpa_repository =
classes()
.that().resideInAPackage("..repository..")
.and().haveSimpleNameEndingWith("Repository")
.should().beInterfaces()
.andShould().beAssignableTo(org.springframework.data.jpa.repository.JpaRepository.class)
.because("Estandarizamos en JpaRepository para coherencia y soporte completo de JPA");
Regla 2: las clases @Service viven en paquete service
@ArchTest
static final ArchRule services_en_paquete_service =
classes()
.that().areAnnotatedWith(org.springframework.stereotype.Service.class)
.should().resideInAPackage("..service..")
.because("Todos los @Service deben estar en un paquete service");
Regla 3: prohibido @Autowired en campos (forzar constructor injection)
@ArchTest
static final ArchRule no_field_injection =
noFields()
.should().beAnnotatedWith(org.springframework.beans.factory.annotation.Autowired.class)
.because("Usamos constructor injection con @RequiredArgsConstructor");
Regla 4: el dominio no depende de Spring
@ArchTest
static final ArchRule domain_no_depende_de_spring =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..")
.because("El dominio debe ser puro POJO, sin dependencias de framework");
Esta última es fundamental en arquitecturas hexagonales: el dominio debe ser independiente de la infraestructura. Si alguien intenta meter @Component o @Transactional en una entidad de dominio, el test lo bloquea.
Regla 5: solo el adaptador HTTP usa Jackson
@ArchTest
static final ArchRule jackson_solo_en_adaptador_http =
classes()
.that().dependOnClassesThat().resideInAPackage("com.fasterxml.jackson..")
.should().resideInAnyPackage("..adapter.in.web..", "..config..")
.because("Jackson es detalle de infraestructura, no debe filtrarse al dominio ni servicios");
Detección de ciclos
Los ciclos entre paquetes son una de las principales fuentes de bugs sutiles y código difícil de mantener. ArchUnit los detecta de un plumazo:
@ArchTest
static final ArchRule sin_ciclos_entre_paquetes =
slices()
.matching("com.certidevs.empleados.(*)..")
.should().beFreeOfCycles();
matching("com.certidevs.empleados.(*)..") define cada slice como el primer subpaquete bajo empleados. Si los paquetes empleados y departamentos se referencian mutuamente, el test falla mostrando el ciclo:
Architecture Violation: Slices matching '...empleados.(*)..' should be free of cycles
Cycle detected:
Slice 'empleados' references slice 'departamentos'
Slice 'departamentos' references slice 'empleados'
Los ciclos son veneno en proyectos grandes. Detectarlos en CI es uno de los retornos más altos de adoptar ArchUnit. Se descubren temprano, cuando refactorizar es barato.
Reglas para Spring Boot
Algunas reglas son específicas de las convenciones de un proyecto Spring Boot. Aquí van las que más se repiten en proyectos profesionales:
@ArchTest
static final ArchRule controllers_no_lanzan_excepciones_genericas =
noMethods()
.that().areDeclaredInClassesThat().resideInAPackage("..controller..")
.should().declareThrowableOfType(Exception.class)
.orShould().declareThrowableOfType(RuntimeException.class)
.because("Los controllers deben lanzar excepciones específicas");
@ArchTest
static final ArchRule transactional_solo_en_servicios =
classes()
.that().areAnnotatedWith(org.springframework.transaction.annotation.Transactional.class)
.should().resideInAPackage("..service..")
.because("@Transactional debe estar en la capa de servicio, no en controllers ni repos");
@ArchTest
static final ArchRule entidades_jpa_en_paquete_domain =
classes()
.that().areAnnotatedWith(jakarta.persistence.Entity.class)
.should().resideInAPackage("..domain..")
.orShould().resideInAPackage("..model..")
.because("Las entidades JPA representan el dominio");
@ArchTest
static final ArchRule no_referencias_a_jpa_desde_controller =
noClasses()
.that().resideInAPackage("..controller..")
.should().dependOnClassesThat()
.resideInAnyPackage("jakarta.persistence..", "org.hibernate..")
.because("Los controllers solo conocen DTOs, nunca entidades JPA");
La última regla es especialmente valiosa: si alguien intenta devolver una entidad JPA directamente desde un controller, el test falla. Esto previene problemas clásicos como serializar relaciones LAZY o exponer campos internos.
allowEmptyShould(true)
Por defecto, ArchUnit falla si una regla "no encuentra clases que cumplan la condición previa" (porque interpreta que algo está mal en la configuración). En proyectos pequeños o nuevos, esto puede dar falsos positivos.
@ArchTest
static final ArchRule services_en_paquete_service =
classes()
.that().areAnnotatedWith(Service.class)
.should().resideInAPackage("..service..")
.allowEmptyShould(true); // no falla si todavía no hay services
Útil cuando la regla está en su sitio antes de que existan clases que la apliquen, o en módulos con poco contenido.
Frozen rules para legacy code
Adoptar ArchUnit en un proyecto nuevo es trivial: las reglas están desde el primer commit y no hay violaciones. En un proyecto legacy, todas las reglas que escribas fallarán de entrada porque hay decenas de violaciones acumuladas.
Las frozen rules resuelven el problema: capturan el estado actual de violaciones (lo "congelan") y solo fallan si aparecen violaciones nuevas:
@ArchTest
static final ArchRule services_en_paquete_service =
FreezingArchRule.freeze(
classes()
.that().areAnnotatedWith(Service.class)
.should().resideInAPackage("..service..")
);
La primera vez que se ejecuta, ArchUnit guarda las violaciones en archunit_store/services_en_paquete_service.txt. En ejecuciones futuras compara las violaciones actuales contra el snapshot:
- Si la lista coincide → pasa.
- Si hay menos violaciones → pasa y actualiza el snapshot (alguien arregló parte del legacy).
- Si hay nuevas violaciones → falla.
Frozen rules son la diferencia entre "imposible adoptar ArchUnit en un proyecto de cinco años" y "lo añadimos hoy y empezamos a mejorar mañana". Mide deuda técnica de forma objetiva: cuanto más pequeño es el archivo de violaciones, más limpio está el proyecto.
Para configurar dónde se guarda el snapshot:
# src/test/resources/archunit.properties
freeze.store.default.path=archunit_store
freeze.refreeze=false
freeze.refreeze=true en una rama puntual permite regenerar el snapshot tras un refactor masivo.
Integración con CI
Para que ArchUnit aporte valor real, debe ejecutarse en CI. Tres recomendaciones:
- Mantén las reglas en una clase dedicada (
ArquitecturaTest.java, por ejemplo) para que se ejecuten siempre, no solo cuando alguien recuerde. - Asegura que el job de tests falla la build ante violaciones (no solo registra warnings). En GitHub Actions o GitLab CI, las violaciones de ArchUnit deben bloquear el merge.
- Reporta el archivo de frozen rules en cada PR: si se actualiza, el revisor sabe que se ha ignorado una violación deliberadamente.
Buenas prácticas
- Empieza con dos o tres reglas críticas (capas + ciclos + no field injection). Añade más conforme el equipo se acostumbra al feedback.
- En proyectos legacy adopta frozen rules desde el día 1. Sin frozen rules, el equipo se rinde la primera semana.
- Mantén una clase de test dedicada (
ArquitecturaTest) para que las reglas sean fáciles de encontrar y revisar. - Documenta cada regla con
because("..."). El mensaje aparece en el fallo y enseña a los nuevos. - Revisa los snapshots de frozen rules en code review: una bajada de violaciones merece comentario; una subida (cuando se desbloquea una regla) merece justificación.
- No abuses del scope: reglas demasiado específicas se vuelven frágiles. Apunta a invariantes que el proyecto siempre quiere mantener.
- Combina ArchUnit con Spring Modulith en proyectos modulares: cada uno cubre un nivel distinto de la arquitectura.
- Si una regla falla con frecuencia legítima, replantea la regla. Si es válida pero los desarrolladores la ignoran, refuerza con un git hook que ejecute ese test antes del commit.
ArchUnit convierte la arquitectura de "promesa" en "contrato". Es de las mejores inversiones que un equipo puede hacer en un proyecto Spring Boot que pretende crecer sin convertirse en un monolito enmarañado. Tres reglas básicas y frozen rules son suficientes para empezar; el catálogo crece de forma natural a partir de los primeros bugs evitados.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Boot
Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Aplicar ArchUnit para verificar reglas de arquitectura como tests JUnit 6. Definir LayeredArchitecture sobre controllers, services y repositories. Crear reglas custom con la DSL classes().that()...should().... Detectar ciclos entre paquetes. Adoptar ArchUnit en código legacy con frozen rules. Integrar las pruebas en CI para que un PR que viole la arquitectura no pase a main.