Blog - Vicent Tortajada

Portada de Hablemos de ArchUnit

Hablemos de ArchUnit

La arquitectura empieza donde acaba la ingeniería.

— Walter Gropius

Hace unos días leí en LinkedIn a alguno de los grandes maestros de la ingeniería del software que la arquitectura sirve para evitar malgastar la creatividad de los desarrolladores. La verdad es que no recuerdo exactamente la frase ni el autor, y quizá esté haciendo una interpretación un tanto libre.

Qué cómodo es, a veces, que te digan qué estructura debe tener una aplicación, qué paquetes pueden depender de unos y de qué otros no. No pensar. Solo escribir código sin preocuparte de cómo organizarlo. ¿No os pasa? Si no os pasa, está muy bien, porque sabéis estructurar el código muy bien. El problema es que no trabajamos solos, normalmente, y habrá otros que también sabrán estructurarlo muy bien, de forma completamente distinta a la nuestra. Y todas son igual de válidas, hasta que el código tiene varios años y se convierte en un caos. Si no os apela todo esto, pensad en Claude, Copilot, o lo que sea que uséis: puedes crearle un .md con instrucciones y decirle que siga determinados patrones, y cruzar los dedos para que te haga caso. Y nunca fallan, ¿verdad? Siempre hacen lo que les dices. Y no hace falta verificar de ninguna forma que te han hecho caso.

Pues ahí entra ArchUnit.

¿Y qué es ArchUnit?

ArchUnit es una librería de testing de Java para validar arquitecturas. Creo que llegó a mis oídos cuando todavía trabajaba con PHP y pensé qué buena idea, no sé cómo no lo ha inventado nadie ya para PHP1.

Básicamente, ArchUnit permite definir una serie de reglas de arquitectura y validar nuestro código contra ellas. Estas reglas van desde dependencias entre clases y paquetes, naming, anotaciones que se pueden usar o no, etc.

La estructura de las reglas suele ser: las clases X deben cumplir Y. Por ejemplo: las clases del package domain deben cumplir no depender del package application. Claramente, esto es una enorme simplificación. De esta forma nos aseguramos de que se cumplan unos mínimos. Luego vemos más ejemplos.

Ya, pero…

Obviamente esto tiene sus limitaciones. Para empezar no sirve de nada si no se ejecutan los tests. Quiero pensar que nadie en su sano juicio metería un -DskipTests en su CI, pero de todo hay en la viña del Señor.

Tampoco sirve de nada si las reglas no se actualizan conforme evoluciona la arquitectura. O dicho de otra forma, la arquitectura no evoluciona porque las reglas son inamovibles.

Otro punto importante, creo yo, es la fricción que puedan generar. Aunque esto pasa con casi cualquier cosa, es importante que el equipo esté involucrado y entienda el porqué de las reglas para que no se vea como una imposición arbitraria. Y, muy acertadamente, estaréis pensando: Si todo el equipo comprende y comparte esas reglas, ¿por qué iban a hacer falta tests para validarlas?. Para empezar, (casi) todos somos falibles. Y más en estos tiempos. También es una buena red de seguridad con la que aprender o entender la arquitectura del proyecto. Sirve como parte de la documentación si está bien hecho.

Ya para cerrar este punto, ArchUnit no reemplaza el resto de testing unitario, de integración, el análisis de código estático o las revisiones de código. Sólo es una herramienta más, como todo.

Venga va, pero enseña código

Vale, ahora vamos con la parte entretenida. Vamos a enseñar un poco los tests que hemos ido implementando en la aplicación de las plantitas. Primero, un poco de explicación de la falla2: en el monolito modular3, en nuestro módulo app, que contiene simplemente el plugin de Quarkus y poco más, hemos añadido los tests de arquitectura. Tiene sentido porque es el módulo que tiene acceso a todas las clases4 y permite definir reglas generales. Aquí he dividido las reglas en buenas prácticas, reglas más orientadas a hexagonal y DDD, y otras con métricas, que aún tengo que refinar un poco.

Para crear un test, lo primero es definir qué paquetes debe escanear, así que normalmente tendremos algo como:

@AnalyzeClasses(packages = "es.vicenttortajada.demeter", importOptions = ImportOption.DoNotIncludeTests.class)
class BoundedContextArchitectureTest {
    ...
}

Aquí le decimos qué paquete debe analizar y que no incluya los tests. Sencillo.

Una vez importadas las clases a analizar vamos con…

Buenas prácticas

Aquí nos ha ayudado la Library API de ArchUnit, en general las General Coding Rules, que vienen a ser unas reglas predefinidas de buenas prácticas^TM^

no_classes_should_access_standard_streams

El nombre lo dice todo —importante—. Básicamente la regla se viola si en el código se detecta un System.out.println o cualquier llamada a la entrada/salida estándar.

@ArchTest
static final ArchRule no_classes_should_access_standard_streams =
    GeneralCodingRules.NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS;

Muy sencillo, se usa la anotación @ArchTest, y se define una regla. En este caso no definimos nada porque es una regla predefinida.

no_classes_should_use_java_util_logging

Otra regla predefinida. Salta si se utiliza java.util.logging en lugar de otras opciones más adecuadas.

@ArchTest
static final ArchRule no_classes_should_use_java_util_logging =
    GeneralCodingRules.NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING;

no_classes_should_use_joda_time

Lo habéis acertado, otra regla predefinida. Para evitar que se use JodaTime en lugar de java.time.

@ArchTest
static final ArchRule no_classes_should_use_joda_time =
    GeneralCodingRules.NO_CLASSES_SHOULD_USE_JODATIME;

no_classes_should_throw_generic_exceptions

Esta me encanta. Cada vez que lanzáis un Exception o un RuntimeException hacéis llorar a un bebé5.

@ArchTest
static final ArchRule no_classes_should_throw_generic_exceptions =
    GeneralCodingRules.NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS;

no_classes_should_use_field_injection

Con esta acabamos, la última de las reglas predefinidas. Forzar que la inyección de dependencias se haga en el constructor. Porque no somos unos salvajes y nos gusta hacer tests.

@ArchTest
static final ArchRule no_classes_should_use_field_injection =
    GeneralCodingRules.NO_CLASSES_SHOULD_USE_FIELD_INJECTION;

Hexagonal

Aquí una serie de tests para validar la arquitectura hexagonal de cada uno de los módulos. También vamos a tirar de la Library API porque nos da métodos predefinidos para ello. Podríamos validar una arquitectura por capas con layeredArchitecture(), pero en nuestro caso utilizaremos onionArchitecture().

Diagrama de arquitectura onion para el módulo especies Diagrama conceptual de capas y adaptadores para entender mejor las reglas de ArchUnit. La imagen me ha la hecho obviamente Copilot.

especies_onion_architecture

Aquí nuestro test concreto:

@ArchTest
static final ArchRule especies_onion_architecture = onionArchitecture()
    .domainModels("..especies.domain..")
    .domainServices("..especies.domain..")
    .applicationServices("..especies.application..")
    .adapter("in.api", "..especies.infrastructure.in.api..")
    .adapter("out.persistence", "..especies.infrastructure.out.persistence..")
    .ignoreDependency(
        resideInAPackage("..especies.infrastructure.quarkus.."),
        resideInAPackage("..especies..")
    );

Aquí hay varias cosas a comentar. Definimos dónde residen nuestros modelos de dominio, servicios de dominio, los servicios de aplicación, y cada uno de los adaptadores.

Como es obvio para todos, los modelos de dominio no pueden llamar a los servicios de aplicación, etc.

Os habréis fijado que los modelos y los servicios de dominio los tengo en el mismo paquete. Sí, porque es una aberración organizar por el tipo en lugar de por agregado o funcionalidad. Me gusta mucho la metáfora de Vaughn Vernon en Implementing Domain-Driven Design: en la cocina no guardamos las cosas del material que están hechas, no guardamos las copas junto a las bandejas de cristal, ni las ollas junto a los tenedores. Ni siquiera guardamos los cuchillos grandes junto a los cuchillos de mesa.

Mención especial al ignoreDependency. Aquí añadimos una excepción a la regla. ¿Por qué? En principio de un adaptador no se puede llamar a otro directamente peeeero en nuestra capa de infraestructura tenemos un paquete quarkus con configuración de serialización que debe llamar a los otros paquetes de adaptadores6. Podríamos evitar esto añadiendo la configuración de serialización específica en cada adaptador, pero me parecía que tenía sentido agrupar todo lo específico de Quarkus en su propio paquete.

Hay un test igual para el otro módulo, Plantas, que no voy a incluir porque no aporta mucho más y tampoco me pagan por palabra.

no_cycles_between_top_level_modules

Otro test de la Library API. Básicamente se dividen las clases en “rebanadas” y se evalúan aserciones sobre éstas. Pero lo vemos con un ejemplo:

@ArchTest
static final ArchRule no_cycles_between_top_level_modules =
    slices().matching("es.vicenttortajada.demeter.(*)..").should().beFreeOfCycles();

Muy sencillo también, como veis. Cada “rebanada” representa un módulo de nuestro monolito modular, e indicamos que no debería tener ciclos en sus dependencias.

domain_must_be_framework_agnostic

Con este test validamos que la capa de dominio no dependa de paquetes que normalmente deberían ir en la capa de infraestructura (persistencia, quarkus, CDI).

@ArchTest
static final ArchRule domain_must_be_framework_agnostic =
    noClasses().that().resideInAnyPackage("..(*).domain..")
        .should().dependOnClassesThat().resideInAnyPackage(
            "io.quarkus..",
            "jakarta.inject..",
            "jakarta.ws.rs..",
            "jakarta.persistence.."
        );

Creo que se explica solo, pero lo traduzco: ninguna clase del paquete domain debería depender de clases de los paquetes tal y cual. Lo ideal es ir añadiendo paquetes conforme los vayamos detectando.

domain_and_application_must_not_depend_on_infrastructure y domain_should_not_depend_on_application

