Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
}
}
96 changes: 81 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<dependency>
<groupId>pe.edu.nova.java.libs</groupId>
<artifactId>nova-architecture-rules</artifactId>
<version>1.0.0</version>
<scope>test</scope>
</dependency>
Published to GitHub Packages, so the repository needs to be declared and
authenticated with a token that has `read:packages`.

<!-- x-release-please-start-version -->
```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")
}
```
<!-- x-release-please-end -->

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

Expand All @@ -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

Expand Down
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand All @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
* <p>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 =
Expand All @@ -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..",
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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.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;
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.
*
* <p>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<String, TestExecutionResult> rules = evaluate(CompliantServiceArchitecture.class);

assertTrue(rules.containsKey(CONTROLLER_ALLOW_LIST), "the rules did not run: " + rules.keySet());
assertEquals(Map.of(), failuresOf(rules));
}

@Test
void aControllerMayUseTheNovaLibrariesButNotTheRestOfNova() {
final Map<String, String> 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
void aLayerWithoutClassesFailsInsteadOfPassing() {
final Map<String, String> 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<String, TestExecutionResult> evaluate(final Class<?> architecture) {
final Map<String, TestExecutionResult> 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<String, String> failuresOf(final Map<String, TestExecutionResult> rules) {
final Map<String, String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
*
* <p>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).
* <p>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 {

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading