ArchUnit

Fabian Sanchez, Javier Escalada - 2021-04-06 10:00:00 +0200


Introducción

A la hora de abordar un nuevo proyecto, una vez que se ha decidido la arquitectura software más conveniente, puede que no todos los programadores la conozcan en detalle; si además el proyecto es complejo, pueden manifestarse dudas durante su desarrollo sobre si se ha infringido alguna convención al añadir un cambio. Si no hay ningún control, la deuda técnica aumentará por muy adecuado que fuera el planteamiento inicial, de este modo la arquitectura se terminará viendo comprometida y el mantenimiento será mucho más costoso. Para evitar este problema lo ideal es establecer unas reglas o restricciones formales desde el principio, que se pueda disponer de ellas antes incluso de escribir la primera línea de código y sirvan además como respaldo ante cambios futuros. Siguiendo la misma idea que se aplica en TDD (desarrollo guiado por pruebas) pero a nivel de arquitectura en lugar de para con la funcionalidad (¡aunque ambos enfoques no son excluyentes!). La biblioteca de ArchUnit es perfecta para este propósito.

¿Qué es ArchUnit?

ArchUnit es una librería de código abierto gratuita escrita en Java de fácil integración en el marco de pruebas unitarias que permite verificar que el código se ajusta a la arquitectura acordada para un proyecto Java, es decir, posibilita implementar tests unitarios que prueben las dependencias entre clases, capas, organización de los paquetes, convenciones de codificación, etc. Connatural a estos test se obtiene una forma de identificar las reglas de arquitectura utilizadas, facilitando la consolidación de la comprensión de la estructura del proyecto. Aportan valor como pruebas automáticas (de regresión) que pueden ser incluidas en un ciclo de integración continua verificando de forma desatendida (sin necesidad de auditorias o revisiones) la consistencia de la arquitectura permitiendo adelantar en una fase temprana del desarrollo cualquier problema que se detecte.

La forma de incluir ArchUnit en los proyectos Java (Maven o gradle) es muy sencilla, solo hay que añadir la siguiente dependencia al pom.xml:

    <dependency>
        <groupId>com.tngtech.archunit</groupId>
        <artifactId>archunit</artifactId>
        <version>0.18.0</version>
        <scope>test</scope>
    </dependency>

O en el build.gradle de gradle:

    dependencies {
        testImplementation 'com.tngtech.archunit:archunit:0.16.0'
    }

ArchUnit utiliza reflexión y el análisis de bytecode de las clases compiladas para construir las reglas de arquitectura. Además, es muy versátil dado que ofrece varios niveles de abstracción que permiten extender o añadir nuevas reglas de manera sencilla e intuitiva. Estos niveles son los que siguen:

Core, que se ocupa de la infraestructura básica, es decir, cómo importar código de bytes en objetos Java.

Lang, contiene la sintaxis de reglas para especificar reglas de arquitectura.

Library, contiene reglas predefinidas más complejas.

ArchUnit ofrece una Api para cada uno de ellos que detallamos a continuación.

Api

1.- Core

Ofrece un mecanismo de importación de clases por defecto. Cada clase de pruebas puede definir el paquete de clases con el que trabajar, normalmente se hace referencia al paquete raíz de los archivos fuente del proyecto (clases). En este ejemplo sería org.tms

    import com.tngtech.archunit.core.domain.JavaClasses;
    import com.tngtech.archunit.core.importer.ClassFileImporter;
    import com.tngtech.archunit.core.importer.ImportOption;
    …
    JavaClasses classes = new ClassFileImporter()
    .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
    .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_ARCHIVES)
    .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
    .importPackages("org.tms");

Además, como se puede ver en el ejemplo es posible excluir mediante opciones de configuración la importación de archivos (no .java), clases de tests o JARs para que no interfieran en las pruebas.

