Repository navigation
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.
… module The customer DAO tests run against a real PostgreSQL, so the module declares the Testcontainers JUnit and PostgreSQL artifacts and the common test-jar holding the shared singleton container, all with test scope, like the auth and catalog modules.
The DAO test loads its own dedicated fixture, which truncates the tables it touches and loads customers, one of them with NULL optional columns. It checks CRUD, the NULL round trip, page ordering, offset and an out-of-range page, the count, an update returning one affected row for a known id and zero for an unknown one, and a delete blocked by the orders foreign key. The order that blocks the delete is inserted by SQL in the test, so the constraint is proven before the order module exists.
The RowMapper is fed a mocked ResultSet, including NULL telephone, email and address, and the entity mapper is checked with hand-built objects and no mocks.
With a mocked DAO the tests cover the page bounds, the 404 cases, trimming, blank optional fields becoming null, the length limits at their exact boundaries, every email format case, an update that affects no row, and the foreign key race on delete becoming a business refusal.
The real security configuration and a real signed JWT are used with a mocked service. Every endpoint is checked for status and JSON shape, NULL optional fields in the response, optional fields absent or empty, validation errors per field, page and size violations, an out-of-range page keeping content as an empty array, and the 404 and 422 mappings.
A parameterized test asserts, for each of the five endpoints, that no token or a revoked one gives 401, that a token holding every other permission or none gives 403, and that a token holding only the required CUSTOMER permission succeeds.
Real services and a real database prove the CRUD, NULL optional fields on create and update, the two email failure modes (400 from Bean Validation, 422 from the service), the 404 cases and the pagination bounds. A customer with an order, inserted by SQL, cannot be deleted: the answer is 422 "Customer is referenced by orders" and the customer stays, then the delete succeeds once the order is gone. Permission-less and write-only users get 403.
The three customer tasks are checked: the entity, dto, RowMapper, mapper, DAO, service and configuration of core/customer, the controller and converter in ws with the WebMvcConfig import, and the tests at all five layers with a dedicated fixture for the DAO test class.
feat(customer): customers
Order is the table-shaped class for the orders table (the class name is Order, the table is orders because ORDER is an SQL keyword) and OrderDto is the business shape the service exposes. The quantity is an int and the total a BigDecimal, never a double, because it is money. The two shapes stay separate classes.
The RowMapper reads one row into an Order with getInt for the quantity and getBigDecimal for the total, so the exact decimal survives the round trip. The entity mapper converts between Order and OrderDto. Neither queries the database or contains a business rule.
OrderDaoImpl reads a page filtered by an optional customer and an optional product, counts with the same filters, finds by id and inserts with INSERT ... RETURNING id. The WHERE clause is built from the filters that are present: only fixed column names are concatenated and every value is a bound parameter, added in placeholder order. There is no update or delete, because orders are immutable in this API.
…stomer services OrderServiceImpl reaches customers and products only through their service interfaces, never their DAOs. The injected beans are transactional proxies, so their calls join the transaction of create, and their ResourceNotFoundException propagates as a 404. The total is the product price times the quantity, computed exactly and compared with the column limit before any scaling. A quantity outside 1 to 1000000 is refused, and a customer or product deleted between the checks and the insert is reported as not found instead of a 500. The service returns dto types and pages through PageDto.
DaoConfig declares the DAO, and ServiceConfig imports it together with the catalog and customer service configurations, by fully qualified name since each module has a class with that simple name. The order module is therefore self-sufficient: the ProductService and CustomerService it injects come from those imported configs. The order service bean is typed by interface so its @transactional proxy applies.
OrderRequest requires the customer, the product and a quantity between 1 and 1000000, so a missing or absurd value is a 400 before it reaches the service. OrderResponse exposes the computed total, and OrderConverter maps to and from the core dto and builds the PageResponse. Payload classes stay distinct from core dto classes.
OrderController offers the paginated list filterable by customerId and productId, the detail and the creation, guarded by @PreAuthorize("hasPermission('ORDER', 'READ'|'WRITE')"). It only calls OrderService and answers with an ApiResponse, and a creation returns 201. There is no update or delete route, so those methods answer 405.
The order ServiceConfig is added to the WebMvcConfig import list by fully qualified name. It brings the DAO, and through its own imports the catalog and customer services it depends on. With this import every core module is now wired into the single application context.
The order DAO tests run against a real PostgreSQL, so the module declares the Testcontainers JUnit and PostgreSQL artifacts and the common test-jar holding the shared singleton container, all with test scope, like the other business modules.
The DAO test loads its own dedicated fixture with customers, products and orders. It checks page ordering, offset and an out-of-range page, each filter alone and both together, counts with the same filters, lookups, the exact NUMERIC total round trip including the column maximum, and the constraints owned by the schema: foreign keys to the customer and the product and the CHECK on the quantity.
The RowMapper is fed a mocked ResultSet with an int quantity and a BigDecimal total, and the entity mapper is checked with hand-built objects and no mocks.
…duct services The order module only sees service interfaces, so the tests mock CustomerService and ProductService. They cover exact totals (3 x 19.99 = 59.97, a zero price, half-up rounding), a total above the column limit refused before any insert, the quantity boundaries, null ids, and the order of the checks: an unknown customer or product propagates its 404 and nothing is inserted. A foreign key violation at insert becomes a not-found error, and the page bounds and filters are checked too.
The real security configuration and a real signed JWT are used with a mocked service. The tests check status and shape of the three endpoints, validation errors per field, the customer and product filters passed to the service, page and size violations, an out-of-range page keeping content as an empty array, the 404 and 422 mappings, and 405 for PUT, DELETE and PATCH. A client-supplied total or id is shown to be ignored.
A parameterized test asserts, for each endpoint, that no token or a revoked one gives 401, that a token holding every other permission or none gives 403, and that a token holding only the required ORDER permission succeeds.
Customers, categories and products are created through their real endpoints, then the tests prove that the total is computed from the product price at creation and stays after the price changes, the filters and pagination, unknown customers and products giving 404 with nothing stored, and invalid quantities giving 400. Deleting a customer or a product referenced by an order gives 422 with the exact message, then succeeds once the order is removed. A decimal quantity such as 1.5 is refused with 400 and stores nothing, which pins the Jackson fix.
By default Jackson accepts a floating point JSON number for an int field and silently truncates it, so a body with a quantity of 1.5 created an order for one unit instead of being refused. The shared ObjectMapper now disables DeserializationFeature.ACCEPT_FLOAT_AS_INT, so such a body is a malformed request and answers 400 through the existing handler. An integration test on the order endpoint pins the behaviour.
…ndencies The three order tasks are checked: the entity, dto, RowMapper, mapper, DAO, service and configuration of core/order, the controller and converter in ws with the WebMvcConfig import, and the tests at all five layers with a dedicated fixture for the DAO tests. The project tree said the order service depends on CategoryService as well, but computing a total and checking the references only needs ProductService and CustomerService, so the README now says so.
The exact product is already compared with the column maximum, which has two decimals, so rounding a value at or below it to two decimals can never exceed it. The second comparison after setScale and its duplicated message could never fire. The rounding itself stays, and the unit tests that pin the totals still pass.
feat(order): orders computed from catalog prices
The last section told the reader to run docker-compose and then deploy the WAR by hand, or to use a tomcat10:deploy goal that was never configured. docker compose now builds the WAR and deploys it itself, needs a mandatory database password and a mounted RSA key pair, and publishes the port given by APP_PORT. The section now lists how to run the tests (Docker is required for Testcontainers), how to start the application, and where the development admin bootstrap is documented.
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
develop -> master
Task checklist
Commit summary
Six feature branches merged into develop through pull requests #1 to #6, plus the README corrections found while implementing (OrderService dependencies, OpenApiConfig not delivered, scan scope of WebMvcConfig, run instructions).
Test plan
mvn clean verifyis green on the whole reactor (936 tests: common 17, auth 231, catalog 94, customer 56, order 48, ws 490)docker compose up --buildon the packaged WAR: health, register, activate, login, /me, logout then 401, bootstrap admin, role/category/product/customer/order creation, refusal of a customer deletion referenced by an order, 400/401/403/404/405/422 in the ApiResponse format