From cadfab39c89355b45b45e5e646ff36696259401b Mon Sep 17 00:00:00 2001 From: Angel Eduardo Hincho Jove Date: Thu, 1 Oct 2026 22:09:45 -0500 Subject: [PATCH 1/4] test: evaluate the layered rules against sample services Run the rules of LayeredArchitectureTest through the ArchUnit engine, with the EngineTestKit of JUnit, over three small sample services kept under fixtures: one that follows the layers, one whose controller reaches outside them and one that only has a controller and a service. The samples pin what the rules do today. The entity, service and repository layers can already use the framework-free Nova libraries, a controller cannot reach other packages of the service or the Nova starters, and a layer with no classes fails instead of passing. Gradle does not run the samples as tests, because some break the rules on purpose. Only LayeredArchitectureRulesTest runs them. --- build.gradle.kts | 5 ++ .../LayeredArchitectureRulesTest.java | 90 +++++++++++++++++++ .../archunit/NovaArchitectureStyleTest.java | 11 ++- .../CompliantServiceArchitecture.java | 17 ++++ .../fixtures/LeakyControllerArchitecture.java | 17 ++++ .../fixtures/PartialServiceArchitecture.java | 17 ++++ .../compliant/controller/ItemController.java | 31 +++++++ .../fixtures/compliant/dto/ItemResponse.java | 9 ++ .../fixtures/compliant/entity/Item.java | 19 ++++ .../compliant/repository/ItemRepository.java | 21 +++++ .../compliant/service/ItemService.java | 35 ++++++++ .../fixtures/leaky/config/Settings.java | 14 +++ .../leaky/controller/LeakyController.java | 17 ++++ .../partial/controller/PingController.java | 27 ++++++ .../fixtures/partial/service/PingService.java | 25 ++++++ .../nova/java/libs/fixture/FixtureError.java | 25 ++++++ .../nova/java/starters/fixture/Wiring.java | 19 ++++ 17 files changed, 393 insertions(+), 6 deletions(-) create mode 100644 src/test/java/pe/edu/nova/java/archunit/LayeredArchitectureRulesTest.java create mode 100644 src/test/java/pe/edu/nova/java/archunit/fixtures/CompliantServiceArchitecture.java create mode 100644 src/test/java/pe/edu/nova/java/archunit/fixtures/LeakyControllerArchitecture.java create mode 100644 src/test/java/pe/edu/nova/java/archunit/fixtures/PartialServiceArchitecture.java create mode 100644 src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/controller/ItemController.java create mode 100644 src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/dto/ItemResponse.java create mode 100644 src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/entity/Item.java create mode 100644 src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/repository/ItemRepository.java create mode 100644 src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/service/ItemService.java create mode 100644 src/test/java/pe/edu/nova/java/archunit/fixtures/leaky/config/Settings.java create mode 100644 src/test/java/pe/edu/nova/java/archunit/fixtures/leaky/controller/LeakyController.java create mode 100644 src/test/java/pe/edu/nova/java/archunit/fixtures/partial/controller/PingController.java create mode 100644 src/test/java/pe/edu/nova/java/archunit/fixtures/partial/service/PingService.java create mode 100644 src/test/java/pe/edu/nova/java/libs/fixture/FixtureError.java create mode 100644 src/test/java/pe/edu/nova/java/starters/fixture/Wiring.java diff --git a/build.gradle.kts b/build.gradle.kts index 4da384a..db596b8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,6 +35,8 @@ dependencies { api("org.junit.jupiter:junit-jupiter-api:$junitVersion") testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + // Corre el motor de ArchUnit sobre los servicios de ejemplo y lee el resultado de cada regla. + testImplementation("org.junit.platform:junit-platform-testkit") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } @@ -49,6 +51,9 @@ tasks.jacocoTestReport { tasks.test { useJUnitPlatform() + // Los servicios de ejemplo de fixtures incumplen las reglas a propósito. Solo los corre + // LayeredArchitectureRulesTest, por su selector; el escaneo de Gradle no debe tomarlos como pruebas. + exclude("**/fixtures/**") finalizedBy(tasks.jacocoTestReport) } diff --git a/src/test/java/pe/edu/nova/java/archunit/LayeredArchitectureRulesTest.java b/src/test/java/pe/edu/nova/java/archunit/LayeredArchitectureRulesTest.java new file mode 100644 index 0000000..9931b06 --- /dev/null +++ b/src/test/java/pe/edu/nova/java/archunit/LayeredArchitectureRulesTest.java @@ -0,0 +1,90 @@ +package pe.edu.nova.java.archunit; + +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import org.junit.jupiter.api.Test; +import org.junit.platform.engine.TestExecutionResult; +import org.junit.platform.testkit.engine.EngineTestKit; +import pe.edu.nova.java.archunit.fixtures.CompliantServiceArchitecture; +import pe.edu.nova.java.archunit.fixtures.LeakyControllerArchitecture; +import pe.edu.nova.java.archunit.fixtures.PartialServiceArchitecture; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.platform.engine.TestExecutionResult.Status.SUCCESSFUL; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; + +/** + * Evalúa las reglas de {@link LayeredArchitectureTest} sobre servicios de ejemplo, con el motor de ArchUnit, + * y lee el resultado de cada una. + * + *

Los servicios de ejemplo están en {@code fixtures}. Unos incumplen las reglas a propósito, así que + * Gradle no los corre como pruebas: los corre esta clase, uno por uno, y mira qué regla falló y por qué. + */ +class LayeredArchitectureRulesTest { + + private static final String CONTROLLER_ALLOW_LIST = "controllers_depend_only_on_allowed_layers"; + + private static final String NO_CLASSES_TO_CHECK = "failed to check any classes"; + + @Test + void aServiceThatFollowsTheLayersPassesEveryRule() { + final Map rules = evaluate(CompliantServiceArchitecture.class); + + assertTrue(rules.containsKey(CONTROLLER_ALLOW_LIST), "the rules did not run: " + rules.keySet()); + assertEquals(Map.of(), failuresOf(rules)); + } + + @Test + void aControllerMayNotReachTheServicePackagesOutsideTheLayersNorTheStartersOfNova() { + final Map failures = failuresOf(evaluate(LeakyControllerArchitecture.class)); + + final String violations = failures.get(CONTROLLER_ALLOW_LIST); + assertNotNull(violations, "the controller reached packages it should not have: " + failures.keySet()); + assertTrue(violations.contains("Settings.name()"), violations); + assertTrue(violations.contains("Wiring.name()"), violations); + } + + @Test + void aLayerWithoutClassesFailsInsteadOfPassing() { + final Map failures = failuresOf(evaluate(PartialServiceArchitecture.class)); + + assertEquals( + Set.of( + "repositories_must_not_depend_on_services", + "entities_must_not_depend_on_any_other_layer", + "dtos_must_not_depend_on_entities"), + failures.keySet()); + failures.forEach((rule, message) -> + assertTrue(message.contains(NO_CLASSES_TO_CHECK), rule + " failed for another reason: " + message)); + } + + /** Corre las reglas de un servicio de ejemplo y devuelve el resultado de cada una por su nombre. */ + private static Map evaluate(final Class architecture) { + final Map rules = new TreeMap<>(); + EngineTestKit.engine("archunit") + .selectors(selectClass(architecture)) + .execute() + .testEvents() + .finished() + .stream() + .forEach(event -> rules.put( + event.getTestDescriptor().getDisplayName(), event.getRequiredPayload(TestExecutionResult.class))); + return rules; + } + + /** Las reglas que no pasaron, con el mensaje con que fallaron. */ + private static Map failuresOf(final Map rules) { + final Map failures = new TreeMap<>(); + rules.forEach((rule, result) -> { + if (result.getStatus() != SUCCESSFUL) { + failures.put( + rule, + result.getThrowable().map(Throwable::getMessage).orElse(result.getStatus().name())); + } + }); + return failures; + } +} diff --git a/src/test/java/pe/edu/nova/java/archunit/NovaArchitectureStyleTest.java b/src/test/java/pe/edu/nova/java/archunit/NovaArchitectureStyleTest.java index 6e72bb1..4942e21 100644 --- a/src/test/java/pe/edu/nova/java/archunit/NovaArchitectureStyleTest.java +++ b/src/test/java/pe/edu/nova/java/archunit/NovaArchitectureStyleTest.java @@ -10,14 +10,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Unit tests for {@link NovaArchitectureStyle} and the wiring of + * Pruebas unitarias de {@link NovaArchitectureStyle} y del cableado de * {@link LayeredArchitectureTest}. * - *

These are NOT ArchUnit rule evaluations — they verify that the - * enum exposes the right identifiers and that the abstract test - * class can be subclassed without surprising side effects. The - * actual rule enforcement is exercised by downstream applications - * (Layered templates generated by the Nova Platform). + *

No evalúan las reglas de ArchUnit: verifican que la enumeración + * expone los identificadores correctos y que la clase de prueba abstracta + * se puede heredar sin efectos inesperados. Las reglas se evalúan en + * {@link LayeredArchitectureRulesTest}. */ class NovaArchitectureStyleTest { diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/CompliantServiceArchitecture.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/CompliantServiceArchitecture.java new file mode 100644 index 0000000..afa478d --- /dev/null +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/CompliantServiceArchitecture.java @@ -0,0 +1,17 @@ +package pe.edu.nova.java.archunit.fixtures; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import pe.edu.nova.java.archunit.LayeredArchitectureTest; + +/** Un servicio con sus cinco capas que cumple todas las reglas. */ +@AnalyzeClasses(packages = CompliantServiceArchitecture.BASE_PACKAGE) +public class CompliantServiceArchitecture extends LayeredArchitectureTest { + + /** El paquete del servicio de ejemplo. */ + public static final String BASE_PACKAGE = "pe.edu.nova.java.archunit.fixtures.compliant"; + + @Override + protected String basePackage() { + return BASE_PACKAGE; + } +} diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/LeakyControllerArchitecture.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/LeakyControllerArchitecture.java new file mode 100644 index 0000000..b5e4787 --- /dev/null +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/LeakyControllerArchitecture.java @@ -0,0 +1,17 @@ +package pe.edu.nova.java.archunit.fixtures; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import pe.edu.nova.java.archunit.LayeredArchitectureTest; + +/** Un servicio cuyo controlador incumple la lista de permitidos. Solo tiene esa capa. */ +@AnalyzeClasses(packages = LeakyControllerArchitecture.BASE_PACKAGE) +public class LeakyControllerArchitecture extends LayeredArchitectureTest { + + /** El paquete del servicio de ejemplo. */ + public static final String BASE_PACKAGE = "pe.edu.nova.java.archunit.fixtures.leaky"; + + @Override + protected String basePackage() { + return BASE_PACKAGE; + } +} diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/PartialServiceArchitecture.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/PartialServiceArchitecture.java new file mode 100644 index 0000000..bea3c5e --- /dev/null +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/PartialServiceArchitecture.java @@ -0,0 +1,17 @@ +package pe.edu.nova.java.archunit.fixtures; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import pe.edu.nova.java.archunit.LayeredArchitectureTest; + +/** Un servicio que solo tiene controlador y servicio: sin repositorio, entidad ni DTO. */ +@AnalyzeClasses(packages = PartialServiceArchitecture.BASE_PACKAGE) +public class PartialServiceArchitecture extends LayeredArchitectureTest { + + /** El paquete del servicio de ejemplo. */ + public static final String BASE_PACKAGE = "pe.edu.nova.java.archunit.fixtures.partial"; + + @Override + protected String basePackage() { + return BASE_PACKAGE; + } +} diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/controller/ItemController.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/controller/ItemController.java new file mode 100644 index 0000000..d3cd9b1 --- /dev/null +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/controller/ItemController.java @@ -0,0 +1,31 @@ +package pe.edu.nova.java.archunit.fixtures.compliant.controller; + +import pe.edu.nova.java.archunit.fixtures.compliant.dto.ItemResponse; +import pe.edu.nova.java.archunit.fixtures.compliant.entity.Item; +import pe.edu.nova.java.archunit.fixtures.compliant.service.ItemService; + +/** Solo traduce: llama al servicio y arma la respuesta. */ +public final class ItemController { + + private final ItemService service; + + /** + * Crea el controlador. + * + * @param service el servicio de ítems + */ + public ItemController(final ItemService service) { + this.service = service; + } + + /** + * Devuelve un ítem. + * + * @param id el identificador + * @return el ítem + */ + public ItemResponse find(final String id) { + final Item item = service.find(id); + return new ItemResponse(item.id(), item.quantity()); + } +} diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/dto/ItemResponse.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/dto/ItemResponse.java new file mode 100644 index 0000000..b319e36 --- /dev/null +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/dto/ItemResponse.java @@ -0,0 +1,9 @@ +package pe.edu.nova.java.archunit.fixtures.compliant.dto; + +/** + * La respuesta de un ítem. + * + * @param id el identificador + * @param quantity las unidades + */ +public record ItemResponse(String id, int quantity) {} diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/entity/Item.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/entity/Item.java new file mode 100644 index 0000000..536de54 --- /dev/null +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/entity/Item.java @@ -0,0 +1,19 @@ +package pe.edu.nova.java.archunit.fixtures.compliant.entity; + +import pe.edu.nova.java.libs.fixture.FixtureError; + +/** Un ítem que defiende su propio invariante con un error de la librería de Nova, sin framework. */ +public record Item(String id, int quantity) { + + /** + * Crea el ítem. + * + * @param id el identificador + * @param quantity las unidades, que no pueden ser negativas + */ + public Item { + if (quantity < 0) { + throw FixtureError.of("la cantidad no puede ser negativa"); + } + } +} diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/repository/ItemRepository.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/repository/ItemRepository.java new file mode 100644 index 0000000..bccb62d --- /dev/null +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/repository/ItemRepository.java @@ -0,0 +1,21 @@ +package pe.edu.nova.java.archunit.fixtures.compliant.repository; + +import pe.edu.nova.java.archunit.fixtures.compliant.entity.Item; +import pe.edu.nova.java.libs.fixture.FixtureError; + +/** Los ítems guardados. Traduce el fallo del almacén a un error de la librería de Nova. */ +public final class ItemRepository { + + /** + * Busca un ítem. + * + * @param id el identificador + * @return el ítem + */ + public Item findById(final String id) { + if (id.isEmpty()) { + throw FixtureError.of("el almacén no responde"); + } + return new Item(id, 1); + } +} diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/service/ItemService.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/service/ItemService.java new file mode 100644 index 0000000..300235d --- /dev/null +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/service/ItemService.java @@ -0,0 +1,35 @@ +package pe.edu.nova.java.archunit.fixtures.compliant.service; + +import pe.edu.nova.java.archunit.fixtures.compliant.entity.Item; +import pe.edu.nova.java.archunit.fixtures.compliant.repository.ItemRepository; +import pe.edu.nova.java.libs.fixture.FixtureError; + +/** El caso de uso. Lanza lo que salió mal como un error de la librería de Nova, no como un status HTTP. */ +public final class ItemService { + + private final ItemRepository items; + + /** + * Crea el servicio. + * + * @param items los ítems guardados + */ + public ItemService(final ItemRepository items) { + this.items = items; + } + + /** + * Busca un ítem. + * + * @param id el identificador + * @return el ítem + * @throws FixtureError si el ítem no existe + */ + public Item find(final String id) throws FixtureError { + final Item item = items.findById(id); + if (item == null) { + throw FixtureError.of("el ítem " + id + " no existe"); + } + return item; + } +} diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/leaky/config/Settings.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/leaky/config/Settings.java new file mode 100644 index 0000000..3148411 --- /dev/null +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/leaky/config/Settings.java @@ -0,0 +1,14 @@ +package pe.edu.nova.java.archunit.fixtures.leaky.config; + +/** Un paquete del propio servicio que no es una de las cinco capas. */ +public final class Settings { + + /** + * Un valor cualquiera, para que haya algo que llamar. + * + * @return el nombre de la configuración + */ + public String name() { + return "settings"; + } +} diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/leaky/controller/LeakyController.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/leaky/controller/LeakyController.java new file mode 100644 index 0000000..ff499e5 --- /dev/null +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/leaky/controller/LeakyController.java @@ -0,0 +1,17 @@ +package pe.edu.nova.java.archunit.fixtures.leaky.controller; + +import pe.edu.nova.java.archunit.fixtures.leaky.config.Settings; +import pe.edu.nova.java.starters.fixture.Wiring; + +/** Un controlador que se sale de las capas: llega a un paquete propio y a un starter de Nova. */ +public final class LeakyController { + + /** + * Describe algo con lo que no debería tocar. + * + * @return un texto + */ + public String describe() { + return new Settings().name() + Wiring.name(); + } +} diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/partial/controller/PingController.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/partial/controller/PingController.java new file mode 100644 index 0000000..3ae5e90 --- /dev/null +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/partial/controller/PingController.java @@ -0,0 +1,27 @@ +package pe.edu.nova.java.archunit.fixtures.partial.controller; + +import pe.edu.nova.java.archunit.fixtures.partial.service.PingService; + +/** Un controlador que llama al servicio. */ +public final class PingController { + + private final PingService service; + + /** + * Crea el controlador. + * + * @param service el servicio + */ + public PingController(final PingService service) { + this.service = service; + } + + /** + * Responde. + * + * @return la respuesta del servicio + */ + public String ping() { + return service.ping(); + } +} diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/partial/service/PingService.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/partial/service/PingService.java new file mode 100644 index 0000000..35c1fd3 --- /dev/null +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/partial/service/PingService.java @@ -0,0 +1,25 @@ +package pe.edu.nova.java.archunit.fixtures.partial.service; + +/** Un servicio sin persistencia, que no tiene repositorio ni entidad. */ +public final class PingService { + + private final String name; + + /** + * Crea el servicio. + * + * @param name el nombre con el que responde + */ + public PingService(final String name) { + this.name = name; + } + + /** + * Responde. + * + * @return la respuesta + */ + public String ping() { + return "pong " + name; + } +} diff --git a/src/test/java/pe/edu/nova/java/libs/fixture/FixtureError.java b/src/test/java/pe/edu/nova/java/libs/fixture/FixtureError.java new file mode 100644 index 0000000..fe2f525 --- /dev/null +++ b/src/test/java/pe/edu/nova/java/libs/fixture/FixtureError.java @@ -0,0 +1,25 @@ +package pe.edu.nova.java.libs.fixture; + +/** + * Hace las veces de una librería de Nova sin framework, como el módulo de errores por capas de ADR-031 + * ({@code pe.edu.nova.java.libs.api.standard.error}). Vive bajo {@code pe.edu.nova.java.libs}, que es lo + * que reconocen las reglas, y se escribe aquí para que la prueba no dependa de una librería publicada. + */ +public final class FixtureError extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private FixtureError(final String message) { + super(message); + } + + /** + * Crea el error con una fábrica, como las de {@code DomainError} y {@code ApplicationError}. + * + * @param message el mensaje para la persona + * @return el error + */ + public static FixtureError of(final String message) { + return new FixtureError(message); + } +} diff --git a/src/test/java/pe/edu/nova/java/starters/fixture/Wiring.java b/src/test/java/pe/edu/nova/java/starters/fixture/Wiring.java new file mode 100644 index 0000000..cbd523e --- /dev/null +++ b/src/test/java/pe/edu/nova/java/starters/fixture/Wiring.java @@ -0,0 +1,19 @@ +package pe.edu.nova.java.starters.fixture; + +/** + * Hace las veces de un starter de Nova, que cablea el framework. Vive bajo + * {@code pe.edu.nova.java.starters}, y las reglas no lo cuentan como una librería sin framework. + */ +public final class Wiring { + + private Wiring() {} + + /** + * Un valor cualquiera, para que haya algo que llamar. + * + * @return el nombre del cableado + */ + public static String name() { + return "wiring"; + } +} From d7ac6751f4c7b339f54e5240b289dc7baa8a8311 Mon Sep 17 00:00:00 2001 From: Angel Eduardo Hincho Jove Date: Thu, 1 Oct 2026 22:13:26 -0500 Subject: [PATCH 2/4] feat: let controllers use the framework-free Nova libraries The controller rule only admitted the layers, java, jakarta, Spring, Quarkus and the test libraries, so a controller could not throw ApplicationError.invalidInput(...) from the layered errors of ADR-031: the rule flagged the call. Allow pe.edu.nova.java.libs.., the framework-free Nova libraries, in that rule. It is narrower than pe.edu.nova.. on purpose. A service lives under pe.edu.nova too, so allowing the whole prefix would stop the rule from catching a controller that reaches the service's own packages outside the layers. The Nova starters stay out, because they wire the framework. The controller was the only layer with an allow-list. The rules of the entity, service, repository and DTO layers only forbid other layers, so they already accepted these libraries and the domain could already throw DomainError. The sample services now prove it. --- .../archunit/LayeredArchitectureTest.java | 23 +++++++++++++++---- .../LayeredArchitectureRulesTest.java | 4 +++- .../compliant/controller/ItemController.java | 6 ++++- .../leaky/controller/LeakyController.java | 12 ++++++++-- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/main/java/pe/edu/nova/java/archunit/LayeredArchitectureTest.java b/src/main/java/pe/edu/nova/java/archunit/LayeredArchitectureTest.java index 75b9cfd..fb00e8e 100644 --- a/src/main/java/pe/edu/nova/java/archunit/LayeredArchitectureTest.java +++ b/src/main/java/pe/edu/nova/java/archunit/LayeredArchitectureTest.java @@ -57,6 +57,13 @@ public abstract class LayeredArchitectureTest { /** Cross-cutting package (shared exceptions, identifiers). */ protected static final String SHARED_PACKAGE = "shared"; + /** + * Las librerías de Nova que no dependen de ningún framework, como el módulo de errores por capas de + * ADR-031 ({@code pe.edu.nova.java.libs.api.standard.error}). A diferencia de las constantes de arriba, + * es un patrón absoluto y no una capa del servicio. + */ + protected static final String NOVA_LIBRARIES = "pe.edu.nova.java.libs.."; + /** * Returns the base package that contains all the application's * code. Defaults to the root package passed to @@ -88,9 +95,15 @@ private String layered(final String leaf) { + "never call repositories directly"); /** - * Controllers may depend on services (mandatory) and the rest of - * the layers, but never on infrastructure-only modules (none - * exist in the Layered convention so this is a defensive rule). + * Un controlador solo accede a las capas del servicio, a la JDK, a Jakarta, a Spring, a Quarkus, a las + * librerías de prueba y a las librerías de Nova sin framework ({@link #NOVA_LIBRARIES}). Estas últimas + * traen los errores por capas de ADR-031, y un controlador tiene que poder lanzar + * {@code ApplicationError.invalidInput(...)} sin que la regla lo marque. + * + *

Es la única capa con una lista de permitidos. Las reglas de las demás solo prohíben capas + * concretas, así que el dominio ({@code entity} y {@code service}) y los repositorios ya podían usar + * esas librerías, como {@code DomainError}. Los starters de Nova ({@code pe.edu.nova.java.starters..}) + * y los demás paquetes del propio servicio siguen fuera de la lista. */ @ArchTest private final ArchRule controllers_depend_only_on_allowed_layers = @@ -103,6 +116,7 @@ private String layered(final String leaf) { layered(ENTITY_PACKAGE), layered(DTO_PACKAGE), layered(SHARED_PACKAGE), + NOVA_LIBRARIES, "java..", "jakarta..", "org.springframework..", @@ -112,7 +126,8 @@ private String layered(final String leaf) { "org.assertj..") .because("controllers are the driving adapter layer; " + "their dependencies must be limited to " - + "the documented Layered packages"); + + "the documented Layered packages and " + + "the framework-free Nova libraries"); /** * Services are the orchestration layer: they may consume diff --git a/src/test/java/pe/edu/nova/java/archunit/LayeredArchitectureRulesTest.java b/src/test/java/pe/edu/nova/java/archunit/LayeredArchitectureRulesTest.java index 9931b06..82f5229 100644 --- a/src/test/java/pe/edu/nova/java/archunit/LayeredArchitectureRulesTest.java +++ b/src/test/java/pe/edu/nova/java/archunit/LayeredArchitectureRulesTest.java @@ -11,6 +11,7 @@ import pe.edu.nova.java.archunit.fixtures.PartialServiceArchitecture; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.platform.engine.TestExecutionResult.Status.SUCCESSFUL; @@ -38,13 +39,14 @@ void aServiceThatFollowsTheLayersPassesEveryRule() { } @Test - void aControllerMayNotReachTheServicePackagesOutsideTheLayersNorTheStartersOfNova() { + void aControllerMayUseTheNovaLibrariesButNotTheRestOfNova() { final Map failures = failuresOf(evaluate(LeakyControllerArchitecture.class)); final String violations = failures.get(CONTROLLER_ALLOW_LIST); assertNotNull(violations, "the controller reached packages it should not have: " + failures.keySet()); assertTrue(violations.contains("Settings.name()"), violations); assertTrue(violations.contains("Wiring.name()"), violations); + assertFalse(violations.contains("FixtureError"), violations); } @Test diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/controller/ItemController.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/controller/ItemController.java index d3cd9b1..97c2b6b 100644 --- a/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/controller/ItemController.java +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/compliant/controller/ItemController.java @@ -3,8 +3,9 @@ import pe.edu.nova.java.archunit.fixtures.compliant.dto.ItemResponse; import pe.edu.nova.java.archunit.fixtures.compliant.entity.Item; import pe.edu.nova.java.archunit.fixtures.compliant.service.ItemService; +import pe.edu.nova.java.libs.fixture.FixtureError; -/** Solo traduce: llama al servicio y arma la respuesta. */ +/** Rechaza la entrada con un error de la librería de Nova, como pide ADR-031, y deja el resto al servicio. */ public final class ItemController { private final ItemService service; @@ -25,6 +26,9 @@ public ItemController(final ItemService service) { * @return el ítem */ public ItemResponse find(final String id) { + if (id.isBlank()) { + throw FixtureError.of("el id es obligatorio"); + } final Item item = service.find(id); return new ItemResponse(item.id(), item.quantity()); } diff --git a/src/test/java/pe/edu/nova/java/archunit/fixtures/leaky/controller/LeakyController.java b/src/test/java/pe/edu/nova/java/archunit/fixtures/leaky/controller/LeakyController.java index ff499e5..fe678c6 100644 --- a/src/test/java/pe/edu/nova/java/archunit/fixtures/leaky/controller/LeakyController.java +++ b/src/test/java/pe/edu/nova/java/archunit/fixtures/leaky/controller/LeakyController.java @@ -1,17 +1,25 @@ package pe.edu.nova.java.archunit.fixtures.leaky.controller; import pe.edu.nova.java.archunit.fixtures.leaky.config.Settings; +import pe.edu.nova.java.libs.fixture.FixtureError; import pe.edu.nova.java.starters.fixture.Wiring; -/** Un controlador que se sale de las capas: llega a un paquete propio y a un starter de Nova. */ +/** + * Un controlador que se sale de las capas: llega a un paquete propio y a un starter de Nova. También usa la + * librería de Nova, que sí puede. + */ public final class LeakyController { /** * Describe algo con lo que no debería tocar. * + * @param id el identificador * @return un texto */ - public String describe() { + public String describe(final String id) { + if (id.isBlank()) { + throw FixtureError.of("el id es obligatorio"); + } return new Settings().name() + Wiring.name(); } } From e96acbe77ddd32625f0eb4a903a0ec24788ccd32 Mon Sep 17 00:00:00 2001 From: Angel Eduardo Hincho Jove Date: Thu, 1 Oct 2026 22:16:11 -0500 Subject: [PATCH 3/4] docs: describe the layered rules as they are and the Gradle install The README still showed Maven, version 1.0.0 and mvn test, and it listed a rule the library does not have. The library forbids service fields that are not final, which rules out field injection, but it never looks for @Autowired. Show the Gradle install from GitHub Packages and the toolchain plugin that brings the library, run the tests with Gradle, and list each layer with the rules that apply to it. Explain that the controller is the only layer with an allow-list and why it admits pe.edu.nova.java.libs.. and not pe.edu.nova.., and that a layer with no classes fails on purpose, with the ArchUnit switch for a service that really lacks one. --- README.md | 96 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 81 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3d8c7c4..13037b6 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,36 @@ drift into a failed CI run. | **Clean** *(phase 2)* | `CleanArchitectureTest` | `domain..`, `application..`, `infrastructure..`, `api..` | | **Hexagonal** *(phase 2)* | `HexagonalArchitectureTest` | `domain..`, `application..`, `adapters..` | +Only the layered rules exist so far. `NovaArchitectureStyle` already names the +other two. + ## Install -```xml - - pe.edu.nova.java.libs - nova-architecture-rules - 1.0.0 - test - +Published to GitHub Packages, so the repository needs to be declared and +authenticated with a token that has `read:packages`. + + +```kotlin +repositories { + maven { + url = uri("https://maven.pkg.github.com/ahincho/nova-java-07-architecture-rules") + credentials { + username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR") + password = providers.gradleProperty("gpr.key").orNull ?: System.getenv("GITHUB_TOKEN") + } + } +} + +dependencies { + testImplementation("pe.edu.nova.java.libs:nova-architecture-rules:1.2.0") +} ``` + + +A Spring Boot service that applies `pe.edu.nova.java.spring-boot-service` from the +[Nova Gradle toolchain](https://github.com/ahincho/nova-java-24-gradle-toolchain) +declares none of this: the plugin puts the library on the test classpath at the +version it pins. ## Use @@ -48,18 +68,64 @@ class ArchitectureTest extends LayeredArchitectureTest { } ``` -Run `mvn test` — ArchUnit reports every violation as a JUnit failure +Run `./gradlew test`. ArchUnit reports every violation as a JUnit failure with the exact offending class. ## Layered rules enforced -- `controller..` depends on `service..`, `entity..`, `dto..`, `shared..` — never on `repository..`. -- `service..` depends on `repository..`, `entity..`, `shared..` — never on `controller..`. -- `repository..` depends on `entity..`, `shared..` — never on anything above. -- `entity..` is a pure data layer — no imports from other layers. -- `dto..` never imports `entity..` (decoupling transport from persistence). -- No service method declares `throws Exception`. -- No `@Autowired` field injection on services. +| Layer | What the rules say | +|---|---| +| `controller..` | never depends on `repository..`, and only reaches an allow-list: the layers and `shared..` of the service, `java..`, `jakarta..`, Spring, Quarkus, the test libraries (JUnit, Mockito, AssertJ) and the framework-free Nova libraries, `pe.edu.nova.java.libs..` | +| `service..` | never depends on `controller..`; no public method declares `throws Exception`; every instance field is `final`, which rules out field injection | +| `repository..` | never depends on `controller..` or `service..` | +| `entity..` | never depends on `controller..`, `service..`, `repository..` or `dto..` | +| `dto..` | never depends on `entity..` | +| any class | does not throw a generic `Throwable`, `Exception` or `RuntimeException` | + +**The controller is the only layer with an allow-list.** The rules of the other +layers forbid specific layers and nothing else, so whatever they do not forbid is +allowed. That is why an entity, a service or a repository can throw the layered +errors of +[ADR-031](https://github.com/ahincho/nova-shared-01-docs/blob/main/adrs/shared/ADR-031-modulo-de-errores-por-capas-con-trazabilidad.md), +such as `DomainError`, and why a controller needed the Nova libraries added to its +list to throw `ApplicationError.invalidInput(...)`. + +The allowance is `pe.edu.nova.java.libs..`, the framework-free libraries, and not +the whole `pe.edu.nova..`. A service lives under `pe.edu.nova` too, and allowing +the prefix would exempt the service's own packages from the rule. The Nova +starters, `pe.edu.nova.java.starters..`, wire the framework, so a controller does +not reach them either. + +## A layer with no classes + +A rule that finds no class in its layer fails with `failed to check any classes` +instead of passing. That is on purpose. The same check catches a `basePackage()` +that stopped matching the code after a rename, and a layer named `controllers` or +`model`, which would otherwise pass without checking a single class. A service +without persistence keeps its `repository` and `entity` packages, each with at +least one class. Two of the service rules fail the same way when the service +layer has no instance field or no public method to check. + +A service that really has no such layer can allow it for its whole test run, with +the switch of ArchUnit itself in `src/test/resources/archunit.properties`: + +```properties +archRule.failOnEmptyShould=false +``` + +It turns off the protection above for every rule, so it is a decision the service +writes down, not a default. + +## Develop + +```bash +./gradlew build +``` + +`LayeredArchitectureRulesTest` runs the rules through the ArchUnit engine over the +sample services in `src/test/java/pe/edu/nova/java/archunit/fixtures`, and checks +which rule fails and why. Gradle does not run those samples as tests, because some +of them break the rules on purpose. ## License From df855546947826bc615111756a59234c9af845d2 Mon Sep 17 00:00:00 2001 From: Angel Eduardo Hincho Jove Date: Thu, 1 Oct 2026 22:16:22 -0500 Subject: [PATCH 4/4] chore: let release-please bump the version in the README The install snippet of the README names the version of the library, and it went stale after the first release: it still showed 1.0.0 when the library was at 1.1.2. Mark the snippet for release-please, so that each release pull request updates it together with the changelog. --- .release-please-config.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.release-please-config.json b/.release-please-config.json index fde452b..14460a1 100644 --- a/.release-please-config.json +++ b/.release-please-config.json @@ -9,7 +9,13 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "draft": false, - "prerelease": false + "prerelease": false, + "extra-files": [ + { + "type": "generic", + "path": "README.md" + } + ] } } }