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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/decisions/0002-modular-monolith.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<jsoup.version>1.23.2</jsoup.version>
<resilience4j.version>2.4.0</resilience4j.version>
<springdoc.version>3.1.1</springdoc.version>
<spring-modulith.version>2.1.1</spring-modulith.version>
<!-- springdoc 3.1.1 brings Swagger UI 5.32.14, whose DOMPurify (3.4.13) has known
vulnerabilities (ZAP flagged it); 5.33.2 updates it to 3.4.16. Drop this once
springdoc brings a newer Swagger UI itself. -->
Expand All @@ -50,6 +51,13 @@
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>${spring-modulith.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>swagger-ui</artifactId>
Expand Down Expand Up @@ -194,6 +202,17 @@
<version>${wiremock.version}</version>
<scope>test</scope>
</dependency>
<!-- @ApplicationModule in each package-info: which modules a module may use. -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-api</artifactId>
</dependency>
<!-- ModularityTests: checks those rules and that modules form no cycle. -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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;
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
/**
* Accounts, login and JWT tokens (Spring Security).
* <p>
* Depends on nothing else in ExtPulse.
*/
@ApplicationModule(allowedDependencies = {})
package com.mccprogrammer.extpulse.auth;

import org.springframework.modulith.ApplicationModule;
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* May use only auth, store (checked by {@code ModularityTests}).
*/
@ApplicationModule(allowedDependencies = { "auth", "store" })
package com.mccprogrammer.extpulse.catalog;

import org.springframework.modulith.ApplicationModule;
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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;
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
/**
* Public portfolio page and the combined installs badge, served without login.
* <p>
* May use only auth, stats (checked by {@code ModularityTests}).
*/
@ApplicationModule(allowedDependencies = { "auth", "stats" })
package com.mccprogrammer.extpulse.publicpage;

import org.springframework.modulith.ApplicationModule;
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
/**
* Reviews from every store in one inbox, with answered / unanswered status.
* <p>
* May use only auth, store (checked by {@code ModularityTests}).
*/
@ApplicationModule(allowedDependencies = { "auth", "store" })
package com.mccprogrammer.extpulse.reviews;

import org.springframework.modulith.ApplicationModule;
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
/**
* Statistics read from the saved snapshots: combined totals, daily and weekly gains,
* split by store.
* <p>
* 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;
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* Depends on nothing else in ExtPulse.
*/
@ApplicationModule(allowedDependencies = {})
package com.mccprogrammer.extpulse.store;

import org.springframework.modulith.ApplicationModule;
20 changes: 20 additions & 0 deletions src/test/java/com/mccprogrammer/extpulse/ModularityTests.java
Original file line number Diff line number Diff line change
@@ -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();
}

}
Loading