Repository navigation
feat(core-architecture): Maven reactor, common module and ws skeleton - #1
Merged
Merged
Conversation
Every module now inherits JUnit 5, Mockito, AssertJ and Spring Test with test scope from the parent, so a module pom only lists what is specific to it. The parent also manages the common test-jar, letting the DAO tests of each business module reuse the shared PostgreSQL Testcontainers helper instead of copying it. Testcontainers and the PostgreSQL driver stay out of the shared list on purpose: only the modules that really run DAO tests declare them.
The core aggregator now lists every business module of the README. Each one starts as an empty jar that depends only on common; order additionally depends on catalog and customer, which is the single cross-module edge of the design (through their service interfaces once they exist). Declaring them now lets ws depend on the whole core from the start and keeps each later branch limited to adding classes, not restructuring the reactor.
common carries what every business module needs: spring-jdbc for JdbcTemplate and transactions, spring-context for @configuration, HikariCP for pooling, the PostgreSQL driver, Flyway with its PostgreSQL module, and the jakarta.persistence annotations used only as documentation on entities. It also publishes its test classes as a test-jar (maven-jar-plugin test-jar goal), so the Testcontainers PostgreSQL holder written once here can be imported by the DAO tests of the other modules.
ws is the web layer: it depends on every core module, on spring-webmvc and on Jackson with the JSR-310 module for Instant. The Servlet API is provided scope because Tomcat 10.1 supplies Servlet 6.0 at runtime, and Bean Validation runs standalone on Hibernate Validator with its Expressly EL. The WAR plugin is told there is no web.xml, since a WebApplicationInitializer replaces it, and the artifact is named ws so the package is ws/target/ws.war as the README states.
These two unchecked exceptions are the shared vocabulary between services and the web layer. A service throws ResourceNotFoundException when an id matches nothing and BusinessRuleException when a domain rule refuses an operation; neither knows anything about HTTP. Keeping them in common lets every core module raise them without depending on ws, while GlobalExceptionHandler translates them to 404 and 422 later.
The migration creates every table of the README data model: the identity and RBAC domain (users, roles, permissions, join tables, activation, blacklist and reset tokens, audit logs) and the e-commerce domain (categories, products, customers, orders). Flyway is the only thing that creates or alters tables, no framework generates the schema. Constraints carry the invariants the database can enforce: a unique index on the lower-cased email, tokens stored as 64 character digests, restrictive foreign keys where a service must refuse the delete first, and no foreign key between the two domains.
JdbcTemplate is Spring JDBC's core class: it borrows a connection, runs the SQL, binds the parameters, and translates SQLException into Spring's unchecked DataAccessException hierarchy. A RowMapper turns one ResultSet row into one object, which together replace what an ORM would hide. AbstractDao holds the shared template and three helpers every DAO reuses: findOne (first row or empty, never EmptyResultDataAccessException), insertReturningId for PostgreSQL's INSERT ... RETURNING id, and count. SQL itself stays in the concrete DAO classes.
DataSourceConfig is the only place these infrastructure beans exist. A @configuration class declares beans explicitly with @bean instead of relying on scanning or auto-configuration. The HikariCP pool reads its settings with getRequiredProperty, so a missing url, user or password fails the startup with a clear message and no default secret exists. Flyway migrates as the bean is created and JdbcTemplate and the DataSourceTransactionManager depend on it, so migrations always run before any DAO. @EnableTransactionManagement makes @transactional service beans proxied. The PostgreSQL driver class is named explicitly because inside a WAR DriverManager cannot see a driver held in WEB-INF/lib.
A tiny vertical slice gives the packaged WAR a public route to probe: a DAO runs SELECT 1, a service turns the outcome into a HealthDto (DOWN when a DataAccessException is raised) and CommonConfig registers both as explicit @bean methods typed by their interfaces, importing DataSourceConfig. It shows the layering without an entity: the DAO owns SQL, the service owns the decision, and only a dto leaves the module. No stereotype annotation is used on either implementation.
ApiResponse<T> wraps every answer, success or error, in the same envelope (success, message, data, timestamp) built through two static factories. PageResponse<T> carries a page of results with its totals and refuses a size below 1, so a bad size can never produce a fake total. They are named payload, not dto, on purpose: core dto classes are the business shape, payload classes are the HTTP shape. They are plain classes with explicit accessors, no Lombok and no record.
…dler @RestControllerAdvice makes these @ExceptionHandler methods apply to every controller, so exceptions become HTTP in exactly one place. Domain exceptions give 404 and 422, Bean Validation failures give 400 with one "field: message" entry per error, malformed JSON, bad parameters, unknown URLs, wrong methods and media types get their own statuses. The catch-all returns a generic 500 and only logs the real error, so no internal detail leaks. A marked spot is left for the 401 and 403 handlers that the auth branch adds with Spring Security.
HealthController exposes GET /api/v1/health. It sees only the HealthService interface and a payload type: a converter maps the core HealthDto to the HealthResponse, keeping core dto and web payload distinct. The route lets a packaged WAR prove that it starts, reaches PostgreSQL and answers in the ApiResponse envelope.
@EnableWebMvc turns on Spring MVC in a plain Spring application, without Boot, and WebMvcConfigurer customises it: one Jackson converter that writes Instants as ISO-8601 text and a Hibernate Validator backed validator. The class imports the core configuration, and every later branch adds its module config to the same import. The component scan is limited to the controller and exception packages: those are the only stereotypes of the project, DAOs and services are never scanned.
Tomcat discovers WebApplicationInitializer implementations through Spring's servlet container initializer, so servlets and filters are registered in Java. The initializer builds one annotation-based context, exposes it as the root context through ContextLoaderListener and hands the same context to the DispatcherServlet mapped on "/". Sharing one context keeps every bean defined once and lets the security filter added later, which lives outside the DispatcherServlet, find it in the root context. A UTF-8 character encoding filter is registered as well.
A singleton Testcontainers PostgreSQL is started once per JVM and its connection settings are injected into the Spring test context with @DynamicPropertySource. The holder is published in the common test-jar so other modules reuse it. FlywayMigrationTest checks that all thirteen tables exist after startup, that the unique email index is case-insensitive and that the restrictive foreign key blocks deleting a referenced category.
The context is built from an empty MockEnvironment, with no database, and must fail with a clear message when the url or the password is missing. The test also asserts that no default password leaks into the message. It documents the rule that a wrong deployment fails at startup instead of silently using a hidden default.
Each DAO test class loads its own dedicated SQL fixture through @SQL before every test: abstract-dao-dataset.sql for the AbstractDao helpers (findOne present and empty, increasing generated ids, count) and health-dao-dataset.sql for the health DAO. No dataset is shared between classes. The fixture of the AbstractDao test restarts the sequence explicitly, because rows inserted with explicit ids do not advance it.
Mockito replaces the DAO, so no database is involved: the service must report UP when the DAO answers true, and DOWN both when it answers false and when it throws a DataAccessException. This is the service layer of the strategy: business decisions verified in isolation, with assertions on the dto that leaves the module.
The tests check the success and error factories, the ceiling computation of totalPages, the refusal of a size of zero or below, and that a page beyond the last one keeps an empty content list. They are plain unit tests without Spring: payload classes hold no logic beyond construction.
…ckMvc MockMvc drives a WebApplicationContext built from a small test config, a test-only controller that throws each case and the real GlobalExceptionHandler. Every error is asserted in the ApiResponse shape: 404, 422, 400 with per-field messages, malformed JSON, constrained request parameters, unknown URL, 405 with its Allow header, 415 and a 500 that does not leak the real message. The health controller is tested with the service mocked. These classes live in ws.mvc because the component scan of WebMvcConfig would otherwise pick up test controllers.
The integration test loads the real WebMvcConfig, hence CommonConfig, DataSourceConfig and Flyway, on a Testcontainers database and checks that the health route answers 200 with status UP and that an unknown URL gives a 404 in the ApiResponse envelope. WebAppInitializerTest uses a mocked ServletContext to verify the registrations: the dispatcher servlet on "/", the ContextLoaderListener and the UTF-8 filter.
The first stage builds the reactor with Maven, copying the poms first so the dependency download is cached until a pom changes. The second stage is a Tomcat 10.1 JRE 17 image that keeps only the WAR, deployed as spring-jdbc-tutorial.war so the context path matches the URLs of the README. The image runs as an unprivileged user and the build context excludes everything but the sources. Tests are skipped in the image build: the DAO tests need a Docker daemon and run in CI instead.
The compose file runs the packaged application beside a PostgreSQL 16 container with a health check. The database password is mandatory and has no default, the published port is configurable through APP_PORT because 8080 is often taken, and the datasource is passed as environment variables that Spring maps to app.datasource.* properties. It is meant for the packaged WAR only: the tests start their own database through Testcontainers. .env.example lists the variables to copy into .env.
The workflow checks out the code, sets up Temurin Java 17 with the Maven cache and runs ./mvnw -B verify on pushes and pull requests to master and develop. There is no services block for PostgreSQL on purpose: the DAO tests start their own container through Testcontainers, using the Docker daemon already present on ubuntu-latest, exactly as they do locally.
On every pull request the workflow reads the commits of the range between the base and the head and rejects any subject that does not match type(scope): summary. The scope is mandatory and the summary is limited to 100 characters. Merge commits are skipped, so the merge of a feature branch does not need to follow the convention.
The template asks for the branch name, the ticked task checklist copied from the README, a commit summary and a test plan covering the five test layers and the dedicated fixture files. The review checklist encodes the rules of this project: no business logic in controllers, layer boundaries between entity and dto, ApiResponse everywhere, declarative permissions, no stereotype on DAOs and services, no Lombok, no record, no ORM, audit logging and Flyway as the only schema source.
All seven core-architecture tasks are checked. The project structure now states two facts learned while implementing them: OpenApiConfig is not delivered because springdoc-openapi needs Spring Boot classes, and WebMvcConfig scans only the controller and exception packages, the sole stereotype-annotated classes of the project. The README stays the specification, so corrections found in practice are written back into it.
Spring MVC exceptions that implement ErrorResponse and have no dedicated handler used to reach the catch-all, which copied their detail text into the message, so framework wording could reach the client. The handler now keeps only the status they carry and answers with its standard reason phrase, or a generic message plus an error log for a 5xx. A non-regression test throws an ErrorResponseException carrying a secret detail and checks that only the reason phrase is returned.
The LocalValidatorFactoryBean is now a @bean, so Spring manages its lifecycle and injects the context, and getValidator() returns that same instance instead of building a new unmanaged one on every call. The Jackson customisation moved from configureMessageConverters to extendMessageConverters: the default converters (String, byte[], Resource) are kept and only the default Jackson converter is replaced by the one that writes Instants as ISO-8601 text.
HealthServiceImpl swallowed the DataAccessException and reported a bare DOWN, leaving an operator blind to the reason (bad credentials, network, exhausted pool). It now logs a warning with the exception before answering DOWN. The logger is Spring's own commons-logging facade (spring-jcl), so core still needs no logging dependency: the ws module picks the backend.
8 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Branch
feature/core-architecture
Task checklist
pom.xml(packagingpom, modulescore/ws,dependencyManagementfor Spring Framework 6.2.x, Testcontainers, etc.)core/pom.xml(packagingpom, modulescommon/auth/catalog/customer/order)core/common:ResourceNotFoundException,BusinessRuleException,AbstractDao,DataSourceConfigV1__init_schema.sqlwsskeleton:WebAppInitializer,WebMvcConfig,ApiResponse<T>,PageResponse<T>,GlobalExceptionHandlerdocker-compose.yml,Dockerfile(multi-stage: Maven build, Tomcat 10.1 runtime).github/workflows/ci.yml:mvn verifyacross the whole reactorCommit summary
Deviations: OpenApiConfig not delivered (springdoc needs Spring Boot); health route added; explicit PostgreSQL driver class (found by testing the packaged WAR).
Test plan
mvn clean verifyis green on the whole reactordocker compose up --build:/spring-jdbc-tutorial/api/v1/healthanswers UP, 404 and 405 in the ApiResponse format