Este es un uso común de la Api Core, pero cabe destacar que toda regla implementada utilizando la Api Lang puede ser codificada solo utilizando esta api, ocurre que esto da lugar a reglas con un código muy extenso y muy difíciles de comprender (con una sintaxis muy parecida a la de java reflection). Con la api Library ocurre lo mismo pero al revés, toda regla de Library puede implementarse usando la Api Lang , pero estas son reglas predefinidas, varias de ellas transversales válidas para casi cualquier arquitectura que llevaría excesivo tiempo reescribir usando solo la Api Lang. Con todo, no es algo de lo que preocuparse, veremos que uso tiene cada una y como se combinan entre si (en particular, la referencia classes del objeto de la clase JavaClasses definido en el código de ejemplo será utilizada para aplicar las reglas definidas mediante las apis Lang y Library en los siguientes apartados).

2.- Lang

Esta Api ofrece una potente sintaxis para expresar reglas de forma abstracta en un lenguaje natural fácilmente comprensible.

Una regla (ArchRule) tiene la siguiente estructura:

ELEMENTS that PREDICATE should CONDITION

En la primera parte, ELEMENTS , se define que conjunto de elementos se pretenden evaluar, normalmente serán clases o métodos o ninguna clase o ningún método (pueden ser campos, constructores, bloques de código, etc). las referencias a estos elementos se encuentran en el paquete:

com.tngtech.archunit.lang.syntax.ArchRuleDefinition

El predicado (o composición de predicados), PREDICATE, hace referencia a las condiciones que cumplen los elementos, filtrando el conjunto anterior (los elementos que no cumplen el predicado, no se tienen en cuenta).

CONDITION, es la condición o condiciones que deben cumplir los elementos anteriores.

Un ejemplo de implementación de una regla con esta Api sería el que sigue:

    ArchRule rule = ArchRuleDefinition.classes().that().resideInAPackage("..service..")
	  .should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");

En este caso el elemento que queremos que se evalue serán las clases, el predicado indica que estas clases estarán ubicadas en el paquete ..service.. (incluyendo las clases en los subpaquetes) y la condición es que estas clases solo puedan ser accedidas por clases dentro del mismo paquete o del paquete ..controller…

Nota: Las expresiones que definen las rutas de cada paquete siguen la sintaxis descrita en: https://www.javadoc.io/doc/com.tngtech.archunit/archunit/0.10.2/com/tngtech/archunit/base/PackageMatcher.html

Para aplicar una regla a los elementos objeto del test (obtenidos mediante la Api Core) basta con llamar al método check(…) de ArchRule .

    rule.check(classes);

Además, a la regla se le puede agregar en su construcción una llamada al método because(…) , pasándole como argumento una cadena de texto donde se puede justificar el uso de la regla, esta es una opción interesante en general y para nuevos desarrolladores en particular dado que redunda en un mayor entendimiento de la arquitectura.

    ArchRule rule = ArchRuleDefinition.noClasses()
         .that().resideInAPackage("org.tms.domain..")
         .should().dependOnClassesThat()
         .resideInAnyPackage("org.tms.adapters..", "org.tms.application..")
         .because("existiría un acoplamiento entre la capa de dominio y el resto");

En ulteriores ejemplos que se refieran a reglas, casi la totalidad involucraran esta Api dado que forma el grueso de la implementación de reglas de ArchUnit.

3.- Library

Este nivel ofrece una colección de reglas predefinidas. Es una Api más concisa para patrones más complejos, pero de uso común, abarca desde reglas para validar una arquitectura en capas, puertos y adaptadores (Onion) o verificaciones de ciclos entre segmentos a reglas propias de algunos frameworks como comprobar que la inyección de dependencias de Spring no se hace a través de atributos de clase anotados con @Autowire .

    Architectures.OnionArchitecture rule = onionArchitecture()
    .domainModels(DOMAIN_MODEL_PACKAGES)
    .domainServices(DOMAIN_SERVICE_PACKAGES)
    .applicationServices(APPLICATION_LAYER_PACKAGES)
    .adapter("adapter rest", REST_ADAPTERS_PACKAGES)
    .adapter("adapter persistence", PERSISTENCE_ADAPTERS_PACKAGES);

    ArchRule otherRule = GeneralCodingRules.NO_CLASSES_SHOULD_USE_FIELD_INJECTION;

Aspectos a cubrir con las reglas arquitectónicas

En lo que sigue se expodrán unos ejemplos práticos de la reglas utilizadas en un proyecto ficticio cuyo código fuente y tests pueden encontrase en el enlace al final de este artículo.

Este proyecto es un ejemplo de aplicación siguiendo una arquitectura Onion (refinamiento de la arquitectura de puertos-adaptadores) con algunos requisitos arquitectónicos añadidos para ilustrar posibles arquetipo de reglas definidas con ArchUnit. Su diagrama de componentes sería el siguiente:

components

Convenciones de nomenclatura

(paquetes, clases, métodos, etc.)

Se puede establecer, por ejemplo, una regla para que toda clase con la anotación @Configuration del framework de Spring deba nombrarse con la terminación …Configuration (y ubicarse en la capa de adaptadores):

    ArchRule rule = ArchRuleDefinition.classes()
    .that().areAnnotatedWith(Configuration.class)
    .should().haveSimpleNameEndingWith("Configuration")
    .andShould().resideInAnyPackage("org.tms.adapters..");

Convenciones de codificación

(atributos, métodos, modificadores de acceso, constructores, inicializadores, etc.)

Por ejemplo, se puede disponer una regla para que toda clase Utils (normalmente contienen métodos públicos estáticos que no pueden encapsularse en un objeto, esto también se puede verificar implementando otras reglas que complementen la regla del ejemplo) debe tener un constructor privado para evitar su instanciación.

    ArchRule rule = ArchRuleDefinition.constructors()
    .that().areDeclaredInClassesThat().haveSimpleNameEndingWith("Utils")
    .should().bePrivate();

Contenido

(paquetes, clases, etc.)

Se puede especificar una regla para que ninguna clase definida con la notación @Repository de Spring pueda ubicarse fuera del paquete de persistencia de la capa de adaptadores, dado que estas clases dependen completamente del framework y la base de datos utilizada (en este caso MongoDB)

    ArchRule rule = ArchRuleDefinition.noClasses()
    .that().areAnnotatedWith(Repository.class)
    .should().resideOutsideOfPackage("org.tms.adapters.persistence..");

Dependencias / Relaciones

(entre capas, entre paquetes, entre clases, etc.)

Se puede hacer uso de la Api Library para establecer que no pueda haber ninguna interdependencia entre adaptadores, por ejemplo entre el adaptador que permite la comunicación mediante una Api rest y el adaptador para la persistencia en base de datos. Se entiende por dependencia cualquier relación entre clases, ya sea de herencia, composición, uso de parámetros o tipo de retorno en los métodos, etc.

    SliceRule rule = SlicesRuleDefinition.slices()
    .matching("org.tms.adapters.(*)")
    .should().notDependOnEachOther();

Dependencias cíclicas entre capas

De manera análoga al anterior ejemplo, se puede generalizar la regla para que comprenda varios paquetes (o clases) como vértices formando grafos cerrados con sus dependencias como arístas.

    SliceRule rule = SlicesRuleDefinition.slices()
    .matching("org.tms.(*)..")
    .should().beFreeOfCycles();

Nota: Las regla para dependencias cíclicas puede ser configuradas para permitir un valor máximo de ciclos además de un número máximo de dependencias por ciclo. Es necesario incluir un fichero de propiedades (archunit.properties) con estos valores (cycles.maxNumberToDetect y cycles.maxNumberOfDependenciesPerEdge) para que ArchUnit los reconozca.

Herencia

Se puede establecer que todo servicio de aplicación nombrado con la terminación UseCase (de lo que se deduce que un servicio de aplicación debe cubrir un caso de uso) deba implementar la interfaz CarSalesService.

    ArchRule rule = ArchRuleDefinition.classes()
    .that().haveSimpleNameEndingWith("UseCase")
    .should().implement(CarSalesService.class);
    rule.check(classes);

Uso de anotaciones

Por ejemplo que toda clase con terminación Document que supone una referencia a una entidad de persistencia y deba utilizar la notación @Document de Spring Data JDBC, asimismo que deba estar ubicada dentro del adaptador de persistencia.

    ArchRule rule = ArchRuleDefinition.classes()
    .that().haveSimpleNameEndingWith("Document")
    .should().beAnnotatedWith(Document.class).andShould().resideOutsideOfPackage("org.tms.adapters.persistence..");

Restricciones

La siguiente regla utiliza la Api Library nuevamente para chequear que no se lanza ninguna excepción genérica en ningún método de ninguna clase.

    ArchRule rule = ArchRuleDefinition.noClasses()
    .should(GeneralCodingRules.THROW_GENERIC_EXCEPTIONS);

Personalización

ArchUnit ofrece una Api muy completa, es difícil que haya una regla que no pueda ser expresada mediante la sintaxis de la Api Lang, pero con todo si esto ocurre, ArchUnit permite su extensión de manera rápida y sencilla implementado el método abstracto check de la clase ArchCondition o apply de DescribedPredicate. Por ejemplo:

ArchRule rule = ArchRuleDefinition.noMethods().should(haveMoreThanSixParameters());
...
public ArchCondition<JavaMethod> haveMoreThanSixParameters() {
    return new ArchCondition<>("have more than six parameters") {
        @Override
        public void check(JavaMethod javaMethod, ConditionEvents conditionEvents) {
            boolean satisfied = javaMethod.reflect().getParameterCount() > 6;
            conditionEvents.add(new SimpleConditionEvent(javaMethod, satisfied,
            String.format("method %s from class %s has %s than '%s' parameters", 
				javaMethod.getName(), javaMethod.getOwner().getName(),
				satisfied ? "more" : "minus (or equals to)", "six")));
        }
    }

Establecemos una archCondition personalizada instanciada para el tipo específico JavaMethod (del Core de ArchUnit), de manera que se cuente los parámetros de cada método y evalue que no supere 6 argumentos.

Las reglas para arquitecturas en capas particulares también pueden ser definidas mediante la Api Library , siempre y cuando estén basadas en capas (si no habría que utilizar las otras Apis):

    private static Architectures.LayeredArchitecture portsAndAdaptersArchitecture = Architectures
        .layeredArchitecture()
        .layer("domain layer").definedBy("org.tms.domain..")
        .layer("application layer").definedBy("org.tms.application..")
        .layer("adapter layer").definedBy("org.tms.adapters..");

    ArchRule rule = portsAndAdaptersArchitecture.whereLayer("domain layer")
            .mayOnlyBeAccessedByLayers("application layer");

Integración con plantUml

ArchUnit permite la integración con diagramas de componentes desarrollados con PlantUML , simplificando (aún más si cabe) la construcción de las reglas de validación de las relaciones entre componentes, de manera que no haya que escribir varías reglas para asegurarse de que la codificación sigue el diagrama planteado. En este ejemplo se ha importado el diagrama mostrado al principio de este artículo y se ha establecido que se consideren solo las dependencias descritas en él (no se tienen en cuenta relaciones implícitas propias de Java, como que toda clase hereda de la clase Object).

    ArchRule rule = ArchRuleDefinition.classes().should(
    PlantUmlArchCondition.adhereToPlantUmlDiagram(this.getClass().getResource("template_archunit.puml"),
    PlantUmlArchCondition.Configurations.consideringOnlyDependenciesInDiagram()));

Errores

Cuando se incumple una regla en un test, ArchUnit muestra un mensaje detallado de la infracción, indicando el número de veces que se ha infringido la regla, así como las clases implicadas, por ejemplo (utilizando el test de dependencias cíclicas entre capas):

alt text

@Test
public void layersShouldBeFreeOfCycles() {
    SliceRule rule = SlicesRuleDefinition.slices()
    .matching("org.tms.(*)..")
    .should().beFreeOfCycles();
    rule.check(classes);
}

java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule ‘slices matching ‘org.tms.(*)..’ should be free of cycles’ was violated (1 times): Cycle detected: Slice adapters -> Slice application -> Slice adapters Dependencies of Slice adapters Class <org.tms.adapters.persistence.CarSalesAdapterRepository> implements interface <org.tms.application.ports.CarSalesPort> … Dependencies of Slice application Field <org.tms.application.services.RegisterCarUseCase.r> has type <org.tms.adapters.persistence.CarSalesDelegatedRepository> in (RegisterCarUseCase.java:0)

En este caso ArchUnit ha revelado que además de una relación entre la capa de adaptadores y aplicación existe otra clase de la capa de aplicación que depende de una clase de la capa de adaptadores.

Código legacy

En proyectos legacy también podemos integrar ArchUnit. En estos casos es habitual encontrar que el código provoque multitud de infracciones que se tengan que una corrección no trivial que deba postergarse.

Este código deberá estar cubierto por pruebas de arquitectura que garanticen que al menos no aumenten las transgresiones ya existentes.

Lo que haremos será ignorar las infracciones actuales basadas en coincidencias de expresiones regulares simples. Para ello, incluiremos un archivo con el nombre archunit_ignore_patterns.txt en la carpeta de recursos del proyecto.

Por ejemplo, imaginemos que la clase org.tms.LegacyService o org.tms.adapters.persistence.LegacyCarSalesRepository tienen muchas infracciones. Cubrimos estas clases agregando al archivo archunit_ignore_patterns.txt la siguiente linea:

.*Legacy*.*

Esta línea se interpretará como una expresión regular y se comparará con las infracciones notificadas. Se ignorarán las infracciones con un mensaje que coincida con el patrón.

Como se ha comentado anteriormente, podemos encontrarnos con proyectos que tengan un número demasiado elevado de infracciones como para corregirlas en el momento de detectarse. Una forma de abordar este problema es establecer un enfoque iterativo, que evite que la base del código se deteriore más. Para ello nos podemos apoyar en FreezingArchRule . Estas reglas permiten registrar las infracciones en fichero interno ViolationStore . Al istanciar una ArchRule y aplicar FreezingArchRule.freeze(archRule), podemos registrar todas las infracciones actuales y evitar que se agreguen nuevas. Esto permite ignorar una regla la primera vez que se ejecuta y mientras no se resuelva la infracción, es decir, una vez resuelta, la regla no volverá a ignorarse.

Para aplicarse requiere que el fichero archunit.properties tenga las propiedades:

freeze.store.default.allowStoreCreation=true (por defecto es false) freeze.store.default.allowStoreUpdate=false (por defecto es true)

Por defecto, FreezingArchRulese utiliza un ViolationStore simple basado en texto sin formato. Esto es suficiente para agregar estos archivos a cualquier sistema de control de versiones para realizar un seguimiento continuo del progreso. Se puede configurar la ubicación de ViolationStore en archunit.properties:

freeze.store.default.path=/….

Ejemplo de cómo quedarían las reglas:

    FreezingArchRule freezeRule = FreezingArchRule.freeze(ArchRuleDefinition.classes()
    .that().resideInAPackage("org.tms.domain..")
    .should().onlyBeAccessed().byAnyPackage("org.tms.domain..", "org.tms.application.."));

Conclusiones

ArchUnit ofrece una poderosa herramienta para mantener la validez de los requisitos arquitectónicos de cualquier proyecto java durante todo su ciclo de vida. Fomenta el uso de buenas prácticas acordes a las arquitecturas propuestas y ayuda a entender las mismas mediante la definición de reglas fácilmente expresables. Estas reglas hacen un tratamiento homogéneo de cualquier aspecto relacionado con la arquitectura.

Su integración en los proyectos ya sean nuevos o heredados es muy fácil y permite estandarizar un conjunto de tests favoreciendo la normalización de las arquitecturas. También su curva de aprendizaje es mínima, basta con codificar un test y el paradigma se asume rápidamente.

No hemos encontrado ninguna razón para no usarlo, al menos… ¡no la hay para no probarlo!

Código fuente

https://github.com/FabianSR/archunit-example-template