From 012c202c80a1fac7d0ba0cd87b130eb316118d2e Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 13 Dec 2025 15:55:35 +1100 Subject: [PATCH 1/2] Refactor with spring modulith --- auto/docker_logs | 2 +- auto/run | 2 +- build.gradle.kts | 8 +- ...{UserApplication.java => Application.java} | 11 +- .../nkcoder/infrastructure/package-info.java | 18 ++ .../notification/NotificationService.java | 16 ++ .../application/UserEventListener.java | 20 ++ .../nkcoder/notification/package-info.java | 20 ++ .../domain/event/UserRegisteredEvent.java | 21 ++ .../java/org/nkcoder/shared/package-info.java | 18 ++ .../CurrentUserArgumentResolver.java | 5 +- .../infrastructure}/WebConfig.java | 3 +- .../security/JwtAuthenticationEntryPoint.java | 2 +- .../security/JwtAuthenticationFilter.java | 2 +- .../security}/SecurityConfig.java | 5 +- .../java/org/nkcoder/user/package-info.java | 23 ++ .../V1.4__create_event_publication_table.sql | 23 ++ .../org/nkcoder/ModulithArchitectureTest.java | 25 +++ .../AdminUserControllerIntegrationTest.java | 70 +++--- .../AuthControllerIntegrationTest.java | 2 +- .../integration/AuthFlowIntegrationTest.java | 202 +++++++++--------- 21 files changed, 345 insertions(+), 153 deletions(-) rename src/main/java/org/nkcoder/{UserApplication.java => Application.java} (51%) create mode 100644 src/main/java/org/nkcoder/infrastructure/package-info.java create mode 100644 src/main/java/org/nkcoder/notification/NotificationService.java create mode 100644 src/main/java/org/nkcoder/notification/application/UserEventListener.java create mode 100644 src/main/java/org/nkcoder/notification/package-info.java create mode 100644 src/main/java/org/nkcoder/shared/kernel/domain/event/UserRegisteredEvent.java create mode 100644 src/main/java/org/nkcoder/shared/package-info.java rename src/main/java/org/nkcoder/{infrastructure/resolver => user/infrastructure}/CurrentUserArgumentResolver.java (91%) rename src/main/java/org/nkcoder/{infrastructure/config => user/infrastructure}/WebConfig.java (85%) rename src/main/java/org/nkcoder/{ => user}/infrastructure/security/JwtAuthenticationEntryPoint.java (97%) rename src/main/java/org/nkcoder/{ => user}/infrastructure/security/JwtAuthenticationFilter.java (98%) rename src/main/java/org/nkcoder/{infrastructure/config => user/infrastructure/security}/SecurityConfig.java (96%) create mode 100644 src/main/java/org/nkcoder/user/package-info.java create mode 100644 src/main/resources/db/migration/V1.4__create_event_publication_table.sql create mode 100644 src/test/java/org/nkcoder/ModulithArchitectureTest.java rename src/test/java/org/nkcoder/{ => user}/integration/AdminUserControllerIntegrationTest.java (89%) rename src/test/java/org/nkcoder/{ => user}/integration/AuthControllerIntegrationTest.java (99%) rename src/test/java/org/nkcoder/{ => user}/integration/AuthFlowIntegrationTest.java (79%) diff --git a/auto/docker_logs b/auto/docker_logs index d9c90eb..5a913ae 100755 --- a/auto/docker_logs +++ b/auto/docker_logs @@ -1,3 +1,3 @@ #!/usr/bin/env sh -docker compose -f docker-compose-all.yml up - \ No newline at end of file +docker compose -f docker-compose-all.yml logs -f --tail 100 diff --git a/auto/run b/auto/run index 1d38033..f521052 100755 --- a/auto/run +++ b/auto/run @@ -1,3 +1,3 @@ #!/usr/bin/env sh -./gradlew bootRun --args='--spring.profiles.active=local' --no-daemon \ No newline at end of file +./gradlew bootRun --args='--spring.profiles.active=local' --no-daemon diff --git a/build.gradle.kts b/build.gradle.kts index d513e7a..6627fa5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,12 +4,12 @@ plugins { id("org.springframework.boot") version "4.0.0" id("io.spring.dependency-management") version "1.1.7" id("org.graalvm.buildtools.native") version "0.11.1" - id("org.jetbrains.kotlin.jvm") version "2.2.21" id("org.flywaydb.flyway") version "11.11.1" id("com.diffplug.spotless") version "8.1.0" id("com.google.protobuf") version "0.9.5" java jacoco + kotlin("jvm") version "2.2.21" } group = "org.nkcoder" @@ -27,6 +27,7 @@ repositories { extra["testcontainersVersion"] = "1.21.3" extra["jjwtVersion"] = "0.13.0" +extra["springModulithVersion"] = "2.0.0" dependencies { // Spring Boot Starters @@ -37,6 +38,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-jackson") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.0") + implementation("org.springframework.modulith:spring-modulith-starter-core") + implementation("org.springframework.modulith:spring-modulith-starter-jpa") // Database implementation("org.springframework.boot:spring-boot-starter-flyway") @@ -62,6 +65,7 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.13.3") testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:postgresql") + testImplementation("org.springframework.modulith:spring-modulith-starter-test") // gRPC and Protobuf implementation("io.grpc:grpc-netty-shaded:1.77.0") @@ -70,11 +74,13 @@ dependencies { implementation("com.google.protobuf:protobuf-java:4.33.1") implementation("jakarta.annotation:jakarta.annotation-api:3.0.0") implementation("org.springframework.grpc:spring-grpc-spring-boot-starter:1.0.0") + implementation(kotlin("stdlib-jdk8")) } dependencyManagement { imports { mavenBom("org.testcontainers:testcontainers-bom:${property("testcontainersVersion")}") + mavenBom("org.springframework.modulith:spring-modulith-bom:${property("springModulithVersion")}") } } diff --git a/src/main/java/org/nkcoder/UserApplication.java b/src/main/java/org/nkcoder/Application.java similarity index 51% rename from src/main/java/org/nkcoder/UserApplication.java rename to src/main/java/org/nkcoder/Application.java index 4060f63..d2ccc55 100644 --- a/src/main/java/org/nkcoder/UserApplication.java +++ b/src/main/java/org/nkcoder/Application.java @@ -3,12 +3,15 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.modulith.Modulith; +@Modulith( + systemName = "Application", + sharedModules = {"shared", "infrastructure"}) @SpringBootApplication @ConfigurationPropertiesScan -public class UserApplication { - - public static void main(String[] args) { - SpringApplication.run(UserApplication.class, args); +public class Application { + static void main(String[] args) { + SpringApplication.run(Application.class, args); } } diff --git a/src/main/java/org/nkcoder/infrastructure/package-info.java b/src/main/java/org/nkcoder/infrastructure/package-info.java new file mode 100644 index 0000000..151a541 --- /dev/null +++ b/src/main/java/org/nkcoder/infrastructure/package-info.java @@ -0,0 +1,18 @@ +/** + * Infrastructure module containing cross-cutting concerns. + * + *

This module provides: + * + *

+ * + *

This is a shared module - all other modules can access it. + */ +@ApplicationModule(type = ApplicationModule.Type.OPEN) +package org.nkcoder.infrastructure; + +import org.springframework.modulith.ApplicationModule; diff --git a/src/main/java/org/nkcoder/notification/NotificationService.java b/src/main/java/org/nkcoder/notification/NotificationService.java new file mode 100644 index 0000000..1201660 --- /dev/null +++ b/src/main/java/org/nkcoder/notification/NotificationService.java @@ -0,0 +1,16 @@ +package org.nkcoder.notification; + +import org.springframework.stereotype.Service; + +@Service +public class NotificationService { + public void sendWelcomeEmail(String email, String userName) { + // TODO: implement email sending + System.out.println("Sending Welcome email to " + email + ", for user: " + userName); + } + + public void sendPasswordResetEmail(String email, String userName) { + // TODO: implement password reset email + System.out.println("Sending password reset email to " + email + ", for user: " + userName); + } +} diff --git a/src/main/java/org/nkcoder/notification/application/UserEventListener.java b/src/main/java/org/nkcoder/notification/application/UserEventListener.java new file mode 100644 index 0000000..046f8e4 --- /dev/null +++ b/src/main/java/org/nkcoder/notification/application/UserEventListener.java @@ -0,0 +1,20 @@ +package org.nkcoder.notification.application; + +import org.nkcoder.notification.NotificationService; +import org.nkcoder.shared.kernel.domain.event.UserRegisteredEvent; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.stereotype.Component; + +@Component +public class UserEventListener { + private final NotificationService notificationService; + + public UserEventListener(NotificationService notificationService) { + this.notificationService = notificationService; + } + + @ApplicationModuleListener + public void onUserRegistered(UserRegisteredEvent event) { + notificationService.sendWelcomeEmail(event.email(), event.userName()); + } +} diff --git a/src/main/java/org/nkcoder/notification/package-info.java b/src/main/java/org/nkcoder/notification/package-info.java new file mode 100644 index 0000000..acd1cd2 --- /dev/null +++ b/src/main/java/org/nkcoder/notification/package-info.java @@ -0,0 +1,20 @@ +/** + * The Notification module handles email and SMS notifications. + * + *

This module provides: + * + *

+ * + *

Listens to events: + * + *

+ */ +@ApplicationModule(allowedDependencies = {"shared", "infrastructure"}) +package org.nkcoder.notification; + +import org.springframework.modulith.ApplicationModule; diff --git a/src/main/java/org/nkcoder/shared/kernel/domain/event/UserRegisteredEvent.java b/src/main/java/org/nkcoder/shared/kernel/domain/event/UserRegisteredEvent.java new file mode 100644 index 0000000..0b88cf5 --- /dev/null +++ b/src/main/java/org/nkcoder/shared/kernel/domain/event/UserRegisteredEvent.java @@ -0,0 +1,21 @@ +package org.nkcoder.shared.kernel.domain.event; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record UserRegisteredEvent(UUID userId, String email, String userName, LocalDateTime occurredOn) + implements DomainEvent { + public UserRegisteredEvent(UUID userId, String email, String userName) { + this(userId, email, userName, LocalDateTime.now()); + } + + @Override + public String eventType() { + return "user.registered"; + } + + @Override + public LocalDateTime occurredOn() { + return occurredOn; + } +} diff --git a/src/main/java/org/nkcoder/shared/package-info.java b/src/main/java/org/nkcoder/shared/package-info.java new file mode 100644 index 0000000..f6d6a5e --- /dev/null +++ b/src/main/java/org/nkcoder/shared/package-info.java @@ -0,0 +1,18 @@ +/** + * Shared kernel containing domain-driven design fundamentals. + * + *

This module provides: + * + *

+ * + *

This is a shared module - all other modules can access it. + */ +@ApplicationModule(type = ApplicationModule.Type.OPEN) +package org.nkcoder.shared; + +import org.springframework.modulith.ApplicationModule; diff --git a/src/main/java/org/nkcoder/infrastructure/resolver/CurrentUserArgumentResolver.java b/src/main/java/org/nkcoder/user/infrastructure/CurrentUserArgumentResolver.java similarity index 91% rename from src/main/java/org/nkcoder/infrastructure/resolver/CurrentUserArgumentResolver.java rename to src/main/java/org/nkcoder/user/infrastructure/CurrentUserArgumentResolver.java index 444a4e4..b6ef95d 100644 --- a/src/main/java/org/nkcoder/infrastructure/resolver/CurrentUserArgumentResolver.java +++ b/src/main/java/org/nkcoder/user/infrastructure/CurrentUserArgumentResolver.java @@ -1,9 +1,10 @@ -package org.nkcoder.infrastructure.resolver; +package org.nkcoder.user.infrastructure; import jakarta.servlet.http.HttpServletRequest; import java.util.UUID; import org.jetbrains.annotations.NotNull; import org.nkcoder.shared.local.annotation.CurrentUser; +import org.nkcoder.user.infrastructure.security.JwtAuthenticationFilter; import org.springframework.core.MethodParameter; import org.springframework.stereotype.Component; import org.springframework.web.bind.support.WebDataBinderFactory; @@ -13,7 +14,7 @@ /** * Resolves method parameters annotated with {@link CurrentUser} by extracting the user ID from request attributes set - * by {@link org.nkcoder.infrastructure.security.JwtAuthenticationFilter} + * by {@link JwtAuthenticationFilter} */ @Component public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { diff --git a/src/main/java/org/nkcoder/infrastructure/config/WebConfig.java b/src/main/java/org/nkcoder/user/infrastructure/WebConfig.java similarity index 85% rename from src/main/java/org/nkcoder/infrastructure/config/WebConfig.java rename to src/main/java/org/nkcoder/user/infrastructure/WebConfig.java index 02940e6..51aac54 100644 --- a/src/main/java/org/nkcoder/infrastructure/config/WebConfig.java +++ b/src/main/java/org/nkcoder/user/infrastructure/WebConfig.java @@ -1,7 +1,6 @@ -package org.nkcoder.infrastructure.config; +package org.nkcoder.user.infrastructure; import java.util.List; -import org.nkcoder.infrastructure.resolver.CurrentUserArgumentResolver; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; diff --git a/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationEntryPoint.java b/src/main/java/org/nkcoder/user/infrastructure/security/JwtAuthenticationEntryPoint.java similarity index 97% rename from src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationEntryPoint.java rename to src/main/java/org/nkcoder/user/infrastructure/security/JwtAuthenticationEntryPoint.java index d983a24..dac9715 100644 --- a/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationEntryPoint.java +++ b/src/main/java/org/nkcoder/user/infrastructure/security/JwtAuthenticationEntryPoint.java @@ -1,4 +1,4 @@ -package org.nkcoder.infrastructure.security; +package org.nkcoder.user.infrastructure.security; import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.http.HttpServletRequest; diff --git a/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationFilter.java b/src/main/java/org/nkcoder/user/infrastructure/security/JwtAuthenticationFilter.java similarity index 98% rename from src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationFilter.java rename to src/main/java/org/nkcoder/user/infrastructure/security/JwtAuthenticationFilter.java index 43692e6..f88afe5 100644 --- a/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationFilter.java +++ b/src/main/java/org/nkcoder/user/infrastructure/security/JwtAuthenticationFilter.java @@ -1,4 +1,4 @@ -package org.nkcoder.infrastructure.security; +package org.nkcoder.user.infrastructure.security; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; diff --git a/src/main/java/org/nkcoder/infrastructure/config/SecurityConfig.java b/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java similarity index 96% rename from src/main/java/org/nkcoder/infrastructure/config/SecurityConfig.java rename to src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java index 30922b0..a3e4796 100644 --- a/src/main/java/org/nkcoder/infrastructure/config/SecurityConfig.java +++ b/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java @@ -1,7 +1,6 @@ -package org.nkcoder.infrastructure.config; +package org.nkcoder.user.infrastructure.security; -import org.nkcoder.infrastructure.security.JwtAuthenticationEntryPoint; -import org.nkcoder.infrastructure.security.JwtAuthenticationFilter; +import org.nkcoder.infrastructure.config.CorsProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/org/nkcoder/user/package-info.java b/src/main/java/org/nkcoder/user/package-info.java new file mode 100644 index 0000000..d8c48b4 --- /dev/null +++ b/src/main/java/org/nkcoder/user/package-info.java @@ -0,0 +1,23 @@ +/** + * The User module handles user authentication and management. + * + *

This module provides: + * + *

+ * + *

Events published: + * + *

+ */ +@ApplicationModule(allowedDependencies = {"shared", "infrastructure"}) +package org.nkcoder.user; + +import org.springframework.modulith.ApplicationModule; diff --git a/src/main/resources/db/migration/V1.4__create_event_publication_table.sql b/src/main/resources/db/migration/V1.4__create_event_publication_table.sql new file mode 100644 index 0000000..1d0d01b --- /dev/null +++ b/src/main/resources/db/migration/V1.4__create_event_publication_table.sql @@ -0,0 +1,23 @@ +-- Spring Modulith Event Publication table +-- Used for reliable event delivery between modules +CREATE TABLE IF NOT EXISTS event_publication +( + id UUID NOT NULL, + listener_id TEXT NOT NULL, + event_type TEXT NOT NULL, + serialized_event TEXT NOT NULL, + publication_date TIMESTAMP WITH TIME ZONE NOT NULL, + completion_date TIMESTAMP WITH TIME ZONE, + status TEXT, + completion_attempts INT, + last_resubmission_date TIMESTAMP WITH TIME ZONE, + PRIMARY KEY (id) +); + +-- Index for hash-based lookup of serialized events +CREATE INDEX IF NOT EXISTS event_publication_serialized_event_hash_idx + ON event_publication USING hash (serialized_event); + +-- Index for finding publications by completion date +CREATE INDEX IF NOT EXISTS event_publication_by_completion_date_idx + ON event_publication (completion_date); \ No newline at end of file diff --git a/src/test/java/org/nkcoder/ModulithArchitectureTest.java b/src/test/java/org/nkcoder/ModulithArchitectureTest.java new file mode 100644 index 0000000..2dd55b8 --- /dev/null +++ b/src/test/java/org/nkcoder/ModulithArchitectureTest.java @@ -0,0 +1,25 @@ +package org.nkcoder; + +import org.junit.jupiter.api.Test; +import org.springframework.modulith.core.ApplicationModules; +import org.springframework.modulith.docs.Documenter; + +public class ModulithArchitectureTest { + ApplicationModules modules = ApplicationModules.of(Application.class); + + @Test + void verifyModuleStructure() { + // Verifies no cyclic dependencies and proper encapsulation + modules.verify(); + } + + @Test + void printModuleArrangement() { + modules.forEach(System.out::println); + } + + @Test + void generateDocumentation() { + new Documenter(modules).writeModulesAsPlantUml().writeIndividualModulesAsPlantUml(); + } +} diff --git a/src/test/java/org/nkcoder/integration/AdminUserControllerIntegrationTest.java b/src/test/java/org/nkcoder/user/integration/AdminUserControllerIntegrationTest.java similarity index 89% rename from src/test/java/org/nkcoder/integration/AdminUserControllerIntegrationTest.java rename to src/test/java/org/nkcoder/user/integration/AdminUserControllerIntegrationTest.java index fe831b8..f0d11b6 100644 --- a/src/test/java/org/nkcoder/integration/AdminUserControllerIntegrationTest.java +++ b/src/test/java/org/nkcoder/user/integration/AdminUserControllerIntegrationTest.java @@ -1,4 +1,4 @@ -package org.nkcoder.integration; +package org.nkcoder.user.integration; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; @@ -148,12 +148,12 @@ void updatesUserSuccessfully() { .header("Authorization", "Bearer " + adminToken) .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "name": "Admin Updated Name", - "emailVerified": true, - "role": "ADMIN" - } - """) + { + "name": "Admin Updated Name", + "emailVerified": true, + "role": "ADMIN" + } + """) .exchange() .expectStatus() .isOk() @@ -202,10 +202,10 @@ void resetsPasswordSuccessfully() { .header("Authorization", "Bearer " + adminToken) .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "newPassword": "NewPassword123" - } - """) + { + "newPassword": "NewPassword123" + } + """) .exchange() .expectStatus() .isOk(); @@ -216,11 +216,11 @@ void resetsPasswordSuccessfully() { .uri("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "target3@example.com", - "password": "NewPassword123" - } - """) + { + "email": "target3@example.com", + "password": "NewPassword123" + } + """) .exchange() .expectStatus() .isOk(); @@ -231,11 +231,11 @@ void resetsPasswordSuccessfully() { .uri("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "target3@example.com", - "password": "OldPassword123" - } - """) + { + "email": "target3@example.com", + "password": "OldPassword123" + } + """) .exchange() .expectStatus() .isUnauthorized(); @@ -250,13 +250,13 @@ private String registerAndGetToken(String email, String password, String name, S .uri("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "%s", - "password": "%s", - "name": "%s", - "role": "%s" - } - """.formatted(email, password, name, role)) + { + "email": "%s", + "password": "%s", + "name": "%s", + "role": "%s" + } + """.formatted(email, password, name, role)) .exchange() .expectStatus() .is2xxSuccessful() @@ -273,13 +273,13 @@ private String registerAndGetUserId(String email, String password, String name, .uri("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "%s", - "password": "%s", - "name": "%s", - "role": "%s" - } - """.formatted(email, password, name, role)) + { + "email": "%s", + "password": "%s", + "name": "%s", + "role": "%s" + } + """.formatted(email, password, name, role)) .exchange() .expectStatus() .is2xxSuccessful() diff --git a/src/test/java/org/nkcoder/integration/AuthControllerIntegrationTest.java b/src/test/java/org/nkcoder/user/integration/AuthControllerIntegrationTest.java similarity index 99% rename from src/test/java/org/nkcoder/integration/AuthControllerIntegrationTest.java rename to src/test/java/org/nkcoder/user/integration/AuthControllerIntegrationTest.java index 768cda1..3d9d6a1 100644 --- a/src/test/java/org/nkcoder/integration/AuthControllerIntegrationTest.java +++ b/src/test/java/org/nkcoder/user/integration/AuthControllerIntegrationTest.java @@ -1,4 +1,4 @@ -package org.nkcoder.integration; +package org.nkcoder.user.integration; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; diff --git a/src/test/java/org/nkcoder/integration/AuthFlowIntegrationTest.java b/src/test/java/org/nkcoder/user/integration/AuthFlowIntegrationTest.java similarity index 79% rename from src/test/java/org/nkcoder/integration/AuthFlowIntegrationTest.java rename to src/test/java/org/nkcoder/user/integration/AuthFlowIntegrationTest.java index 8255dde..70638b9 100644 --- a/src/test/java/org/nkcoder/integration/AuthFlowIntegrationTest.java +++ b/src/test/java/org/nkcoder/user/integration/AuthFlowIntegrationTest.java @@ -1,4 +1,4 @@ -package org.nkcoder.integration; +package org.nkcoder.user.integration; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -36,13 +36,13 @@ void fullAuthenticationFlow() { .uri("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "flow@example.com", - "password": "Password123", - "name": "Flow Test User", - "role": "MEMBER" - } - """) + { + "email": "flow@example.com", + "password": "Password123", + "name": "Flow Test User", + "role": "MEMBER" + } + """) .exchange() .expectStatus() .is2xxSuccessful() @@ -79,10 +79,10 @@ void fullAuthenticationFlow() { .uri("/api/auth/refresh") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "refreshToken": "%s" - } - """.formatted(refreshToken)) + { + "refreshToken": "%s" + } + """.formatted(refreshToken)) .exchange() .expectStatus() .isOk() @@ -103,10 +103,10 @@ void fullAuthenticationFlow() { .uri("/api/auth/refresh") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "refreshToken": "%s" - } - """.formatted(refreshToken)) + { + "refreshToken": "%s" + } + """.formatted(refreshToken)) .exchange() .expectStatus() .isUnauthorized(); @@ -127,10 +127,10 @@ void fullAuthenticationFlow() { .header("Authorization", "Bearer " + newAccessToken) .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "refreshToken": "%s" - } - """.formatted(newRefreshToken)) + { + "refreshToken": "%s" + } + """.formatted(newRefreshToken)) .exchange() .expectStatus() .isOk(); @@ -141,10 +141,10 @@ void fullAuthenticationFlow() { .uri("/api/auth/refresh") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "refreshToken": "%s" - } - """.formatted(newRefreshToken)) + { + "refreshToken": "%s" + } + """.formatted(newRefreshToken)) .exchange() .expectStatus() .isUnauthorized(); @@ -163,13 +163,13 @@ void registersNewUser() { .uri("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "newuser@example.com", - "password": "Password123", - "name": "New User", - "role": "MEMBER" - } - """) + { + "email": "newuser@example.com", + "password": "Password123", + "name": "New User", + "role": "MEMBER" + } + """) .exchange() .expectStatus() .is2xxSuccessful() @@ -191,13 +191,13 @@ void rejectsDuplicateEmail() { .uri("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "duplicate@example.com", - "password": "Password123", - "name": "First User", - "role": "MEMBER" - } - """) + { + "email": "duplicate@example.com", + "password": "Password123", + "name": "First User", + "role": "MEMBER" + } + """) .exchange() .expectStatus() .is2xxSuccessful(); @@ -208,13 +208,13 @@ void rejectsDuplicateEmail() { .uri("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "duplicate@example.com", - "password": "Password123", - "name": "Second User", - "role": "MEMBER" - } - """) + { + "email": "duplicate@example.com", + "password": "Password123", + "name": "Second User", + "role": "MEMBER" + } + """) .exchange() .expectStatus() .isBadRequest() @@ -231,13 +231,13 @@ void normalizesEmail() { .uri("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "UPPERCASE@EXAMPLE.COM", - "password": "Password123", - "name": "Uppercase Email", - "role": "MEMBER" - } - """) + { + "email": "UPPERCASE@EXAMPLE.COM", + "password": "Password123", + "name": "Uppercase Email", + "role": "MEMBER" + } + """) .exchange() .expectStatus() .is2xxSuccessful() @@ -263,11 +263,11 @@ void logsInSuccessfully() { .uri("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "login@example.com", - "password": "Password1234" - } - """) + { + "email": "login@example.com", + "password": "Password1234" + } + """) .exchange() .expectStatus() .isOk() @@ -288,11 +288,11 @@ void rejectsWrongPassword() { .uri("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "wrongpass@example.com", - "password": "WrongPassword123" - } - """) + { + "email": "wrongpass@example.com", + "password": "WrongPassword123" + } + """) .exchange() .expectStatus() .isUnauthorized() @@ -309,11 +309,11 @@ void rejectsNonExistentEmail() { .uri("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "nonexistent@example.com", - "password": "Password123" - } - """) + { + "email": "nonexistent@example.com", + "password": "Password123" + } + """) .exchange() .expectStatus() .isUnauthorized() @@ -338,10 +338,10 @@ void updatesProfile() { .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "name": "Updated Name" - } - """) + { + "name": "Updated Name" + } + """) .exchange() .expectStatus() .isOk() @@ -362,12 +362,12 @@ void changesPassword() { .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "currentPassword": "OldPassword123", - "newPassword": "NewPassword123", - "confirmPassword": "NewPassword123" - } - """) + { + "currentPassword": "OldPassword123", + "newPassword": "NewPassword123", + "confirmPassword": "NewPassword123" + } + """) .exchange() .expectStatus() .isOk(); @@ -378,11 +378,11 @@ void changesPassword() { .uri("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "password@example.com", - "password": "NewPassword123" - } - """) + { + "email": "password@example.com", + "password": "NewPassword123" + } + """) .exchange() .expectStatus() .isOk(); @@ -393,11 +393,11 @@ void changesPassword() { .uri("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "password@example.com", - "password": "OldPassword123" - } - """) + { + "email": "password@example.com", + "password": "OldPassword123" + } + """) .exchange() .expectStatus() .isUnauthorized(); @@ -435,13 +435,13 @@ private void registerUser(String email, String password, String name) { .uri("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "%s", - "password": "%s", - "name": "%s", - "role": "MEMBER" - } - """.formatted(email, password, name)) + { + "email": "%s", + "password": "%s", + "name": "%s", + "role": "MEMBER" + } + """.formatted(email, password, name)) .exchange() .expectStatus() .is2xxSuccessful(); @@ -453,13 +453,13 @@ private String registerAndGetToken(String email, String password, String name) { .uri("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" - { - "email": "%s", - "password": "%s", - "name": "%s", - "role": "MEMBER" - } - """.formatted(email, password, name)) + { + "email": "%s", + "password": "%s", + "name": "%s", + "role": "MEMBER" + } + """.formatted(email, password, name)) .exchange() .expectStatus() .is2xxSuccessful() From 2efd68798b8d0126b09e2123c1973e1dba912184 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 13 Dec 2025 16:26:57 +1100 Subject: [PATCH 2/2] Update documentation --- CLAUDE.md | 243 ++++++++++++++++++++++++++++++++++++++++++++---------- README.md | 106 +++++++++++++++--------- 2 files changed, 268 insertions(+), 81 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c5206ab..1592590 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is a Java 21 Spring Boot 3.5.3 microservice implementing user authentication and management with both REST and gRPC APIs. The service features JWT authentication with refresh token rotation, token families for multi-device support, role-based access control (ADMIN/MEMBER), and PostgreSQL persistence with Flyway migrations. +This is a **Java 25 Spring Boot 4.0** modular monolith implementing user authentication and management with both REST and gRPC APIs. The application uses **Spring Modulith** for module boundaries and event-driven communication between modules. The service features JWT authentication with refresh token rotation, token families for multi-device support, role-based access control (ADMIN/MEMBER), and PostgreSQL persistence with Flyway migrations. + +### Key Technologies +- **Java 25** with virtual threads (Project Loom) +- **Spring Boot 4.0** with Spring Framework 7.0 +- **Spring Modulith 2.0** for modular architecture +- **PostgreSQL** with Flyway migrations +- **gRPC** alongside REST APIs ## Build & Development Commands @@ -40,11 +47,14 @@ docker compose up -d # Run all tests ./gradlew test +# Run module architecture verification +./gradlew test --tests "org.nkcoder.ModulithArchitectureTest" + # Run specific test class -./gradlew test --tests "org.nkcoder.controller.AuthControllerTest" +./gradlew test --tests "org.nkcoder.user.application.service.AuthApplicationServiceTest" -# Run specific test method -./gradlew test --tests "org.nkcoder.controller.AuthControllerTest.shouldRegisterUser" +# Run integration tests +./gradlew test --tests "org.nkcoder.user.integration.*" # Test with coverage ./gradlew test jacocoTestReport @@ -76,28 +86,100 @@ docker compose up -d ## Architecture -### Layered Structure +### Spring Modulith Structure + +The application is organized as a **modular monolith** using Spring Modulith. Each module has clear boundaries and communicates via events. + +``` +org.nkcoder/ +├── Application.java ← Bootstrap (@Modulith entry point) +│ +├── user/ ← User Module (authentication & management) +│ ├── package-info.java ← @ApplicationModule(allowedDependencies = {"shared", "infrastructure"}) +│ ├── interfaces/rest/ ← REST controllers (public API) +│ ├── application/service/ ← Application services (use case orchestration) +│ ├── domain/ ← Domain models, services, repositories (ports) +│ └── infrastructure/ ← Adapters (JPA, Security, JWT) +│ +├── notification/ ← Notification Module (email/SMS) +│ ├── package-info.java ← @ApplicationModule(allowedDependencies = {"shared", "infrastructure"}) +│ ├── NotificationService.java ← Public API +│ └── application/ ← Event listeners +│ +├── shared/ ← Shared Kernel (OPEN module) +│ ├── package-info.java ← @ApplicationModule(type = OPEN) +│ ├── kernel/domain/event/ ← Domain events (UserRegisteredEvent, etc.) +│ ├── kernel/exception/ ← Common exceptions +│ └── local/rest/ ← ApiResponse, GlobalExceptionHandler +│ +└── infrastructure/ ← Infrastructure Module (OPEN module) + ├── package-info.java ← @ApplicationModule(type = OPEN) + └── config/ ← CORS, OpenAPI, JPA auditing configs +``` + +### Module Dependency Rules -The codebase follows a strict layered architecture: +``` +notification ──→ shared ←── user + ↑ + infrastructure +``` + +- **user** depends on: `shared`, `infrastructure` +- **notification** depends on: `shared`, `infrastructure` +- **shared** and **infrastructure** are OPEN modules (accessible by all) +- Modules communicate via **domain events** (not direct calls) + +### Hexagonal Architecture (within User Module) ``` -Controller → Service → Repository → Entity - ↓ ↓ - DTO Mapper +┌─────────────────────────────────────────────────────────────┐ +│ User Module │ +├─────────────────────────────────────────────────────────────┤ +│ interfaces/rest/ ← Driving Adapters (REST API) │ +│ ├── AuthController │ +│ ├── UserController │ +│ └── AdminUserController │ +├─────────────────────────────────────────────────────────────┤ +│ application/service/ ← Use Case Orchestration │ +│ ├── AuthApplicationService │ +│ └── UserApplicationService │ +├─────────────────────────────────────────────────────────────┤ +│ domain/ ← Core Business Logic │ +│ ├── model/ (User, RefreshToken, Value Objects)│ +│ ├── service/ (AuthenticationService, TokenGenerator - ports)│ +│ └── repository/ (UserRepository, RefreshTokenRepository - ports)│ +├─────────────────────────────────────────────────────────────┤ +│ infrastructure/ ← Driven Adapters │ +│ ├── persistence/ (JPA entities, repository adapters)│ +│ └── security/ (JWT filter, SecurityConfig, BCrypt)│ +└─────────────────────────────────────────────────────────────┘ ``` -**Key Directories** (under `src/main/java/org/nkcoder/`): -- `controller/` - REST endpoints (AuthController, UserController, HealthController) -- `service/` - Business logic (AuthService, UserService) -- `repository/` - JPA repositories (UserRepository, RefreshTokenRepository) -- `entity/` - JPA entities (User, RefreshToken) -- `dto/` - Data Transfer Objects organized by feature (auth/, user/, common/) -- `mapper/` - Entity-to-DTO mappers (UserMapper) -- `security/` - Security components (JwtAuthenticationFilter, JwtAuthenticationEntryPoint) -- `config/` - Configuration classes (SecurityConfig, JwtProperties, OpenApiConfig, etc.) -- `util/` - Utilities (JwtUtil) -- `grpc/` - gRPC service implementations (AuthGrpcService, GrpcMapper) -- `exception/` - Custom exceptions (AuthenticationException, ValidationException, ResourceNotFoundException) +### Key Components by Module + +**User Module** (`org.nkcoder.user`): +- `interfaces/rest/` - AuthController, UserController, AdminUserController +- `application/service/` - AuthApplicationService, UserApplicationService +- `domain/model/` - User (aggregate), RefreshToken, value objects (Email, UserId, UserRole, etc.) +- `domain/service/` - TokenGenerator, PasswordEncoder, AuthenticationService (ports) +- `domain/repository/` - UserRepository, RefreshTokenRepository (ports) +- `infrastructure/persistence/` - JPA entities, repository adapters +- `infrastructure/security/` - JwtAuthenticationFilter, SecurityConfig, JwtTokenGeneratorAdapter + +**Notification Module** (`org.nkcoder.notification`): +- `NotificationService` - Public API for sending notifications +- `application/UserEventListener` - Listens to UserRegisteredEvent + +**Shared Module** (`org.nkcoder.shared`): +- `kernel/domain/event/` - DomainEvent, UserRegisteredEvent, UserProfileUpdatedEvent +- `kernel/domain/valueobject/` - AggregateRoot base class +- `kernel/exception/` - AuthenticationException, ValidationException, ResourceNotFoundException +- `local/rest/` - ApiResponse, GlobalExceptionHandler +- `local/event/` - SpringDomainEventPublisher + +**Infrastructure Module** (`org.nkcoder.infrastructure`): +- `config/` - CorsProperties, OpenApiConfig, JpaAuditingConfig, WebConfig ### Security & Authentication @@ -120,10 +202,10 @@ Controller → Service → Repository → Entity - `POST /api/users/auth/logout-single` - Deletes only current refresh token (single device) **JWT Authentication Flow**: -1. JwtAuthenticationFilter extracts token from `Authorization: Bearer {token}` header -2. Token validated and claims extracted via JwtUtil -3. UsernamePasswordAuthenticationToken created with role authorities (ROLE_MEMBER/ROLE_ADMIN) -4. Context stored in SecurityContextHolder +1. `JwtAuthenticationFilter` (in `user.infrastructure.security`) extracts token from `Authorization: Bearer {token}` header +2. Token validated via `TokenGenerator` port (implemented by `JwtTokenGeneratorAdapter`) +3. `UsernamePasswordAuthenticationToken` created with role authorities (ROLE_MEMBER/ROLE_ADMIN) +4. Context stored in `SecurityContextHolder` 5. Request attributes set: userId, email, role (accessible in controllers) **Security Configuration**: @@ -206,13 +288,36 @@ PATCH /api/users/{userId}/password - Reset password (admin only) - `ValidationException` - Business validation failures (duplicate email, password mismatch) - `ResourceNotFoundException` - Entity not found by ID +### Event-Driven Communication + +Modules communicate via domain events using Spring Modulith's event infrastructure: + +**Publishing Events** (in User module): +```java +// In AuthApplicationService after registration +domainEventPublisher.publish(new UserRegisteredEvent(user.getId(), user.getEmail(), user.getName())); +``` + +**Listening to Events** (in Notification module): +```java +@Component +public class UserEventListener { + @ApplicationModuleListener + public void onUserRegistered(UserRegisteredEvent event) { + notificationService.sendWelcomeEmail(event.email(), event.userName()); + } +} +``` + +**Event Publication Table**: Spring Modulith persists events to `event_publication` table for reliable delivery (transactional outbox pattern). + ### Configuration Management **Profiles**: - `local` - Local development with Docker Compose -- `docker` - Docker container environment +- `dev` - Development environment with external database - `prod` - Production with environment variables -- `test` - Test profile with in-memory/test database +- `test` - Test profile with TestContainers **Environment Variables** (required for production): ```bash @@ -237,10 +342,11 @@ CLIENT_URL=http://localhost:3000 - `V1.1__create_tables.sql` - Initial schema (users, refresh_tokens tables) - `V1.2__seeding_users.sql` - Seed data (admin@timor.com, demo@timor.com) - `V1.3__update_users_role.sql` - Schema updates +- `V1.4__create_event_publication_table.sql` - Spring Modulith event publication table **Migration Best Practices**: - Never modify existing migrations (create new ones) -- Use sequential versioning: V1.1, V1.2, V1.3, etc. +- Use sequential versioning: V1.1, V1.2, V1.3, etc. (uppercase V required!) - Validate migrations with `validate-on-migrate: true` - Baseline existing databases with `baseline-on-migrate: true` @@ -268,15 +374,28 @@ CLIENT_URL=http://localhost:3000 **Test Types**: - Unit tests: `@WebMvcTest` for controllers, `@MockBean` for services - Integration tests: `@SpringBootTest` with TestContainers for PostgreSQL +- Module tests: `ModulithArchitectureTest` verifies module boundaries - Security tests: Use `@WithMockUser` or custom security setup -**Base Test Classes**: -- `BaseControllerTest` - Base for controller unit tests (MockMvc setup) -- `BaseSecurityControllerTest` - Base with security context -- `IntegrationTestingBase` - Base for integration tests (TestContainers) +**Module Verification Test**: +```java +class ModulithArchitectureTest { + ApplicationModules modules = ApplicationModules.of(Application.class); + + @Test + void verifyModuleStructure() { + modules.verify(); // Fails on illegal cross-module dependencies + } +} +``` + +**Integration Test Setup**: +- Tests in `org.nkcoder.user.integration/` package +- Use `@SpringBootTest(classes = Application.class)` to specify bootstrap class +- WebTestClient for REST API testing (Spring Boot 4 compatible) **Test Configuration**: -- `TestConfig.java` provides test-specific beans +- `TestContainersConfiguration.java` provides PostgreSQL container - `application-test.yml` configures test profile - TestContainers automatically manages PostgreSQL instance @@ -301,9 +420,10 @@ CLIENT_URL=http://localhost:3000 - Packages: lowercase (e.g., org.nkcoder.service) **Package Organization**: -- DTOs grouped by feature: `dto/auth/`, `dto/user/`, `dto/common/` -- One entity per file -- One controller per resource domain (Auth, User) +- Modules are direct sub-packages of `org.nkcoder` +- Each module follows hexagonal architecture: `interfaces/`, `application/`, `domain/`, `infrastructure/` +- Domain events shared across modules go in `shared.kernel.domain.event/` +- One controller per resource domain (Auth, User, AdminUser) **Dependency Injection**: - Prefer constructor injection over field injection @@ -350,17 +470,31 @@ CLIENT_URL=http://localhost:3000 ## Common Development Tasks -**Adding a New Endpoint**: -1. Create request/response DTOs in appropriate `dto/` subdirectory -2. Add method to service layer with business logic -3. Add controller method with @GetMapping/@PostMapping/@PatchMapping -4. Add validation annotations to request DTO -5. Add test cases in controller test class -6. Run `./gradlew test` to verify +**Adding a New Endpoint** (in User module): +1. Create request DTO in `user/interfaces/rest/request/` +2. Create command DTO in `user/application/dto/command/` +3. Add mapper method in `user/interfaces/rest/mapper/` +4. Add method to application service (`user/application/service/`) +5. Add controller method in `user/interfaces/rest/` +6. Add test cases +7. Run `./gradlew test` to verify + +**Adding a New Module**: +1. Create package `org.nkcoder.{modulename}/` +2. Create `package-info.java` with `@ApplicationModule(allowedDependencies = {"shared", "infrastructure"})` +3. Create module structure: `interfaces/`, `application/`, `domain/`, `infrastructure/` +4. Add event listeners if needed to react to events from other modules +5. Run `ModulithArchitectureTest` to verify module boundaries + +**Publishing Domain Events**: +1. Create event record in `shared/kernel/domain/event/` (if cross-module) or `{module}/domain/event/` (if module-internal) +2. Inject `DomainEventPublisher` in your service +3. Call `domainEventPublisher.publish(event)` after business logic +4. Create `@ApplicationModuleListener` in consuming module **Database Schema Change**: 1. Create new migration file: `V{next_version}__{description}.sql` in `src/main/resources/db/migration/` -2. Update entity class if needed +2. Update JPA entity in `{module}/infrastructure/persistence/entity/` if needed 3. Run `./gradlew bootRun` to apply migration 4. Verify with database client or integration test @@ -375,3 +509,24 @@ CLIENT_URL=http://localhost:3000 **Role Assignment**: New users default to MEMBER role; ADMIN role must be explicitly assigned or seeded +## Spring Modulith Guidelines + +**Module Boundaries**: +- Never import internal classes from other modules (only public API) +- Use domain events for cross-module communication +- Shared code goes in `shared` module (marked as OPEN) +- Run `./gradlew test` regularly - `ModulithArchitectureTest` catches violations + +**Event Best Practices**: +- Events are immutable records +- Events should be past-tense (`UserRegistered`, not `RegisterUser`) +- Cross-module events go in `shared.kernel.domain.event/` +- Use `@ApplicationModuleListener` for reliable event handling (auto-retry, persistence) + +**Future Microservice Extraction**: +When ready to extract a module as a microservice: +1. Events become messages (Kafka/RabbitMQ) +2. REST/gRPC calls replace direct method calls +3. Module's `infrastructure/` adapters change, domain stays the same +4. Database can be separated per module + diff --git a/README.md b/README.md index ac8714f..71ff7ab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # User Service - Java/Spring Boot -A comprehensive user authentication and management service built with Java 21 and Spring Boot 3. +A comprehensive user authentication and management service built with **Java 25** and **Spring Boot 4**, architected as a **modular monolith** using **Spring Modulith**. ## Features @@ -27,21 +27,23 @@ A comprehensive user authentication and management service built with Java 21 an ## Tech Stack -- **Java 25** (Latest LTS) -- **Spring Boot 4** -- **Spring Security 6** +- **Java 25** with Virtual Threads (Project Loom) +- **Spring Boot 4.0** with Spring Framework 7 +- **Spring Modulith 2.0** for modular architecture +- **Spring Security 7** - **Spring Data JPA** - **PostgreSQL 17** -- **JWT (JJWT)** +- **gRPC** alongside REST APIs +- **JWT (JJWT 0.13)** - **Gradle 8+** - **Docker & Docker Compose** -- **Swagger/OpenAPI 3** +- **Swagger/OpenAPI 3** (springdoc-openapi) ## Quick Start ### Prerequisites -- Java 21 +- Java 25 - Gradle 8+ - PostgreSQL 17+ - Docker & Docker Compose (optional) @@ -223,8 +225,14 @@ curl -X POST http://localhost:3001/api/users/auth/refresh \ ### Code Quality ```bash -# Check code style -./gradlew checkstyleMain checkstyleTest +# Apply code formatting (Palantir Java Format) +./gradlew spotlessApply + +# Check code formatting +./gradlew spotlessCheck + +# Verify module architecture +./gradlew test --tests "org.nkcoder.ModulithArchitectureTest" # Generate dependency report ./gradlew dependencyInsight --dependency @@ -232,34 +240,55 @@ curl -X POST http://localhost:3001/api/users/auth/refresh \ ## Project Structure +The application uses **Spring Modulith** for a modular monolith architecture: + +``` +src/main/java/org/nkcoder/ +├── Application.java # Bootstrap (@Modulith entry point) +│ +├── user/ # User Module +│ ├── package-info.java # @ApplicationModule definition +│ ├── interfaces/rest/ # REST controllers +│ │ ├── AuthController.java +│ │ ├── UserController.java +│ │ └── AdminUserController.java +│ ├── application/service/ # Application services +│ │ ├── AuthApplicationService.java +│ │ └── UserApplicationService.java +│ ├── domain/ # Domain layer +│ │ ├── model/ # Aggregates, entities, value objects +│ │ ├── service/ # Domain services (ports) +│ │ └── repository/ # Repository interfaces (ports) +│ └── infrastructure/ # Infrastructure layer +│ ├── persistence/ # JPA entities & repository adapters +│ └── security/ # JWT filter, SecurityConfig +│ +├── notification/ # Notification Module +│ ├── package-info.java +│ ├── NotificationService.java # Public API +│ └── application/ # Event listeners +│ +├── shared/ # Shared Kernel (OPEN module) +│ ├── kernel/ +│ │ ├── domain/event/ # Domain events +│ │ └── exception/ # Common exceptions +│ └── local/rest/ # ApiResponse, GlobalExceptionHandler +│ +└── infrastructure/ # Infrastructure Module (OPEN module) + └── config/ # CORS, OpenAPI, JPA auditing +``` + +### Module Dependencies + ``` -user-service/ -├── src/ -│ ├── main/ -│ │ ├── java/com/timor/user/ -│ │ │ ├── config/ # Configuration classes -│ │ │ ├── controller/ # REST controllers -│ │ │ ├── dto/ # Data Transfer Objects -│ │ │ ├── entity/ # JPA entities -│ │ │ ├── enums/ # Enums -│ │ │ ├── exception/ # Custom exceptions -│ │ │ ├── mapper/ # Entity-DTO mappers -│ │ │ ├── repository/ # Data repositories -│ │ │ ├── security/ # Security components -│ │ │ ├── service/ # Business logic -│ │ │ └── util/ # Utility classes -│ │ └── resources/ -│ │ ├── application.yml -│ │ └── application-docker.yml -│ └── test/ # Test files -├── scripts/ -│ └── init.sql # Database initialization -├── docker-compose.yml -├── Dockerfile -├── build.gradle -└── README.md +notification ──→ shared ←── user + ↑ + infrastructure ``` +- Modules communicate via **domain events** (not direct calls) +- `shared` and `infrastructure` are OPEN modules (accessible by all) + ## Security Features - **JWT Authentication**: Stateless authentication with HS512 algorithm @@ -281,9 +310,10 @@ user-service/ ### Application Profiles -- `default`: Local development -- `docker`: Docker container environment -- `test`: Testing environment +- `local`: Local development with Docker Compose for PostgreSQL +- `dev`: Development environment with external database +- `prod`: Production environment +- `test`: Testing with TestContainers ### Key Configuration Properties @@ -307,6 +337,8 @@ spring: ## References +- [Spring Modulith Documentation](https://docs.spring.io/spring-modulith/reference/) +- [Spring Boot 4.0 Migration Guide](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide) - [Implementing Domain Driven Design with Spring](https://github.com/maciejwalkowiak/implementing-ddd-with-spring-talk) ## License