A complete, hands-on walkthrough of building a CRUD REST API with Spring Boot 3.5.x (Spring Framework 6, Java 17), organized into Git branches that progressively cover most of the key concepts in the Spring Boot ecosystem.
The data model follows this EER schema: categories → products → orders ← customers.
This document is the complete specification of the project: it is meant to be followed step by step to implement each branch.
Repository: https://github.com/EdgarEldy/spring-boot-tutorial
- Tech stack
- Data model
- Branching strategy
- Project structure
- Standard response format
- Spring AOP
- feature/core-architecture
- feature/products
- feature/customers
- feature/orders
- feature/auth
- feature/roles-permissions
- feature/token-management
- feature/user-management
- Order of work
- Code conventions
- Concepts covered
- How to follow this tutorial
| Component | Choice |
|---|---|
| Framework | Spring Boot 3.5.x (Spring Framework 6) |
| Language | Java 17 (LTS) |
| Build | Maven |
| Database | PostgreSQL 16 (via Docker Compose) |
| Migrations | Flyway |
| ORM | Spring Data JPA / Hibernate |
| DTO mapping | MapStruct + Lombok |
| Validation | Jakarta Bean Validation |
| API documentation | springdoc-openapi (Swagger UI) |
| Monitoring | Spring Boot Actuator |
| Security | Spring Security 6 + JWT (jjwt) |
| Aspect-oriented programming | Spring AOP |
| Cache | Spring Cache abstraction + Caffeine |
| Tests | JUnit 5, Mockito, Testcontainers, AssertJ |
| CI/CD | GitHub Actions |
| Containerization | Docker, docker-compose |
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)
categories
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK, auto-increment |
| category_name | VARCHAR(255) | NOT NULL |
products
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK, auto-increment |
| category_id | BIGINT | FK → categories.id, NOT NULL |
| product_name | VARCHAR(255) | NOT NULL |
| unit_price | FLOAT | NOT NULL, > 0 |
customers
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK, auto-increment |
| first_name | VARCHAR(255) | NOT NULL |
| last_name | VARCHAR(255) | NOT NULL |
| telephone | VARCHAR(50) | NOT NULL |
| VARCHAR(255) | NOT NULL, UNIQUE, valid email format | |
| address | VARCHAR(255) | NOT NULL |
orders
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK, auto-increment |
| customer_id | BIGINT | FK → customers.id, NOT NULL |
| product_id | BIGINT | FK → products.id, NOT NULL |
| quantity | INT | NOT NULL, > 0 |
| total | DOUBLE | NOT NULL, computed = quantity × product unit_price |
| Branch | Role |
|---|---|
master |
Stable, production-ready code. No direct commits only merges from develop. |
develop |
Integration branch. All feature/* branches are merged here before master. |
feature/core-architecture |
Technical foundation: project structure, configuration, baseline security, Docker, CI. |
feature/products |
Category and Product CRUD. |
feature/customers |
Customer CRUD. |
feature/orders |
Order CRUD, business logic linking products and customers. |
feature/auth |
Authentication and authorization (Spring Security + JWT). |
feature/roles-permissions |
Fine-grained Role/Permission CRUD and method-level security (@PreAuthorize, custom PermissionEvaluator). |
feature/token-management |
Activation, blacklisted and password-reset tokens; async email (@Async); scheduled cleanup (@Scheduled). |
feature/user-management |
AppUser CRUD (admin), user-role assignment (role_user join table, V7), account activation/password-reset flows. |
Each feature is developed on its own branch, then merged into develop via a documented Pull Request (even solo), to keep a clear, educational trace of each step. In practice, on this particular repository, merges have been done locally (git merge) rather than through an actual GitHub Pull Request: this section describes the intended workflow for anyone forking or following along, not necessarily every merge already present in this history.
spring-boot-tutorial/
├── src/
│ ├── main/
│ │ ├── java/com/edgareldy/springboottutorial/
│ │ │ ├── SpringBootTutorialApplication.java
│ │ │ ├── config/
│ │ │ │ ├── OpenApiConfig.java
│ │ │ │ ├── SecurityConfig.java
│ │ │ │ ├── CorsConfig.java
│ │ │ │ ├── CacheConfig.java
│ │ │ │ └── JpaAuditingConfig.java
│ │ │ ├── entity/
│ │ │ │ ├── Category.java
│ │ │ │ ├── Product.java
│ │ │ │ ├── Customer.java
│ │ │ │ ├── Order.java
│ │ │ │ └── user/
│ │ │ │ ├── AppUser.java
│ │ │ │ └── Role.java
│ │ │ ├── repository/
│ │ │ │ ├── CategoryRepository.java
│ │ │ │ ├── ProductRepository.java
│ │ │ │ ├── CustomerRepository.java
│ │ │ │ ├── OrderRepository.java
│ │ │ │ └── AppUserRepository.java
│ │ │ ├── dto/
│ │ │ │ ├── common/
│ │ │ │ │ ├── ApiResponse.java
│ │ │ │ │ └── PageResponse.java
│ │ │ │ ├── category/ (CategoryRequest, CategoryResponse)
│ │ │ │ ├── product/ (ProductRequest, ProductResponse)
│ │ │ │ ├── customer/ (CustomerRequest, CustomerResponse)
│ │ │ │ ├── order/ (OrderRequest, OrderResponse)
│ │ │ │ └── auth/ (RegisterRequest, LoginRequest, AuthResponse)
│ │ │ ├── mapper/
│ │ │ │ ├── CategoryMapper.java
│ │ │ │ ├── ProductMapper.java
│ │ │ │ ├── CustomerMapper.java
│ │ │ │ └── OrderMapper.java
│ │ │ ├── service/
│ │ │ │ ├── CategoryService.java
│ │ │ │ ├── ProductService.java
│ │ │ │ ├── CustomerService.java
│ │ │ │ ├── OrderService.java
│ │ │ │ ├── AuthService.java
│ │ │ │ └── impl/
│ │ │ │ ├── CategoryServiceImpl.java
│ │ │ │ ├── ProductServiceImpl.java
│ │ │ │ ├── CustomerServiceImpl.java
│ │ │ │ ├── OrderServiceImpl.java
│ │ │ │ └── AuthServiceImpl.java
│ │ │ ├── controller/
│ │ │ │ ├── CategoryController.java
│ │ │ │ ├── ProductController.java
│ │ │ │ ├── CustomerController.java
│ │ │ │ ├── OrderController.java
│ │ │ │ └── AuthController.java
│ │ │ ├── exception/
│ │ │ │ ├── ResourceNotFoundException.java
│ │ │ │ ├── BusinessRuleException.java
│ │ │ │ └── GlobalExceptionHandler.java
│ │ │ ├── security/
│ │ │ │ ├── JwtService.java
│ │ │ │ ├── JwtAuthFilter.java
│ │ │ │ └── UserDetailsServiceImpl.java
│ │ │ ├── event/
│ │ │ │ ├── OrderCreatedEvent.java
│ │ │ │ └── OrderCreatedEventListener.java
│ │ │ ├── aspect/
│ │ │ │ ├── LoggingAspect.java
│ │ │ │ └── ExecutionTimeAspect.java
│ │ │ └── actuator/
│ │ │ └── AppInfoEndpoint.java
│ │ └── resources/
│ │ ├── application.yml
│ │ ├── application-dev.yml
│ │ ├── application-test.yml
│ │ ├── application-prod.yml
│ │ └── db/migration/
│ │ ├── V1__init_schema.sql
│ │ └── V2__init_users_and_roles.sql
│ └── test/
│ └── java/com/edgareldy/springboottutorial/
│ ├── controller/ (MockMvc tests)
│ ├── service/ (Mockito unit tests)
│ └── repository/ (@DataJpaTest / Testcontainers)
├── docker-compose.yml
├── Dockerfile
├── .github/workflows/ci.yml
├── pom.xml
└── README.md
Every API response (success and error alike) is wrapped in a generic ApiResponse<T> DTO, defined in dto/common/ApiResponse.java, to keep a consistent contract across all endpoints.
public record ApiResponse<T>(
boolean success,
String message,
T data,
Instant timestamp
) {
public static <T> ApiResponse<T> success(T data, String message) {
return new ApiResponse<>(true, message, data, Instant.now());
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, message, null, Instant.now());
}
}- On list endpoints,
dataholds aPageResponse<T>(paginated content:content,page,size,totalElements,totalPages) instead of a plainList<T>. - On error,
GlobalExceptionHandlerreturns anApiResponse<ProblemDetail>withsuccess=false:dataholds a standard RFC 7807ProblemDetail(Spring 6's built-in class,org.springframework.http.ProblemDetail), not a project-specific error DTO. Field validation errors are attached as afieldErrorsextension property. - Every DTO field is exposed in the JSON contract as snake_case (e.g.
product_name,unit_price,category_id), decoupled from the Java field's own camelCase name via an explicit@JsonPropertyon each record component. Controllers returnResponseEntity<ApiResponse<T>>, notApiResponse<T>directly:ApiResponse<T>still wraps every response body,ResponseEntityonly adds control over the HTTP status code alongside it. - Success response example:
{
"success": true,
"message": "Product created successfully",
"data": { "id": 12, "product_name": "Mechanical keyboard", "unit_price": 79.99, "category_id": 3 },
"timestamp": "2026-07-02T10:15:30Z"
}- Error response example:
{
"success": false,
"message": "Product not found",
"data": {
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Product not found",
"instance": "/api/v1/products/99"
},
"timestamp": "2026-07-02T10:16:05Z"
}The project uses Spring AOP to illustrate aspect-oriented programming, kept out of any business logic in services/controllers.
- Dependency:
spring-boot-starter-aop LoggingAspect(aspect/LoggingAspect.java):@Aroundadvice on all@Servicebeans (pointcutexecution(* com.edgareldy.springboottutorial.service..*(..))), logs method entry/exit, arguments, and thrown exceptionsExecutionTimeAspect(aspect/ExecutionTimeAspect.java):@Aroundadvice on controller methods, measures and logs the execution time of each HTTP requestRepositoryAuditAspect(aspect/RepositoryAuditAspect.java): on the repository layer, the four advice types the other two aspects don't use:@Before/@Afteraround every repository call,@AfterReturningonsavespecifically,@AfterThrowingon any repository exception- Can later be reused for auditing (e.g. tracing who created/modified an order) without polluting business code
Technical foundation shared by the whole project, to be merged first into develop. Originally named feature/config; renamed to better reflect that it lays out the whole architectural skeleton (config, cross-cutting concerns, containerization, CI), not just configuration files.
- Initialize the project via Spring Initializr (Maven, Java 17, Spring Boot 3.5.16)
- Dependencies:
spring-boot-starter-web,spring-boot-starter-data-jpa,spring-boot-starter-validation,spring-boot-starter-actuator,spring-boot-starter-security,spring-boot-starter-aop,spring-boot-starter-mail,spring-boot-devtools,flyway-core,flyway-database-postgresql,postgresqldriver,lombok,mapstruct+mapstruct-processor,springdoc-openapi-starter-webmvc-ui,jjwt-api/jjwt-impl/jjwt-jackson - Test dependencies:
spring-boot-starter-test,spring-security-test,testcontainers(junit-jupiter, postgresql) - Create the package tree shown above
-
application.yml: shared configuration (app name, port, JSON date format) -
application-dev.yml: local datasource,spring.jpa.show-sql=true, Flyway enabled,spring.mailpointing to MailHog (localhost:1025) -
application-test.yml: Testcontainers datasource -
application-prod.yml: datasource via environment variables, JSON logs,spring.mailvia env vars (MAIL_HOST,MAIL_PORT,MAIL_USERNAME,MAIL_PASSWORD) - Flyway script
V1__init_schema.sql(categories, products, customers, orders tables with FKs) -
InvalidTokenException: custom unchecked exception for expired or invalid tokens (used byfeature/token-management) -
GlobalExceptionHandler(@RestControllerAdvice): handlesResourceNotFoundException(404),MethodArgumentNotValidException(400, field details),BusinessRuleException(422),InvalidTokenException(400),BadCredentialsException(401),LockedException(401),DisabledException(401),AccessDeniedException(403),AuthenticationException(401),ObjectOptimisticLockingFailureException(409), genericException(500) - Error payload as a standard RFC 7807
ProblemDetail(org.springframework.http.ProblemDetail):type,title,status,detail,instance, plus afieldErrorsextension property on validation failures - Generic
ApiResponse<T>(dto/common/ApiResponse.java) andPageResponse<T>(dto/common/PageResponse.java) DTOs, used to wrap every controller response -
LoggingAspectandExecutionTimeAspect(aspect/): logging and execution-time measurement via Spring AOP (see Spring AOP);LoggingAspectuses reflection-based masking to redact fields annotated@SensitiveFieldbefore logging arguments and return values -
OpenApiConfig: title, description, version, JWT security scheme in Swagger UI - Actuator: expose
health,info,metricsin dev (plus a customappinfoendpoint, seeAppInfoEndpoint);healthonly in prod - Structured logging (
logback-spring.xmland Actuator ECS/JSON config, combined) -
CorsConfig: allowlocalhost:4200in dev; allowed methods includePATCH -
AsyncConfig:@EnableAsyncwith a dedicatedThreadPoolTaskExecutorfor email-sending tasks -
SchedulingConfig:@EnableSchedulingto activate@Scheduledtasks (used byfeature/token-management) -
JpaAuditingConfig(@EnableJpaAuditing): native Spring Data JPA auditing support, consumed byCategoryonfeature/products -
spring-boot-devtools: automatic restart and other dev-only conveniences, disabled outside local runs - Multi-stage
Dockerfile(Maven build + lightweight JRE image) -
docker-compose.yml:appservice +dbservice (PostgreSQL 16) +mailhogservice (local SMTP for email testing) with volumes and environment variables read from.env -
.env.example: template listing all required environment variables includingMAIL_HOST,MAIL_PORT,MAIL_USERNAME,MAIL_PASSWORD -
.github/workflows/ci.yml: Maven build + tests on every push/PR - Branch
READMEexplaining the configuration choices (see below)
- Spring Boot 3.5.16, not 4.1.x. The project was first scoped around Spring Boot 4.1 / Spring Framework 7, but springdoc-openapi had no release compatible with Spring Framework 7 on Maven Central at implementation time. Spring Boot 3.5.16 (Spring Framework 6) is fully compatible with every required dependency (springdoc, MapStruct, jjwt) and is still an actively maintained line, so it was chosen instead.
dependencyManagementexplicitly importsspring-boot-dependenciesas${project.parent.version}, in addition to inheriting it via<parent>. This is redundant (the parent POM already provides the same bill of materials) but was requested explicitly to make version management visible directly independencyManagement.hibernate.ddl-autoisvalidatein every profile, neverupdateorcreate. Flyway'sV1__init_schema.sqlis the single source of truth for the schema; Hibernate only checks that entity mappings agree with it. This avoids the classic drift where a forgotten entity annotation silently alters the schema in one environment but not another.- Structured logging combines two mechanisms on purpose.
logback-spring.xmlincludes Spring Boot's own default appenders (defaults.xml,console-appender.xml) instead of redefining them, and adds a prod-onlyfile-appender.xmlinclude via<springProfile name="prod">. The actual JSON formatting comes from Spring Boot 3.4+'s nativelogging.structured.format.console/.fileproperties (set toecsinapplication-prod.yml), so no extra dependency (e.g.logstash-logback-encoder) or hand-written JSON encoder was needed. CorsConfigis annotated@Profile("dev"). Only the local Angular dev server (localhost:4200) gets a CORS exemption; prod is expected to declare its own, narrower policy once a real frontend origin exists.- Testcontainers only, no H2.
application-test.ymldeclares no datasource at all:TestcontainersConfiguration's@ServiceConnectionPostgreSQLContainerbean wires the datasource automatically. Using real PostgreSQL in tests (instead of an in-memory H2 database) avoids behavioral differences between the two engines (types, constraints, SQL dialect) that would otherwise only surface in production. GlobalExceptionHandlerreturnsApiResponse<ProblemDetail>, not a project-specific error DTO.ProblemDetailis Spring 6's built-in RFC 7807 type; using it directly asApiResponse<T>'s type parameter demonstrates the standard without abandoning the project's own envelope, sinceApiResponse<T>still wraps every response (success and error alike). Only the shape ofdataon errors changed; every success response (ApiResponse<CategoryResponse>,ApiResponse<PageResponse<ProductResponse>>, etc.) is untouched.
Includes Category and Product, given their direct link in the model.
| Method | URL | Description |
|---|---|---|
| GET | /api/v1/categories |
Paginated list of categories |
| GET | /api/v1/categories/{id} |
Category detail, including its associated products |
| POST | /api/v1/categories |
Create a category |
| PUT | /api/v1/categories/{id} |
Update a category |
| DELETE | /api/v1/categories/{id} |
Delete a category |
| GET | /api/v1/products |
Paginated list, filterable by categoryId, productName (contains), minPrice/maxPrice, sortable by productName/unitPrice |
| GET | /api/v1/products/{id} |
Product detail |
| POST | /api/v1/products |
Create a product |
| PUT | /api/v1/products/{id} |
Update a product |
| DELETE | /api/v1/products/{id} |
Delete a product |
-
Category,Productentities with@OneToMany/@ManyToOnerelation (LAZYloading) -
CategoryRepository,ProductRepository(Spring Data JPA) with derived queries (findByCategoryId) and one@QueryusingJOIN FETCH - DTOs
CategoryRequest/CategoryResponse,ProductRequest/ProductResponse - Corresponding MapStruct mappers
-
CategoryService,ProductServiceinterfaces (contracts) and theirCategoryServiceImpl,ProductServiceImplimplementations inservice/impl, annotated@Service, with@Transactionalon writes - Business rule: a category containing products cannot be deleted (
BusinessRuleException) -
CategoryController,ProductController: REST CRUD + pagination (Pageable) + sorting - OpenAPI documentation (
@Operation,@ApiResponse) on every endpoint - Service unit tests (Mockito)
-
@DataJpaTestrepository tests - MockMvc controller integration tests
- Optimistic locking (
@Version) onProduct, withOptimisticLockingFailureExceptionmapped to 409 - Dynamic filtering (
Specification/JpaSpecificationExecutor) onProduct:productName/minPrice/maxPrice, in addition tocategoryId - Native JPA auditing (
@CreatedDate/@LastModifiedDate) onCategory, viafeature/core-architecture'sJpaAuditingConfig - Controllers return
ResponseEntity<ApiResponse<T>>; every DTO field is exposed in JSON as snake_case via@JsonProperty -
CategoryResponse.products(the category's associatedProducts) on the detail endpoint only
CategoryResponse.productsis only populated byGET /api/v1/categories/{id}, never by the paginated list.CategoryRepository.findByIdWithProducts(LEFT JOIN FETCH) backs the detail endpoint; the list endpoint'sCategoryMapper.toResponseexplicitly setsproductsto an empty list rather than touching the entity's lazy, unfetched collection, avoiding an N+1 across a full page of categories.- Snake_case JSON via
@JsonPropertyper field, not a global Jackson naming strategy. Keeps every Java field/record component camelCase (idiomatic, and MapStruct keeps matching DTO fields to entity fields by name automatically) while the public JSON contract is snake_case. - Flyway migrations on this branch are numbered
V3/V4, skippingV2.V2is reserved forfeature/auth'sV2__init_users_and_roles.sql, not present on this branch yet; Flyway version numbers must stay unique and ordered across the whole project regardless of merge order. ProductServiceImpl.findAllonly falls back to aSpecification-based query whenproductName/minPrice/maxPriceis supplied. The plain andcategoryId-only cases keep using the existing@EntityGraph-backed repository methods; theSpecificationpath has no fetch join forcategory, an accepted N+1 risk kept simple rather than complicatingProductSpecificationswith one, and scoped only to requests that actually use the new filters.
| Method | URL | Description |
|---|---|---|
| GET | /api/v1/customers |
Paginated list, search by name (?search=) |
| GET | /api/v1/customers/{id} |
Customer detail |
| POST | /api/v1/customers |
Create a customer |
| PUT | /api/v1/customers/{id} |
Update a customer |
| DELETE | /api/v1/customers/{id} |
Delete a customer |
-
Customerentity -
CustomerRepositorywith search (findByEmail, derived query on first/last name) - DTOs
CustomerRequest/CustomerResponsewith validation (valid email, phone format) -
CustomerMapper -
CustomerServiceinterface andCustomerServiceImplimplementation (inservice/impl): email uniqueness check on create and update -
CustomerController - Unit and integration tests
-
CustomerControllerreturnsResponseEntity<ApiResponse<T>>;CustomerRequest/CustomerResponsefields exposed in JSON as snake_case via@JsonProperty
CustomerResponsedoes not carry the customer's ordered products on this branch.Order(and therefore any customer-to-product link) does not exist yet here; it is introduced byfeature/orders, which is where that association becomes queryable and where this is implemented instead.
OrderResponseembeds the fullCustomerResponse/ProductResponseDTOs (customer,product) instead of summarized sub-objects, on both the detail endpoint (OrderRepository.findByIdWithDetails, entity fetch join) and the paginated list (OrderRepository.findAllProjected, a flat JPQL constructor-expression projection extended with every column both DTOs need, still avoiding N+1 on a page of results).CustomerResponse.findById(GET /api/v1/customers/{id}) now also returns theproductsthat customer has ordered, resolved via a dedicatedOrderRepository.findDistinctProductsByCustomerIdquery rather than a new JPA relationship onCustomer/Product;findAllkeeps returning an emptyproductslist per customer to avoid the same N+1 cost on a page.OrderControllerreturnsResponseEntity<ApiResponse<T>>;OrderRequest/OrderResponsefields exposed in JSON as snake_case via@JsonProperty(customer_id,product_id).
| Method | URL | Description |
|---|---|---|
| GET | /api/v1/orders |
Paginated list, filterable by customerId/productId |
| GET | /api/v1/orders/{id} |
Order detail |
| POST | /api/v1/orders |
Create an order (total computed automatically) |
| PUT | /api/v1/orders/{id} |
Update an order |
| DELETE | /api/v1/orders/{id} |
Delete an order |
| GET | /api/v1/customers/{id}/orders |
Orders for a given customer |
-
Orderentity with@ManyToOnetoCustomerandProduct -
OrderRepositorywith join queries (@QuerywithJOIN), DTO projection for lists - DTOs
OrderRequest(customerId, productId, quantity) /OrderResponse(fullCustomerResponse/ProductResponse, not summarized sub-objects) -
OrderMapper -
OrderServiceinterface andOrderServiceImplimplementation (inservice/impl): computestotal = quantity * product.unitPrice, checks that the customer and product exist -
OrderCreatedEventapplication event published after creation, consumed byOrderCreatedEventListener(e.g. business logging, future email notification) -
OrderControllerreturnsResponseEntity<ApiResponse<T>> -
CustomerResponse.findByIdreturns the customer's ordered products (see Response shapes above) - Unit tests (total computation, business rules) and integration tests
AppUserentity (id, username, email, hashed password, roles)Roleenum (ADMIN,USER)- Migration
V2__init_users_and_roles.sql
| Method | URL | Description | Access |
|---|---|---|---|
| POST | /api/v1/auth/register |
Sign up | Public |
| POST | /api/v1/auth/login |
Sign in, returns a JWT | Public |
| GET | /api/v1/auth/me |
Current user profile | Authenticated |
| Resource | GET | POST/PUT/DELETE |
|---|---|---|
| categories, products | Public | ADMIN |
| customers, orders | Authenticated | ADMIN |
-
AppUser,Role,AppUserRepository -
JwtService(generation, validation, claims extraction) -
JwtAuthFilter(OncePerRequestFilter) -
UserDetailsServiceImpl -
SecurityConfig(SecurityFilterChain, BCryptPasswordEncoder, per-endpoint authorization rules, CSRF disabled for a stateless API,STATELESSsession) -
AuthServiceinterface andAuthServiceImplimplementation (inservice/impl),AuthController(register, login, me) - Custom 401/403 error handling (
AuthenticationEntryPoint,AccessDeniedHandler) - Swagger updated to support the "Authorize" button (Bearer token)
- Integration tests with
spring-security-test(@WithMockUser, allowed/denied access tests) -
AuthControllerreturnsResponseEntity<ApiResponse<T>>;AuthResponse.tokenTypeexposed in JSON astoken_typevia@JsonProperty(the other auth DTO fields,username/email/password/token, are single words already identical in both conventions)
Fine-grained access control: replaces the Role enum from feature/auth with a full JPA entity hierarchy (Role, Permission), CRUD endpoints for both, and method-level security via @PreAuthorize + a custom PermissionEvaluator.
Permissionentity (id,resource,action) - migrationV5Roleentity (id,name) with a@ManyToManytoPermissionrole_permissionjoin table
All endpoints are ADMIN-only (@PreAuthorize("hasRole('ADMIN')") at class level).
| Method | URL | Description |
|---|---|---|
| GET | /api/v1/permissions |
Paginated list of permissions |
| POST | /api/v1/permissions |
Create a permission |
| DELETE | /api/v1/permissions/{id} |
Delete a permission |
| GET | /api/v1/roles |
Paginated list of roles (with permissions) |
| POST | /api/v1/roles |
Create a role |
| PUT | /api/v1/roles/{id} |
Update a role name |
| DELETE | /api/v1/roles/{id} |
Delete a role |
| POST | /api/v1/roles/{id}/permissions/{permissionId} |
Assign a permission to a role |
| DELETE | /api/v1/roles/{id}/permissions/{permissionId} |
Remove a permission from a role |
-
Permissionentity andPermissionRepository -
Roleentity (JPA, replaces enum) andRoleRepositorywith@EntityGraphon paginatedfindAllto avoid N+1 on permissions - Migration
V5__roles_permissions.sql - DTOs
PermissionRequest/PermissionResponse,RoleRequest/RoleResponse -
PermissionMapper,RoleMapper -
PermissionService/PermissionServiceImplandRoleService/RoleServiceImpl -
PermissionControllerandRoleController(ADMIN-only) -
MethodSecurityConfig(@EnableMethodSecurity, staticMethodSecurityExpressionHandlerbean to avoid circular dependency) -
CustomPermissionEvaluator(PermissionEvaluator): evaluateshasPermission('RESOURCE', 'ACTION')againstPERMISSION_RESOURCE:ACTIONauthorities stored on the user - Service, repository, and controller tests
Token lifecycle management for account activation and password reset, plus async email delivery and scheduled cleanup.
ActivationTokenentity (id,user,token,createdAt,expiresAt,validatedAt) - migrationV6; 24-hour TTL;validatedAtmarks a used token (not deleted, auditable);findByTokencarriesPESSIMISTIC_WRITElock to prevent double-use racesBlacklistedTokenentity (id,user,token,jti,blacklistedAt,createdAt,expiresAt,validatedAt) for JWT revocation; revocation check viaexistsByJti(no lock needed, read-only)PasswordResetTokenentity (id,user,token,type,expiryDate) - 1-hour TTL; consumed by deletion (single-use: token is deleted before expiry is checked, so it is never reusable even if validation fails)
-
ActivationToken,BlacklistedToken,PasswordResetTokenentities and repositories (packageentity/token,repository) - Migration
V6__init_token_tables.sql(activation_tokens, password_reset_tokens, blacklisted_tokens with FK to app_users, indexes on user_id) -
SecureTokenGenerator(@Component,SecureRandom, 32 bytes Base64 URL-safe without padding) -
EmailServiceinterface andEmailServiceImpl(@Async("emailTaskExecutor")withJavaMailSender; uses executor fromAsyncConfiginfeature/core-architecture;app.base-urlproperty for link generation) -
ActivationTokenService/ActivationTokenServiceImpl,BlacklistedTokenService/BlacklistedTokenServiceImpl,PasswordResetTokenService/PasswordResetTokenServiceImpl -
TokenCleanupScheduler(@Scheduled(cron = "0 0 3 * * *")daily at 3 AM; three separate@Transactionalmethods, one per table; usesSchedulingConfigfromfeature/core-architecture) -
InvalidTokenExceptionhandler already present inGlobalExceptionHandler(wired byfeature/core-architecture) - MailHog service added to
docker-compose.yml;application-dev.ymlpoints tolocalhost:1025;application-prod.ymlreadsMAIL_HOST/MAIL_PORT/MAIL_USERNAME/MAIL_PASSWORDfrom env;.env.exampleupdated withMAIL_*andAPP_BASE_URL - Unit tests:
ActivationTokenServiceImplTest,BlacklistedTokenServiceImplTest,PasswordResetTokenServiceImplTest,EmailServiceImplTest,SecureTokenGeneratorTest,TokenCleanupSchedulerTest - Repository tests (Testcontainers):
ActivationTokenRepositoryTest,BlacklistedTokenRepositoryTest,PasswordResetTokenRepositoryTest - Fixed
@ActiveProfiles("test")on all three@SpringBootTestfull-context classes (RepositoryAuditAspectTest,SpringBootTutorialApplicationTests,SecurityAuthorizationTest) soapplication-test.yml(which stubsspring.mail.host) is loaded andJavaMailSenderis auto-configured -
JwtService: addedjticlaim (random UUID per token via.id(UUID.randomUUID().toString())),extractJti(String token), andextractExpiration(String token); removedisTokenValid()(replaced byJwtExceptionin filter + blacklist check) -
AuthService.logout(String rawToken)contract added to the interface -
AuthServiceImpl.logout(): extracts jti/username/expiration from token, loadsAppUser, delegates toBlacklistedTokenService.blacklist() -
AuthController:POST /api/v1/auth/logoutstripsAuthorization: Bearer <token>header and callsauthService.logout() -
JwtAuthFilter: injectsBlacklistedTokenService; checksisBlacklisted(jti)before populating theSecurityContext; catchesDataAccessExceptionalongsideJwtExceptionfor fail-open behaviour on DB outages - Tests:
JwtServiceTestrewritten for jti model;AuthServiceImplTestextended with@Mock BlacklistedTokenService+ twologout()tests;AuthControllerTestgains_08_/_09_forPOST /logout;SecurityAuthorizationTest._16_exercises the full blacklist lifecycle (login -> GET 200 -> logout -> GET 401); all@WebMvcTestslices updated with@MockitoBean BlacklistedTokenService
Admin-facing user management: list, view, activate/deactivate accounts, assign roles; plus account activation and password-reset flows driven by email tokens.
AppUsergains a@ManyToManywithRole(entity) viarole_userjoin table, migrationV7
| Method | URL | Description | Access |
|---|---|---|---|
| GET | /api/v1/users |
Paginated list of users | ADMIN |
| GET | /api/v1/users/{id} |
User detail | ADMIN |
| PUT | /api/v1/users/{id}/roles |
Assign roles to a user | ADMIN |
| DELETE | /api/v1/users/{id} |
Delete a user | ADMIN |
| GET | /api/v1/auth/activate |
Activate account with token | Public |
| POST | /api/v1/auth/forgot-password |
Request a password-reset email | Public |
| POST | /api/v1/auth/reset-password |
Reset password with token | Public |
- Migration
V7__role_user.sql(role_userjoin table, seeds ADMIN/USER roles, migrates enum data) -
AppUserrefactored from@Enumerated Roleto@ManyToMany Set<Role>+boolean enabled -
UserDetailsServiceImplmaps roles toROLE_prefixed authorities, propagatesdisabled(!enabled) -
AppUserRepository.findByUsernameannotated@EntityGraph(attributePaths="roles")to avoidLazyInitializationExceptioninJwtAuthFilter -
AuthServiceImpl.register()setsenabled=false, loads USER role from DB, sends activation email -
UserController(ADMIN-only,@PreAuthorizeat class level) - Account activation flow (register -> activation email ->
GET /auth/activate?token=) - Password-reset flow (forgot-password -> email ->
POST /auth/reset-password) -
UserResponseexposesaccount_lockedfield so the Angular users list can show lock status on initial load - Unit and integration tests (service, repository, controller, security authorization)
feature/core-architecture→ Pull Request todevelopfeature/products(depends oncore-architecture) → Pull Request todevelopfeature/customers→ Pull Request todevelopfeature/orders(depends onproductsandcustomers) → Pull Request todevelopfeature/auth(secures everything) → Pull Request todevelopfeature/roles-permissions(fine-grained RBAC) → Pull Request todevelopfeature/token-management(token lifecycle, async email, scheduled cleanup) → Pull Request todevelopfeature/user-management(admin user CRUD, activation/reset flows) → Pull Request todevelopdevelop→masteronce everything is tested and validated
- Root package:
com.edgareldy.springboottutorial - DTOs: Java
recordrather than classes (immutability, less boilerplate) - No business logic in controllers: delegate to the service layer only
- Contract/implementation services: the interface (
XxxService) lives at the root ofservice/, its implementation (XxxServiceImpl) lives inservice/impl/. Controllers and tests only depend on the interface (injected by interface type), never directly on the implementation. - Any service method that writes to the database is annotated
@Transactional(on the implementation) - Every controller returns a
ResponseEntity<ApiResponse<T>>(see Standard response format), includingGlobalExceptionHandlerfor errors - DTO fields are exposed in JSON as snake_case via
@JsonPropertyon each record component, kept camelCase in Java so mappers and services stay idiomatic - List responses are always paginated (
Page<T>→PageResponse<T>DTO) - Endpoint names are plural,
kebab-casewhen composed - Every endpoint documented with
@Operation(summary = "...") - All REST endpoints are versioned under the
/api/v1prefix (e.g./api/v1/categories), so the API can introduce a breaking/api/v2later without touching existing clients - Test naming convention: every test method is named
_NN_Should<Outcome>_When<Condition>, whereNNis a two-digit sequence number with a leading zero, restarting at_01_in each test class and following the order of the methods in the source (e.g._01_ShouldReturnCategory_WhenCategoryExists,_02_ShouldReturnEmpty_WhenCategoryDoesNotExist). This applies to every kind of test (unit, repository, controller, integration, parameterized). No other style (shouldX(),testX(),givenX_whenY_thenZ()) is accepted.
- Layered architecture (controller / service / repository)
- Spring Data JPA: relations, derived queries,
@Query, pagination, sorting, projections - DTOs and mapping (MapStruct)
- Validation (Bean Validation)
- Centralized exception handling (
ProblemDetail, RFC 7807) - Transactions (
@Transactional) - Optimistic locking (
@Version) - Dynamic queries (
Specification/JpaSpecificationExecutor) - Native JPA auditing (
@CreatedDate/@LastModifiedDate) - Schema migrations (Flyway)
- API documentation (OpenAPI / Swagger UI)
- Observability (Actuator, including a custom endpoint; structured logging)
- Security (Spring Security, JWT, role-based authorization)
- Method-level security (
@PreAuthorize,@EnableMethodSecurity, customPermissionEvaluator) - Async processing (
@Async,@EnableAsync,ThreadPoolTaskExecutor) - Scheduled tasks (
@Scheduled,@EnableScheduling) - Email sending (
JavaMailSender, MailHog for local testing) - Testing (unit, integration, Testcontainers)
- Multi-environment configuration (Spring profiles)
@ConfigurationProperties- Application cache (
@Cacheable) - Data seeding (
CommandLineRunner) - Application events (
ApplicationEventPublisher) - Aspect-oriented programming (Spring AOP:
@Around,@Before,@After,@AfterReturning,@AfterThrowing) - Local development tooling (Spring Boot DevTools)
- Containerization (Docker, docker-compose)
- Continuous integration (GitHub Actions)
- Clone the repository and check out
develop - Create/checkout the
feature/core-architecturebranch and follow its task checklist - Continue with
feature/products,feature/customers,feature/orders,feature/auth,feature/roles-permissions,feature/token-management,feature/user-managementin that order - Open a Pull Request to
developat the end of each branch - Copy
.env.exampleto.envand fill in the required variables - Run the project with
docker-compose up, then open Swagger UI athttp://localhost:8080/swagger-ui.html - MailHog UI is available at
http://localhost:8025to inspect outgoing emails in local dev