Skip to content

release: spring-jdbc-tutorial complete API - #7

Merged
EdgarEldy merged 150 commits into
masterfrom
develop
Sep 19, 2026
Merged

EdgarEldy merged 150 commits into
masterfrom
develop

Conversation

@EdgarEldy

Copy link
Copy Markdown
Owner

Branch

develop -> master

Task checklist

  • feature/core-architecture (Maven reactor, common module, ws skeleton, Docker, CI)
  • feature/auth (registration, activation, JWT login/logout, password reset)
  • feature/rbac (roles, permissions, method security, audit logging, last-admin rule)
  • feature/catalog (categories, products)
  • feature/customer (customers)
  • feature/order (orders computed from catalog prices)

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 verify is green on the whole reactor (936 tests: common 17, auth 231, catalog 94, customer 56, order 48, ws 490)
  • docker compose up --build on 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

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.
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.
@EdgarEldy
EdgarEldy merged commit 4c2af21 into master Sep 19, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant