A complete tutorial for building a REST API with classic Spring Framework (no Spring Boot) and Spring JDBC (JdbcTemplate), organized as a Maven multi-module project that strictly separates business logic from the web layer: everything business-related lives in core, everything HTTP-related lives in ws. Covers a full identity/RBAC domain (users, roles, permissions, tokens) and an e-commerce domain (categories, products, customers, orders) as two independent data models.
This document is the complete specification of the project: it is meant to be followed step by step to implement each branch.
- Why no Spring Boot, and why no stereotype annotations
- core vs. ws: what belongs where
- Entity, DTO, Mapper, DAO, Service: five layers, five responsibilities
- Tech stack
- Data model
- Module structure
- Branching strategy
- Project structure
- Standard response format
- Testing strategy
- feature/core-architecture
- feature/auth
- feature/rbac
- feature/catalog
- feature/customer
- feature/order
- Order of work
- Code conventions
- Concepts covered
- How to follow this tutorial
This project uses Spring Framework directly, wired through explicit Java @Configuration classes rather than Spring Boot's auto-configuration - every bean this project needs is declared by hand, in a @Bean method, in a config class that belongs to the same module as the thing it wires. There is no @SpringBootApplication, no starter, no classpath-driven auto-configuration guessing what should exist.
The same philosophy extends to the business layer: no @Repository, no @Service, no @Component on a DAO or service implementation class. Every DAO and service bean is registered explicitly in that module's own DaoConfig/ServiceConfig, by its interface type. This is a deliberate constraint, not an oversight - it means a bean's existence and its wiring are always visible by reading one config class, never inferred from an annotation plus a component-scan base package that might or might not cover it.
coreowns every business concern: entities, data access (DAOs), business rules (services), and the configuration wiring both together.corehas no dependency on anything web-related - no Spring MVC, no Servlet API, no JSON serialization concern. Acoremodule could be reused behind a completely different front door (a CLI, a batch job, a different web framework) without modification.ws("web service") owns every HTTP concern: controllers, request/response payloads, security, and the application's single entry point.wsdepends on everycore/*module, but nocore/*module ever depends onws.- This is not multi-module for the sake of enforcing it via the compiler alone (though it does that too) - it's a statement about where business logic is allowed to live, matching the same discipline other projects enforce through a test instead of a module boundary.
Without an ORM, five concerns that JPA would normally blur together into two (an @Entity and a repository) are kept explicitly separate here:
- Entity (
entity/): the raw, table-shaped representation of a row - one class per table, annotated@jakarta.persistence.Table(name = "...")(and@Id/@Columnon its fields). These annotations are documentation only - this project has no JPA provider, noEntityManager, nothing reads them at runtime. They exist purely so the mapping between a class and its table/columns is declared in one visible place, next to the class itself, instead of being implicit in whatever SQL aRowMapperhappens to write. ARowMapperstill does all the actual work of turning aResultSetinto this object - the annotations don't do it. - DTO (
dto/): the business-facing representation aServicereturns and accepts. It usually mirrors its entity closely, but it's a genuinely separate class (suffixedDtoto keep the two importable side by side without a naming clash) - aServicemethod never returns anentity/object directly, even when nothing has changed between the two shapes yet. - Mapper (
mapper/): now covers two distinct, equally single-purpose jobs, both living in the same package but never the same class:- a
RowMapper<Entity>implementation, converting oneResultSetrow into oneentity/object - used only bydao/impl/ - an
EntityMapper, convertingentity/↔dto/in both directions - used only byservice/impl/
- a
- DAO (
dao/+dao/impl/): owns SQL andJdbcTemplatecalls, and works exclusively inentity/types - a DAO method never sees or returns adto/object. - Service (
service/+service/impl/): owns business rules and transaction boundaries, and works exclusively indto/types at its own boundary - it calls a DAO (gettingentity/back), converts via theEntityMapper, and only ever hands adto/object to whatever called it.
A DAO never calls a service. A service never touches JdbcTemplate directly. A controller (in ws) never touches a DAO or an entity/ class - only a service, and only dto/ types.
| Component | Choice |
|---|---|
| Framework | Spring Framework 6.2.x (classic, no Spring Boot) |
| Language | Java 17 (LTS) |
| Build | Maven, multi-module (core and ws as top-level modules, core itself aggregating common/auth/catalog/customer/order) |
| Data access | Spring JDBC (JdbcTemplate), hand-written RowMapper<T> implementations - no JPA provider, no Hibernate, no Spring Data, no EntityManager |
| Entity annotations | jakarta.persistence-api only (the annotations @Table/@Id/@Column) - used purely as documentation on entity/ classes, never processed by anything at runtime |
| Database | PostgreSQL 16 (via Docker Compose) |
| Migrations | Flyway |
| Web layer | Spring MVC, packaging war |
| Application server | Tomcat 10.1.x (Jakarta Servlet 6.0, matching Spring Framework 6.x's jakarta.* namespace) |
| Security | Spring Security 6.x (explicit SecurityFilterChain bean, no auto-configuration), JWT via io.jsonwebtoken:jjwt |
| Permission checks | @PreAuthorize("hasPermission(...)") backed by a custom PermissionEvaluator |
| Validation | Jakarta Bean Validation (Hibernate Validator as the implementation, used standalone - not JPA's) |
| Tests | JUnit 5, Mockito, Spring Test (MockMvc), Testcontainers (PostgreSQL, for DAO tests) |
| CI/CD | GitHub Actions |
| Containerization | Docker, docker-compose |
Two independent domains, no cross-domain foreign key.
users (id, first_name, last_name, email, password, enabled, account_locked)
│ N──N (via role_user)
roles (id, role_name)
│ N──N (via role_permission)
permissions (id, resource, action)
activation_tokens (id, user_id, token, created_at, expires_at, validated_at)
blacklisted_tokens (id, user_id, token, jti, blacklisted_at, created_at, expires_at, validated_at)
password_reset_tokens (id, user_id, token, type, expiry_date)
audit_logs (id, actor_user_id, action, entity_type, entity_id, details, created_at)
categories (id, category_name)
│ 1
│
│ N
products (id, category_id, product_name, unit_price)
│ 1
│
│ N
orders (id, customer_id, product_id, quantity, total)
│ N
│
│ 1
customers (id, first_name, last_name, telephone, email, address)
┌────────┐
│ ws │ (Spring MVC, security, HTTP payloads)
└───┬────┘
┌───────────────┼───────────────┬──────────────┐
│ │ │ │
┌────▼────┐ ┌──────▼─────┐ ┌──────▼─────┐ ┌─────▼────┐
│ auth │ │ catalog │ │ customer │ │ order │
└────┬─────┘ └──────┬─────┘ └──────┬─────┘ └────┬─────┘
│ │ │ │
│ order also depends on catalog and customer
│ │ │ │
└────────────────┴───────────────┴──────────────┘
│
┌────▼────┐
│ common │ (DataSourceConfig, AbstractDao, shared exceptions)
└─────────┘
order is the only core module that depends on another business module (catalog, customer) - and only on their service interfaces, never their DAOs, to validate a product/customer exist and read the product's price.
| Branch | Role |
|---|---|
master |
Stable, production-ready code. No direct commits, only merges from develop. |
develop |
Integration branch. |
feature/core-architecture |
Parent/core/ws POM structure, common module, ws skeleton (servlet wiring, exception handling, Docker, CI). |
feature/auth |
core/auth module: User, tokens, registration/activation/login/logout/password reset. |
feature/rbac |
Role/Permission inside core/auth, PermissionEvaluator, full CRUD, audit logging. |
feature/catalog |
core/catalog module: Category/Product. |
feature/customer |
core/customer module: Customer. |
feature/order |
core/order module: Order, depends on catalog/customer services. |
spring-jdbc-tutorial/
├── pom.xml (parent aggregator, packaging=pom, modules: core, ws)
├── core/
│ ├── pom.xml (aggregator, packaging=pom, modules: common, auth, catalog, customer, order)
│ ├── common/
│ │ ├── pom.xml
│ │ └── src/main/java/com/edgareldy/springjdbctutorial/core/common/
│ │ ├── exception/
│ │ │ ├── ResourceNotFoundException.java
│ │ │ └── BusinessRuleException.java
│ │ ├── dao/support/
│ │ │ └── AbstractDao.java (shared JdbcTemplate access, common query helpers)
│ │ └── config/
│ │ └── DataSourceConfig.java (DataSource, JdbcTemplate, PlatformTransactionManager - the only place these are declared)
│ ├── auth/
│ │ ├── pom.xml (depends on common)
│ │ └── src/main/java/.../core/auth/
│ │ ├── entity/
│ │ │ ├── User.java, Role.java, Permission.java (@Table("users"), @Table("roles"), @Table("permissions"))
│ │ │ ├── ActivationToken.java, BlacklistedToken.java, PasswordResetToken.java
│ │ │ └── AuditLog.java
│ │ ├── dto/
│ │ │ ├── UserDto.java, RoleDto.java, PermissionDto.java
│ │ │ ├── ActivationTokenDto.java, BlacklistedTokenDto.java, PasswordResetTokenDto.java
│ │ │ └── AuditLogDto.java
│ │ ├── mapper/
│ │ │ ├── UserRowMapper.java, RoleRowMapper.java, PermissionRowMapper.java (ResultSet → entity)
│ │ │ ├── ActivationTokenRowMapper.java, BlacklistedTokenRowMapper.java, PasswordResetTokenRowMapper.java, AuditLogRowMapper.java
│ │ │ └── UserMapper.java, RoleMapper.java, PermissionMapper.java, AuditLogMapper.java (entity ↔ dto)
│ │ ├── dao/
│ │ │ ├── UserDao.java, RoleDao.java, PermissionDao.java, AuditLogDao.java (interfaces, work in entity/ types)
│ │ │ └── impl/
│ │ │ └── UserDaoImpl.java, RoleDaoImpl.java, PermissionDaoImpl.java, AuditLogDaoImpl.java (JdbcTemplate, unannotated)
│ │ ├── service/
│ │ │ ├── AuthService.java, RbacService.java, AuditLogger.java (interfaces, work in dto/ types)
│ │ │ └── impl/
│ │ │ └── AuthServiceImpl.java, RbacServiceImpl.java, AuditLoggerImpl.java (unannotated)
│ │ └── config/
│ │ ├── DaoConfig.java (@Bean UserDao, RoleDao, PermissionDao, AuditLogDao)
│ │ └── ServiceConfig.java (@Bean AuthService, RbacService, AuditLogger)
│ ├── catalog/
│ │ ├── pom.xml (depends on common)
│ │ └── src/main/java/.../core/catalog/
│ │ ├── entity/ (Category.java @Table("categories"), Product.java @Table("products"))
│ │ ├── dto/ (CategoryDto.java, ProductDto.java)
│ │ ├── mapper/ (CategoryRowMapper.java, ProductRowMapper.java, CategoryMapper.java, ProductMapper.java)
│ │ ├── dao/ (CategoryDao.java, ProductDao.java + impl/)
│ │ ├── service/ (CategoryService.java, ProductService.java + impl/)
│ │ └── config/ (DaoConfig.java, ServiceConfig.java)
│ ├── customer/
│ │ ├── pom.xml (depends on common)
│ │ └── src/main/java/.../core/customer/
│ │ ├── entity/ (Customer.java @Table("customers"))
│ │ ├── dto/ (CustomerDto.java)
│ │ ├── mapper/ (CustomerRowMapper.java, CustomerMapper.java)
│ │ ├── dao/ (CustomerDao.java + impl/)
│ │ ├── service/ (CustomerService.java + impl/)
│ │ └── config/ (DaoConfig.java, ServiceConfig.java)
│ └── order/
│ ├── pom.xml (depends on catalog, customer)
│ └── src/main/java/.../core/order/
│ ├── entity/ (Order.java @Table("orders"))
│ ├── dto/ (OrderDto.java)
│ ├── mapper/ (OrderRowMapper.java, OrderMapper.java)
│ ├── dao/ (OrderDao.java + impl/)
│ ├── service/ (OrderService.java + impl/, depends on ProductService/CustomerService only: the category is never needed)
│ └── config/ (DaoConfig.java, ServiceConfig.java)
└── ws/
├── pom.xml (depends on core/auth, core/catalog, core/customer, core/order)
└── src/main/
├── java/com/edgareldy/springjdbctutorial/ws/
│ ├── controller/
│ │ ├── AuthController.java, UserController.java, RoleController.java, PermissionController.java
│ │ └── CategoryController.java, ProductController.java, CustomerController.java, OrderController.java
│ ├── payload/
│ │ ├── common/ (ApiResponse.java, PageResponse.java)
│ │ └── auth/, rbac/, catalog/, customer/, order/ (HTTP request/response classes - "payload", not "dto",
│ │ to avoid colliding with core's own dto/ naming)
│ ├── converter/ (core dto/ ↔ ws payload/ - deliberately not called "mapper",
│ │ since that name is already used for RowMapper in core)
│ │ ├── UserConverter.java, RoleConverter.java, PermissionConverter.java
│ │ └── CategoryConverter.java, ProductConverter.java, CustomerConverter.java, OrderConverter.java
│ ├── security/
│ │ ├── JwtService.java
│ │ ├── JwtAuthFilter.java
│ │ └── CustomPermissionEvaluator.java (PermissionEvaluator, backs @PreAuthorize("hasPermission(...)"))
│ ├── exception/
│ │ └── GlobalExceptionHandler.java (@RestControllerAdvice - the one stereotype-adjacent annotation
│ │ in this project, since Spring MVC requires it for this mechanism)
│ └── config/
│ ├── WebAppInitializer.java (WebApplicationInitializer, replaces web.xml)
│ ├── WebMvcConfig.java (@EnableWebMvc, imports every core module's DaoConfig/ServiceConfig, scans only ws.controller and ws.exception)
│ ├── SecurityConfig.java (SecurityFilterChain, method security enabling @PreAuthorize)
│ └── OpenApiConfig.java (not delivered: springdoc-openapi relies on Spring Boot classes)
└── webapp/
└── (empty - no JSPs, API-only)
├── docker-compose.yml
├── Dockerfile
├── .github/workflows/ci.yml
└── README.md
Every response is wrapped in a generic ApiResponse<T>, defined once in ws/payload/common/.
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
private Instant timestamp;
public static <T> ApiResponse<T> success(T data, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.success = true;
response.message = message;
response.data = data;
response.timestamp = Instant.now();
return response;
}
public static <T> ApiResponse<T> error(String message) {
ApiResponse<T> response = new ApiResponse<>();
response.success = false;
response.message = message;
response.timestamp = Instant.now();
return response;
}
// explicit getters/setters below - no Lombok, no record (see Code conventions)
}GlobalExceptionHandler maps ResourceNotFoundException (from any core module) to 404, Bean Validation failures to 400, BusinessRuleException to 422, and anything unmapped to 500, always through ApiResponse<Void>.
Every branch from feature/auth onward ships tests at all five layers before its Pull Request is opened - a branch is not complete with only service tests, or only controller tests.
| Layer | Tool | What it verifies | Data |
|---|---|---|---|
| DAO | JUnit 5 + Spring Test + Testcontainers (real PostgreSQL) | Real SQL against a real schema: inserts, updates, deletes, derived queries, all in entity/ types |
Each DAO test class loads its own dedicated SQL fixture file (e.g. user-dao-dataset.sql, category-dao-dataset.sql), executed before each test - never a shared dataset reused across DAOs, so one DAO's test data can never accidentally satisfy or break another's assumptions |
| RowMapper | JUnit 5 + Mockito | A RowMapper.mapRow(...) implementation, given a mocked java.sql.ResultSet (Mockito.mock(ResultSet.class), stubbed column by column), producing the expected entity/ object |
No database at all - pure, fast unit tests |
| EntityMapper | JUnit 5 | An entity ↔ dto conversion, given a hand-built object on either side | No database, no mocks needed - the simplest tests in the project |
| Service | JUnit 5 + Mockito | Business rules and orchestration, with every DAO (and any other module's service) dependency mocked, asserting on the dto/ objects returned |
No database - constructed test data only |
| Controller | JUnit 5 + Spring Test (MockMvc) |
HTTP-level behavior: status codes, payload shape, security/permission enforcement, with the service layer mocked | No database - MockMvc against a standalone or WebApplicationContext-backed setup |
Every test method, at every layer, is named _NN_Should<Outcome>_When<Condition> - a two-digit, zero-padded sequence number (execution order within the class, not a requirement enforced by JUnit but kept consistent by convention), followed by what's expected, followed by the condition that produces it.
@Test
void _01_ShouldReturnCategory_WhenCategoryExists() { ... }
@Test
void _02_ShouldReturnEmpty_WhenCategoryDoesNotExist() { ... }No other naming style (shouldX(), testX(), givenX_whenY_thenZ()) is used anywhere in this project's test suite.
- Parent
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(holds aJdbcTemplatereference, offers a couple of shared helpers),DataSourceConfig(DataSourcevia HikariCP,JdbcTemplate,DataSourceTransactionManager) - Flyway script
V1__init_schema.sql(all tables from both domains, includingaudit_logs) -
wsskeleton:WebAppInitializer(WebApplicationInitializer),WebMvcConfig(@EnableWebMvc, no module config imported yet),ApiResponse<T>,PageResponse<T>,GlobalExceptionHandler -
docker-compose.yml(wsdeployed on Tomcat + PostgreSQL),Dockerfile(multi-stage: Maven build, Tomcat 10.1 runtime) -
.github/workflows/ci.yml:mvn verifyacross the whole reactor
| Method | URL | Description |
|---|---|---|
| POST | /api/v1/auth/register |
Register (creates a disabled user + activation token) |
| GET | /api/v1/auth/activate-account |
Activates a user account |
| POST | /api/v1/auth/login |
Returns a JWT |
| POST | /api/v1/auth/logout |
Blacklists the current JWT |
| GET | /api/v1/auth/me |
Current user profile |
| POST | /api/v1/auth/forgot-password |
Generates a password-reset token |
| POST | /api/v1/auth/reset-password |
Consumes the token, updates the password |
-
core/auth:User,ActivationToken,BlacklistedToken,PasswordResetTokenentity classes (entity/,@Table("users")/@Table("activation_tokens")/etc.,@Id/@Columnon fields, plain Java otherwise) and their matchingUserDto,ActivationTokenDto,BlacklistedTokenDto,PasswordResetTokenDtoclasses (dto/) -
UserRowMapper,ActivationTokenRowMapper,BlacklistedTokenRowMapper,PasswordResetTokenRowMapper(ResultSet→ entity), andUserMapper(entity ↔ dto, the only one of these four actually needed yet - the token entities/dtos are used directly byAuthServiceImplwithout a full bidirectional mapper, since nothing outside the service ever needs a token dto's shape beyond whatAuthControllerreads directly offAuthService's own return types) -
UserDao+UserDaoImpl(and the token DAOs), allJdbcTemplate-based, all unannotated, all working inentity/types -
AuthService(interface) +AuthServiceImpl: registration, activation, login (password hashing/verification viaorg.springframework.security.crypto.bcrypt.BCryptPasswordEncoder, used standalone), logout, forgot/reset password - every public method takes/returnsdto/types, converting to/fromentity/viaUserMapperaround eachUserDaocall -
forgotPasswordreturns the exact same outcome - same behavior, roughly the same timing - whether or not the submitted email matches an existing account, so the endpoint can't be used to enumerate registered emails -
core/auth'sDaoConfig/ServiceConfig -
ws:JwtService(issuance/validation viajjwt, uniquejtiper token),JwtAuthFilter,AuthController,UserConverter(core.auth.dto.UserDto↔ws.payload.auth.*) -
WebMvcConfigupdated to importcore/auth'sDaoConfig/ServiceConfig - Tests at all five layers (see Testing strategy):
UserDao/token DAOs against Testcontainers with their own fixture files, eachRowMapperagainst a mockedResultSet,UserMapperagainst a hand-built entity/dto pair (pure unit test, no mocks needed),AuthServiceImplwithUserDaomocked,AuthControllerviaMockMvcwithAuthServicemocked - plus one full register → activate → login →/meflow
Full CRUD for users, roles, and permissions, still inside core/auth. Assignments always flow in one direction: permissions are assigned onto a role, never the reverse; roles are assigned onto a user, never the reverse.
| Method | URL | Description | Access |
|---|---|---|---|
| GET | /api/v1/users |
Paginated list | @PreAuthorize("hasPermission('USER','READ')") |
| GET | /api/v1/users/{id} |
Detail, including roles | hasPermission('USER','READ') |
| PATCH | /api/v1/users/{id}/roles/{roleId} |
Assign a role to a user | hasPermission('USER','WRITE') |
| DELETE | /api/v1/users/{id}/roles/{roleId} |
Remove a role from a user | hasPermission('USER','WRITE') |
| GET | /api/v1/roles |
List, including permissions | hasPermission('ROLE','READ') |
| POST | /api/v1/roles |
Create | hasPermission('ROLE','WRITE') |
| PUT | /api/v1/roles/{id} |
Update (name only) | hasPermission('ROLE','WRITE') |
| DELETE | /api/v1/roles/{id} |
Delete | hasPermission('ROLE','WRITE') |
| POST | /api/v1/roles/{id}/permissions/{permissionId} |
Assign a permission to a role | hasPermission('ROLE','WRITE') |
| DELETE | /api/v1/roles/{id}/permissions/{permissionId} |
Remove a permission from a role | hasPermission('ROLE','WRITE') |
| GET | /api/v1/permissions |
List | hasPermission('PERMISSION','READ') |
| POST | /api/v1/permissions |
Create | hasPermission('PERMISSION','WRITE') |
| PUT | /api/v1/permissions/{id} |
Update | hasPermission('PERMISSION','WRITE') |
| DELETE | /api/v1/permissions/{id} |
Delete | hasPermission('PERMISSION','WRITE') |
-
Role,Permission,AuditLogentity classes (entity/,@Table("roles")/@Table("permissions")/@Table("audit_logs")) andRoleDto/PermissionDto/AuditLogDto(dto/),RoleRowMapper/PermissionRowMapper/AuditLogRowMapper(entity, fromResultSet) plusRoleMapper/PermissionMapper/AuditLogMapper(entity ↔ dto),RoleDao/PermissionDao/AuditLogDao+ impl (working in entity/ types) -
RbacService(interface) +RbacServiceImpl:createRole/updateRole/deleteRole-deleteRolerejects if any user is still assigned this rolecreatePermission/updatePermission/deletePermission- rejects if any role still has this permissionassignPermissionToRole/removePermissionFromRole- rejects removingROLE:WRITEfrom a role if it would leave zero users anywhere holding a role that grants itassignRoleToUser/removeRoleFromUser- the same last-admin check applied at the point of removal from a specific user
-
AuditLogger/AuditLoggerImpl: a singlelog(String action, String entityType, Long entityId, String details)method, called from everyRbacServiceImplmutation -
CustomPermissionEvaluator(ws, implements Spring Security'sPermissionEvaluator): resolves the authenticated user's permissions (loaded once at login and embedded in the JWT, read from theAuthenticationobject - no database call per request) and answershasPermission(target, permission)calls from@PreAuthorize -
SecurityConfigupdated:@EnableMethodSecurity,CustomPermissionEvaluatorregistered on theMethodSecurityExpressionHandler - A seeding step (a Flyway data-migration): baseline permissions covering every resource/action this project defines, assigned to a seeded
ADMINrole - without this, nobody could ever be grantedROLE:WRITE/PERMISSION:WRITEto create the first assignment -
RoleController,PermissionController,UserControllerrole-assignment endpoints,RoleConverter/PermissionConverter - Tests at all five layers, including the "still referenced" rejection on
deleteRole/deletePermission, the last-admin rejection triggered both ways,CustomPermissionEvaluatorallowing/denying correctly, and an assertion that everyRbacServiceImplmutation produces a matchingAuditLogrow
| Method | URL | Description | Access |
|---|---|---|---|
| GET | /api/v1/categories |
Paginated list | hasPermission('CATEGORY','READ') |
| GET | /api/v1/categories/{id} |
Detail | hasPermission('CATEGORY','READ') |
| POST | /api/v1/categories |
Create | hasPermission('CATEGORY','WRITE') |
| PUT | /api/v1/categories/{id} |
Update | hasPermission('CATEGORY','WRITE') |
| DELETE | /api/v1/categories/{id} |
Delete | hasPermission('CATEGORY','WRITE') |
| GET | /api/v1/products |
Paginated list, filterable by categoryId |
hasPermission('PRODUCT','READ') |
| GET | /api/v1/products/{id} |
Detail | hasPermission('PRODUCT','READ') |
| POST | /api/v1/products |
Create | hasPermission('PRODUCT','WRITE') |
| PUT | /api/v1/products/{id} |
Update | hasPermission('PRODUCT','WRITE') |
| DELETE | /api/v1/products/{id} |
Delete | hasPermission('PRODUCT','WRITE') |
-
core/catalog:Category,Productentity classes (@Table("categories")/@Table("products")) andCategoryDto/ProductDto,CategoryRowMapper/ProductRowMapper(entity) plusCategoryMapper/ProductMapper(entity ↔ dto),CategoryDao/ProductDao+ impl,CategoryService/ProductService+ impl,DaoConfig/ServiceConfig - Business rule: deleting a category that still has products is rejected (
BusinessRuleException→ 422) -
ws:CategoryController,ProductController,CategoryConverter,ProductConverter -
WebMvcConfigupdated to importcore/catalog's config - Tests at all five layers -
CategoryDao/ProductDaoeach with their own fixture dataset, the category-deletion rejection case, a permission-denied case
| Method | URL | Description | Access |
|---|---|---|---|
| GET | /api/v1/customers |
Paginated list | hasPermission('CUSTOMER','READ') |
| GET | /api/v1/customers/{id} |
Detail | hasPermission('CUSTOMER','READ') |
| POST | /api/v1/customers |
Create | hasPermission('CUSTOMER','WRITE') |
| PUT | /api/v1/customers/{id} |
Update | hasPermission('CUSTOMER','WRITE') |
| DELETE | /api/v1/customers/{id} |
Delete | hasPermission('CUSTOMER','WRITE') |
-
core/customer:Customerentity (@Table("customers")) andCustomerDto,CustomerRowMapper(entity) plusCustomerMapper(entity ↔ dto),CustomerDao+ impl,CustomerService+ impl,DaoConfig/ServiceConfig -
ws:CustomerController,CustomerConverter -
WebMvcConfigupdated to importcore/customer's config - Tests at all five layers,
CustomerDaowith its own fixture dataset
| Method | URL | Description | Access |
|---|---|---|---|
| GET | /api/v1/orders |
Paginated list, filterable by customerId/productId |
hasPermission('ORDER','READ') |
| GET | /api/v1/orders/{id} |
Detail | hasPermission('ORDER','READ') |
| POST | /api/v1/orders |
Create (computes total) |
hasPermission('ORDER','WRITE') |
-
core/order:Orderentity (@Table("orders")) andOrderDto,OrderRowMapper(entity) plusOrderMapper(entity ↔ dto),OrderDao+ impl,OrderService+ impl (depends oncatalog'sProductServiceandcustomer'sCustomerService- their service interfaces, never their DAOs directly): computestotal = quantity * product.unitPrice, checks the referenced customer/product exist -
core/order'sDaoConfig/ServiceConfig(the latter injectingProductService/CustomerServicebeans from the other modules' contexts) -
ws:OrderController,OrderConverter -
WebMvcConfigupdated to importcore/order's config - Tests at all five layers,
OrderDaowith its own fixture dataset, the total computation, and the not-found cases for a badcustomerId/productId
feature/core-architecture→ Pull Request todevelopfeature/auth(depends oncore-architecture) → Pull Request todevelopfeature/rbac(depends onauth) → Pull Request todevelopfeature/catalog(depends oncore-architecture) → Pull Request todevelopfeature/customer(depends oncore-architecture) → Pull Request todevelopfeature/order(depends oncatalog,customer) → Pull Request todevelopdevelop→master
- Root package:
com.edgareldy.springjdbctutorial, sub-rootedcore.<module>andws - No Lombok, no Java
record, anywhere in this project - every entity, dto, and payload class is a plain Java class with explicit fields, an explicit constructor, explicit getters/setters, and explicitequals/hashCodewhere needed. This is a deliberate departure from other tutorials that default to records for DTOs. - No
@Repository,@Service, or@Componenton a DAO or service implementation - every bean is declared in that module'sDaoConfig/ServiceConfigvia an explicit@Beanmethod entity/classes carry@Table/@Id/@Columnas documentation only - no JPA provider ever reads them, and their presence never implies any actual persistence behavior beyond whatdao/impl/explicitly writes in SQL- A DAO method never returns a
ResultSet, never takes SQL as a parameter from outsidedao/impl/, and never sees adto/type - onlyentity/ - A
RowMappernever queries the database and never contains business logic - it only converts oneResultSetrow into oneentity/object - An
EntityMappernever queries the database and never contains business logic - it only converts betweenentity/anddto/, in both directions - A service never calls
JdbcTemplatedirectly, never calls another module's DAO (only another module's service, injected by interface type), and never accepts or returns anentity/type at its own public boundary - onlydto/ core'sdto/andws'spayload/are named differently on purpose, to keep "the business object" and "the HTTP request/response shape" visually distinct even where their fields overlap;entity/anddto/classes are distinguished by theDtosuffix so both can be imported in the sameEntityMapperwithout ambiguity- Every RBAC mutation calls
AuditLogger - Every DAO test class loads its own dedicated SQL fixture file, never a dataset shared with another DAO's tests
- Multi-module Maven with a strict business/web separation enforced by module boundaries, not just convention
- Explicit Spring configuration (
@Configuration/@Bean) as an alternative to component scanning and stereotype annotations - Spring JDBC (
JdbcTemplate) as an ORM-free data access layer - The Entity / DTO / Mapper / DAO / Service five-way split, and why an ORM normally hides this distinction
- Hand-written
RowMapper<T>implementations, tested independently of any database - Spring Security configured explicitly (
SecurityFilterChain, method security), JWT issued and validated by hand - Fine-grained permission checks via a custom
PermissionEvaluatorbacking@PreAuthorize("hasPermission(...)") - Directional relationship management (permissions onto roles, roles onto users) as a deliberate convention
- Audit logging as a first-class concern for every sensitive RBAC mutation
- Five-layer testing (DAO, RowMapper, EntityMapper, service, controller), each with the tool and data isolation appropriate to it
- Using
jakarta.persistenceannotations purely as documentation, with no JPA provider ever processing them - Classic WAR packaging and deployment on an external servlet container, without Spring Boot
- Containerization (Docker, docker-compose)
- Continuous integration (GitHub Actions)
- Clone the repository and check out
develop - Follow the branches in order:
feature/core-architecture→feature/auth→feature/rbac→feature/catalog→feature/customer→feature/order - Run the tests with
./mvnw verify(needs a Docker daemon: the DAO tests start PostgreSQL through Testcontainers) - Run the packaged application:
cp .env.example .envand setDB_PASSWORD, generate an RSA key pair in./keys(the commands are at the top ofdocker-compose.yml), thendocker compose up --build. The image buildsws/target/ws.warand deploys it on Tomcat 10.1 asspring-jdbc-tutorial.war; there is notomcat10:deployconfiguration - Access the API at
http://localhost:${APP_PORT}/spring-jdbc-tutorial/api/v1/...(APP_PORTdefaults to 8080).GET /api/v1/healthis public; to get a first administrator in development, seedev-keys/README.md