diff --git a/README.md b/README.md index 2d5f1ac..8e38d47 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,24 @@ flowchart LR Backup --> Bucket[(Object Storage)] ``` +Inside the app, each feature package is a module. The dependencies between them are +declared in each `package-info.java` and checked by a test (Spring Modulith): no cycles, +and nothing beyond the list. + +```mermaid +flowchart BT + catalog --> auth & store + reviews --> auth & store + stats --> auth & catalog & store + collection --> catalog & reviews & store + alerts --> auth & collection & store + publicpage --> auth & stats +``` + ## Engineering highlights +- **Architecture as a test:** Spring Modulith checks that the feature modules form no + cycle and use only the modules each one declares. - **Tests against recorded store responses:** WireMock serves real, anonymized responses of each store; a weekly workflow runs the same adapters against the live stores to catch changes. Testcontainers runs the database tests on a real PostgreSQL. A JaCoCo diff --git a/docs/decisions/0002-modular-monolith.md b/docs/decisions/0002-modular-monolith.md index 0320e6a..1cb68ff 100644 --- a/docs/decisions/0002-modular-monolith.md +++ b/docs/decisions/0002-modular-monolith.md @@ -14,12 +14,20 @@ One Spring Boot application (a monolith), with one package per feature: `store`, `collection`, `stats`, `reviews`, `alerts`, `publicpage`, `auth`. Each package owns its classes; others use it only through its public services. +Since 2026-10-28 this is checked, not only intended: Spring Modulith treats each +top-level package as a module, and `ModularityTests` fails on a cycle between modules +or on a dependency a module's `package-info.java` doesn't allow +(`@ApplicationModule(allowedDependencies = ...)`). `store` and `auth` depend on no other +module; the others list theirs. + ## Consequences - One thing to build, test, deploy and debug. - The packages can become separate services later if one part needs to scale on its own. - Packaging by feature (not by layer: `controllers/`, `services/`, `repositories/`) keeps everything about reviews in one place. +- A new dependency between features is a one-line change to a `package-info.java`, + visible in review, instead of an import nobody notices. ## Alternatives considered diff --git a/pom.xml b/pom.xml index 892b9ac..b53e6ce 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,7 @@ 1.23.2 2.4.0 3.1.1 + 2.1.1 @@ -50,6 +51,13 @@ + + org.springframework.modulith + spring-modulith-bom + ${spring-modulith.version} + pom + import + org.webjars swagger-ui @@ -194,6 +202,17 @@ ${wiremock.version} test + + + org.springframework.modulith + spring-modulith-api + + + + org.springframework.modulith + spring-modulith-starter-test + test + diff --git a/src/main/java/com/mccprogrammer/extpulse/alerts/package-info.java b/src/main/java/com/mccprogrammer/extpulse/alerts/package-info.java index 2a07b0b..9b94c5a 100644 --- a/src/main/java/com/mccprogrammer/extpulse/alerts/package-info.java +++ b/src/main/java/com/mccprogrammer/extpulse/alerts/package-info.java @@ -2,5 +2,10 @@ * Alerts: rules a user sets (low ratings, install spikes, new versions, a listing that * stopped being collected, a weekly summary), checked after every collection and sent by * email or to a Discord channel, never twice for the same thing. + *

+ * May use only auth, collection, store (checked by {@code ModularityTests}). */ +@ApplicationModule(allowedDependencies = { "auth", "collection", "store" }) package com.mccprogrammer.extpulse.alerts; + +import org.springframework.modulith.ApplicationModule; diff --git a/src/main/java/com/mccprogrammer/extpulse/auth/package-info.java b/src/main/java/com/mccprogrammer/extpulse/auth/package-info.java index b3bc506..512a23c 100644 --- a/src/main/java/com/mccprogrammer/extpulse/auth/package-info.java +++ b/src/main/java/com/mccprogrammer/extpulse/auth/package-info.java @@ -1,4 +1,9 @@ /** * Accounts, login and JWT tokens (Spring Security). + *

+ * Depends on nothing else in ExtPulse. */ +@ApplicationModule(allowedDependencies = {}) package com.mccprogrammer.extpulse.auth; + +import org.springframework.modulith.ApplicationModule; diff --git a/src/main/java/com/mccprogrammer/extpulse/catalog/package-info.java b/src/main/java/com/mccprogrammer/extpulse/catalog/package-info.java index 2f66b60..cb58d06 100644 --- a/src/main/java/com/mccprogrammer/extpulse/catalog/package-info.java +++ b/src/main/java/com/mccprogrammer/extpulse/catalog/package-info.java @@ -1,5 +1,10 @@ /** * What ExtPulse tracks: publishers, their extensions and each extension's listings on the * stores. The other packages read listings from here. + *

+ * May use only auth, store (checked by {@code ModularityTests}). */ +@ApplicationModule(allowedDependencies = { "auth", "store" }) package com.mccprogrammer.extpulse.catalog; + +import org.springframework.modulith.ApplicationModule; diff --git a/src/main/java/com/mccprogrammer/extpulse/collection/package-info.java b/src/main/java/com/mccprogrammer/extpulse/collection/package-info.java index a879801..d71962c 100644 --- a/src/main/java/com/mccprogrammer/extpulse/collection/package-info.java +++ b/src/main/java/com/mccprogrammer/extpulse/collection/package-info.java @@ -1,5 +1,10 @@ /** * Daily collection: the scheduled job that asks every store adapter for stats and * reviews, saves one snapshot per listing per day and records each run. + *

+ * May use only catalog, reviews, store (checked by {@code ModularityTests}). */ +@ApplicationModule(allowedDependencies = { "catalog", "reviews", "store" }) package com.mccprogrammer.extpulse.collection; + +import org.springframework.modulith.ApplicationModule; diff --git a/src/main/java/com/mccprogrammer/extpulse/publicpage/package-info.java b/src/main/java/com/mccprogrammer/extpulse/publicpage/package-info.java index c0ccb5d..3fa939e 100644 --- a/src/main/java/com/mccprogrammer/extpulse/publicpage/package-info.java +++ b/src/main/java/com/mccprogrammer/extpulse/publicpage/package-info.java @@ -1,4 +1,9 @@ /** * Public portfolio page and the combined installs badge, served without login. + *

+ * May use only auth, stats (checked by {@code ModularityTests}). */ +@ApplicationModule(allowedDependencies = { "auth", "stats" }) package com.mccprogrammer.extpulse.publicpage; + +import org.springframework.modulith.ApplicationModule; diff --git a/src/main/java/com/mccprogrammer/extpulse/reviews/package-info.java b/src/main/java/com/mccprogrammer/extpulse/reviews/package-info.java index 66a0b04..fca1286 100644 --- a/src/main/java/com/mccprogrammer/extpulse/reviews/package-info.java +++ b/src/main/java/com/mccprogrammer/extpulse/reviews/package-info.java @@ -1,4 +1,9 @@ /** * Reviews from every store in one inbox, with answered / unanswered status. + *

+ * May use only auth, store (checked by {@code ModularityTests}). */ +@ApplicationModule(allowedDependencies = { "auth", "store" }) package com.mccprogrammer.extpulse.reviews; + +import org.springframework.modulith.ApplicationModule; diff --git a/src/main/java/com/mccprogrammer/extpulse/stats/package-info.java b/src/main/java/com/mccprogrammer/extpulse/stats/package-info.java index 4b05a3e..75d7bca 100644 --- a/src/main/java/com/mccprogrammer/extpulse/stats/package-info.java +++ b/src/main/java/com/mccprogrammer/extpulse/stats/package-info.java @@ -1,5 +1,10 @@ /** * Statistics read from the saved snapshots: combined totals, daily and weekly gains, * split by store. + *

+ * May use only auth, catalog, store (checked by {@code ModularityTests}). */ +@ApplicationModule(allowedDependencies = { "auth", "catalog", "store" }) package com.mccprogrammer.extpulse.stats; + +import org.springframework.modulith.ApplicationModule; diff --git a/src/main/java/com/mccprogrammer/extpulse/store/package-info.java b/src/main/java/com/mccprogrammer/extpulse/store/package-info.java index c2b6c5f..57a13ef 100644 --- a/src/main/java/com/mccprogrammer/extpulse/store/package-info.java +++ b/src/main/java/com/mccprogrammer/extpulse/store/package-info.java @@ -1,5 +1,10 @@ /** * Store adapters: one {@code StoreSource} per store (VS Code Marketplace, Open VSX, * Chrome Web Store). The only code that talks to the stores. + *

+ * Depends on nothing else in ExtPulse. */ +@ApplicationModule(allowedDependencies = {}) package com.mccprogrammer.extpulse.store; + +import org.springframework.modulith.ApplicationModule; diff --git a/src/test/java/com/mccprogrammer/extpulse/ModularityTests.java b/src/test/java/com/mccprogrammer/extpulse/ModularityTests.java new file mode 100644 index 0000000..d69c3c6 --- /dev/null +++ b/src/test/java/com/mccprogrammer/extpulse/ModularityTests.java @@ -0,0 +1,20 @@ +package com.mccprogrammer.extpulse; + +import org.junit.jupiter.api.Test; + +import org.springframework.modulith.core.ApplicationModules; + +/** + * The package-per-feature structure, checked by Spring Modulith: each top-level package + * is a module, modules form no cycle, and a module uses only the ones its package-info + * allows ({@code @ApplicationModule(allowedDependencies = ...)}). A new dependency + * between features is then a change to that list, made on purpose and reviewed. + */ +class ModularityTests { + + @Test + void modulesUseOnlyTheModulesTheyAreAllowedTo() { + ApplicationModules.of(ExtpulseApplication.class).verify(); + } + +}