Este test en realidad ya lo cubre el de onion, pero por si se nos olvida añadirlo para algún módulo:

 @ArchTest
static final ArchRule domain_and_application_must_not_depend_on_infrastructure =
    noClasses().that().resideInAnyPackage(
                "..(*).domain..",
                "..(*).application.."
            )
            .should().dependOnClassesThat().resideInAnyPackage(
                "..(*).infrastructure.."
            );

@ArchTest
static final ArchRule domain_should_not_depend_on_application =
    noClasses().that().resideInAnyPackage(
                "..(*).domain.."
            )
            .should().dependOnClassesThat().resideInAnyPackage(
                "..(*).application.."
            );

Lo mismo que antes: ninguna clase que resida en el paquete dominio o aplicación debería depender de clases que residen en infraestructura. Me siento un poco tonto describiendo algo que se define tan bien por sí solo. Creo que me gusta tanto esta librería por esto.

Convenciones

Esta sección contiene convenciones de nombres. No creo que suela ser lo más importante, aunque lo sea, y cuesta encontrar un equilibrio entre consistencia de nombres y rigidez. Dejo algunos ejemplos:

ports_must_be_interfaces

Lo dice el nombre. Los puertos deberían ser siempre interfaces:

@ArchTest
static final ArchRule ports_must_be_interfaces =
    classes().that().resideInAnyPackage("..(*).application..")
        .and().haveSimpleNameEndingWith("Port")
        .should().beInterfaces();

Si el nombre acaba en Port y está en la capa de aplicación, debería ser una interfaz. Por otro lado, también querremos que todos los puertos residan en la capa de aplicación:


@ArchTest
static final ArchRule ports_must_reside_in_application =
    classes().that().haveSimpleNameEndingWith("Port")
        .should().resideInAnyPackage("..application..");

No quiero poner tests repetidos, pero podríamos hacer reglas similares para casos de uso, repositorios, mappers, o lo que sea que tengamos.

Cositas que se quedan fuera

No quiero añadir más tests que no suman nada a los anteriores, pero vamos con un repaso de lo que no he llegado a implementar o de ideas que se me están ocurriendo sobre la marcha. Vamos primero con algunos puntos interesantes que he visto por encima en la documentación.

Reglas a partir de PlantUML. Sí, como lo leéis. Puedes cargar un diagrama PlantUML7 y te genera las reglas a partir de ahí. No lo he probado todavía, la verdad, y no sé hasta qué punto es útil, pero como curiosidad está bastante gracioso.

Congelar reglas. Tampoco lo he probado. Pero, por lo que entiendo de la documentación, la primera vez que lo ejecutas obtiene todas las violaciones de reglas y las guarda. En las siguientes ejecuciones de tests solo se tienen en cuenta violaciones nuevas. Tiene sentido en proyectos ya establecidos para asegurar que el nuevo código sigue las normas, pero se permite que el antiguo siga siendo… como es.

Métricas. Esto estoy intentando implementarlo, pero todavía no tengo nada serio. Te permite extraer una serie de métricas de arquitectura que bien podemos mostrar de manera informativa o sobre las que podríamos hacer aserciones si superan determinado umbral, por ejemplo.

Otras ideas. Dependiendo de la arquitectura que tengamos en mente, por ejemplo podríamos asegurarnos de que sólo existe una raíz de agregado por módulo8. Esto podríamos hacerlo si marcamos las raíces de agregado con una interfaz o una anotación.

Consideraciones finales

Al final, los tests son útiles y sencillos de usar, pero siguen siendo sólo una salvaguarda. Y hay que llegar a un compromiso con la rigidez de la arquitectura. Está bien que haya determinadas convenciones de nombres, por ejemplo, pero tampoco conviene crear unas reglas demasiado estrictas.

Por otro lado, con algo de imaginación es muy fácil saltarse esas reglas, cambiando los nombres de clases, paquetes, etc. O comentando las reglas. He visto hacer ambas cosas :)


  1. Sí, incluso en PHP se deben validar arquitecturas ;) ↩︎

  2. ¿Se entiende esta expresión fuera de València? ↩︎

  3. Sí, al final me cansé de los microservicios y lo he juntado todo en uno. Acertadamente. ↩︎

  4. Creo que técnicamente es el bytecode, no el código en sí. ↩︎

  5. No queráis saber qué pasa cuando en un lenguaje con tipado débil creáis un método que devuelve un resultado o un String con el mensaje de error ;). ↩︎

  6. Esto ocurre porque estamos compilando a nativo. Si ejecutamos en la JVM esta configuración no es necesaria. ↩︎

  7. PlantUML es un lenguaje para definir diagramas UML en formato texto. A mí últimamente me tiene enamorado, porque al final es texto plano y puedes versionarlo fácilmente. A nivel visual deja mucho que desear porque por debajo usa Graphviz y… bueno, todo lo automático carece de alma. Pero se puede importar desde algunos editores y ya retocarlo a gusto. ↩︎

  8. Módulo en el sentido DDD, que viene a ser un package de Java. ↩︎

Etiquetas: