Skip to content

About

Spring Boot 3.5.x CRUD REST API: JPA/Hibernate, Flyway, MapStruct, JWT auth, method-level security (RBAC), Spring AOP, Caffeine cache, async email, scheduled cleanup, Swagger UI and GitHub Actions CI

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Spring Boot Tutorial

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

Table of contents

Tech stack

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

Data model

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)

Column details

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
email 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

Branching strategy

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.

Project structure

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

Standard response format

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, data holds a PageResponse<T> (paginated content: content, page, size, totalElements, totalPages) instead of a plain List<T>.
  • On error, GlobalExceptionHandler returns an ApiResponse<ProblemDetail> with success=false: data holds a standard RFC 7807 ProblemDetail (Spring 6's built-in class, org.springframework.http.ProblemDetail), not a project-specific error DTO. Field validation errors are attached as a fieldErrors extension 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 @JsonProperty on each record component. Controllers return ResponseEntity<ApiResponse<T>>, not ApiResponse<T> directly: ApiResponse<T> still wraps every response body, ResponseEntity only 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"
}

Spring AOP

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): @Around advice on all @Service beans (pointcut execution(* com.edgareldy.springboottutorial.service..*(..))), logs method entry/exit, arguments, and thrown exceptions
  • ExecutionTimeAspect (aspect/ExecutionTimeAspect.java): @Around advice on controller methods, measures and logs the execution time of each HTTP request
  • RepositoryAuditAspect (aspect/RepositoryAuditAspect.java): on the repository layer, the four advice types the other two aspects don't use: @Before/@After around every repository call, @AfterReturning on save specifically, @AfterThrowing on any repository exception
  • Can later be reused for auditing (e.g. tracing who created/modified an order) without polluting business code

feature/core-architecture

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.

Tasks

  • 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, postgresql driver, 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.mail pointing to MailHog (localhost:1025)
  • application-test.yml: Testcontainers datasource
  • application-prod.yml: datasource via environment variables, JSON logs, spring.mail via 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 by feature/token-management)
  • GlobalExceptionHandler (@RestControllerAdvice): handles ResourceNotFoundException (404), MethodArgumentNotValidException (400, field details), BusinessRuleException (422), InvalidTokenException (400), BadCredentialsException (401), LockedException (401), DisabledException (401), AccessDeniedException (403), AuthenticationException (401), ObjectOptimisticLockingFailureException (409), generic Exception (500)
  • Error payload as a standard RFC 7807 ProblemDetail (org.springframework.http.ProblemDetail): type, title, status, detail, instance, plus a fieldErrors extension property on validation failures
  • Generic ApiResponse<T> (dto/common/ApiResponse.java) and PageResponse<T> (dto/common/PageResponse.java) DTOs, used to wrap every controller response
  • LoggingAspect and ExecutionTimeAspect (aspect/): logging and execution-time measurement via Spring AOP (see Spring AOP); LoggingAspect uses reflection-based masking to redact fields annotated @SensitiveField before logging arguments and return values
  • OpenApiConfig: title, description, version, JWT security scheme in Swagger UI
  • Actuator: expose health, info, metrics in dev (plus a custom appinfo endpoint, see AppInfoEndpoint); health only in prod
  • Structured logging (logback-spring.xml and Actuator ECS/JSON config, combined)
  • CorsConfig: allow localhost:4200 in dev; allowed methods include PATCH
  • AsyncConfig: @EnableAsync with a dedicated ThreadPoolTaskExecutor for email-sending tasks
  • SchedulingConfig: @EnableScheduling to activate @Scheduled tasks (used by feature/token-management)
  • JpaAuditingConfig (@EnableJpaAuditing): native Spring Data JPA auditing support, consumed by Category on feature/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: app service + db service (PostgreSQL 16) + mailhog service (local SMTP for email testing) with volumes and environment variables read from .env
  • .env.example: template listing all required environment variables including MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD
  • .github/workflows/ci.yml: Maven build + tests on every push/PR
  • Branch README explaining the configuration choices (see below)

Configuration notes

  • 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.
  • dependencyManagement explicitly imports spring-boot-dependencies as ${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 in dependencyManagement.
  • hibernate.ddl-auto is validate in every profile, never update or create. Flyway's V1__init_schema.sql is 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.xml includes Spring Boot's own default appenders (defaults.xml, console-appender.xml) instead of redefining them, and adds a prod-only file-appender.xml include via <springProfile name="prod">. The actual JSON formatting comes from Spring Boot 3.4+'s native logging.structured.format.console/.file properties (set to ecs in application-prod.yml), so no extra dependency (e.g. logstash-logback-encoder) or hand-written JSON encoder was needed.
  • CorsConfig is 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.yml declares no datasource at all: TestcontainersConfiguration's @ServiceConnection PostgreSQLContainer bean 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.
  • GlobalExceptionHandler returns ApiResponse<ProblemDetail>, not a project-specific error DTO. ProblemDetail is Spring 6's built-in RFC 7807 type; using it directly as ApiResponse<T>'s type parameter demonstrates the standard without abandoning the project's own envelope, since ApiResponse<T> still wraps every response (success and error alike). Only the shape of data on errors changed; every success response (ApiResponse<CategoryResponse>, ApiResponse<PageResponse<ProductResponse>>, etc.) is untouched.

feature/products

Includes Category and Product, given their direct link in the model.

Endpoints

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

Tasks

  • Category, Product entities with @OneToMany/@ManyToOne relation (LAZY loading)
  • CategoryRepository, ProductRepository (Spring Data JPA) with derived queries (findByCategoryId) and one @Query using JOIN FETCH
  • DTOs CategoryRequest/CategoryResponse, ProductRequest/ProductResponse
  • Corresponding MapStruct mappers
  • CategoryService, ProductService interfaces (contracts) and their CategoryServiceImpl, ProductServiceImpl implementations in service/impl, annotated @Service, with @Transactional on 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)
  • @DataJpaTest repository tests
  • MockMvc controller integration tests
  • Optimistic locking (@Version) on Product, with OptimisticLockingFailureException mapped to 409
  • Dynamic filtering (Specification/JpaSpecificationExecutor) on Product: productName/minPrice/maxPrice, in addition to categoryId
  • Native JPA auditing (@CreatedDate/@LastModifiedDate) on Category, via feature/core-architecture's JpaAuditingConfig
  • Controllers return ResponseEntity<ApiResponse<T>>; every DTO field is exposed in JSON as snake_case via @JsonProperty
  • CategoryResponse.products (the category's associated Products) on the detail endpoint only

Configuration notes

  • CategoryResponse.products is only populated by GET /api/v1/categories/{id}, never by the paginated list. CategoryRepository.findByIdWithProducts (LEFT JOIN FETCH) backs the detail endpoint; the list endpoint's CategoryMapper.toResponse explicitly sets products to 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 @JsonProperty per 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, skipping V2. V2 is reserved for feature/auth's V2__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.findAll only falls back to a Specification-based query when productName/minPrice/maxPrice is supplied. The plain and categoryId-only cases keep using the existing @EntityGraph-backed repository methods; the Specification path has no fetch join for category, an accepted N+1 risk kept simple rather than complicating ProductSpecifications with one, and scoped only to requests that actually use the new filters.

feature/customers

Endpoints

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

Tasks

  • Customer entity
  • CustomerRepository with search (findByEmail, derived query on first/last name)
  • DTOs CustomerRequest/CustomerResponse with validation (valid email, phone format)
  • CustomerMapper
  • CustomerService interface and CustomerServiceImpl implementation (in service/impl): email uniqueness check on create and update
  • CustomerController
  • Unit and integration tests
  • CustomerController returns ResponseEntity<ApiResponse<T>>; CustomerRequest/CustomerResponse fields exposed in JSON as snake_case via @JsonProperty

Configuration notes

  • CustomerResponse does 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 by feature/orders, which is where that association becomes queryable and where this is implemented instead.

feature/orders

Response shapes

  • OrderResponse embeds the full CustomerResponse/ProductResponse DTOs (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 the products that customer has ordered, resolved via a dedicated OrderRepository.findDistinctProductsByCustomerId query rather than a new JPA relationship on Customer/Product; findAll keeps returning an empty products list per customer to avoid the same N+1 cost on a page.
  • OrderController returns ResponseEntity<ApiResponse<T>>; OrderRequest/OrderResponse fields exposed in JSON as snake_case via @JsonProperty (customer_id, product_id).

Endpoints

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

Tasks

  • Order entity with @ManyToOne to Customer and Product
  • OrderRepository with join queries (@Query with JOIN), DTO projection for lists
  • DTOs OrderRequest (customerId, productId, quantity) / OrderResponse (full CustomerResponse/ProductResponse, not summarized sub-objects)
  • OrderMapper
  • OrderService interface and OrderServiceImpl implementation (in service/impl): computes total = quantity * product.unitPrice, checks that the customer and product exist
  • OrderCreatedEvent application event published after creation, consumed by OrderCreatedEventListener (e.g. business logging, future email notification)
  • OrderController returns ResponseEntity<ApiResponse<T>>
  • CustomerResponse.findById returns the customer's ordered products (see Response shapes above)
  • Unit tests (total computation, business rules) and integration tests

feature/auth

Model

  • AppUser entity (id, username, email, hashed password, roles)
  • Role enum (ADMIN, USER)
  • Migration V2__init_users_and_roles.sql

Endpoints

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

Authorization rules

Resource GET POST/PUT/DELETE
categories, products Public ADMIN
customers, orders Authenticated ADMIN

Tasks

  • AppUser, Role, AppUserRepository
  • JwtService (generation, validation, claims extraction)
  • JwtAuthFilter (OncePerRequestFilter)
  • UserDetailsServiceImpl
  • SecurityConfig (SecurityFilterChain, BCrypt PasswordEncoder, per-endpoint authorization rules, CSRF disabled for a stateless API, STATELESS session)
  • AuthService interface and AuthServiceImpl implementation (in service/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)
  • AuthController returns ResponseEntity<ApiResponse<T>>; AuthResponse.tokenType exposed in JSON as token_type via @JsonProperty (the other auth DTO fields, username/email/password/token, are single words already identical in both conventions)

feature/roles-permissions

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.

Model

  • Permission entity (id, resource, action) - migration V5
  • Role entity (id, name) with a @ManyToMany to Permission
  • role_permission join table

Endpoints

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

Tasks

  • Permission entity and PermissionRepository
  • Role entity (JPA, replaces enum) and RoleRepository with @EntityGraph on paginated findAll to avoid N+1 on permissions
  • Migration V5__roles_permissions.sql
  • DTOs PermissionRequest/PermissionResponse, RoleRequest/RoleResponse
  • PermissionMapper, RoleMapper
  • PermissionService / PermissionServiceImpl and RoleService / RoleServiceImpl
  • PermissionController and RoleController (ADMIN-only)
  • MethodSecurityConfig (@EnableMethodSecurity, static MethodSecurityExpressionHandler bean to avoid circular dependency)
  • CustomPermissionEvaluator (PermissionEvaluator): evaluates hasPermission('RESOURCE', 'ACTION') against PERMISSION_RESOURCE:ACTION authorities stored on the user
  • Service, repository, and controller tests

feature/token-management

Token lifecycle management for account activation and password reset, plus async email delivery and scheduled cleanup.

Model

  • ActivationToken entity (id, user, token, createdAt, expiresAt, validatedAt) - migration V6; 24-hour TTL; validatedAt marks a used token (not deleted, auditable); findByToken carries PESSIMISTIC_WRITE lock to prevent double-use races
  • BlacklistedToken entity (id, user, token, jti, blacklistedAt, createdAt, expiresAt, validatedAt) for JWT revocation; revocation check via existsByJti (no lock needed, read-only)
  • PasswordResetToken entity (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)

Tasks

  • ActivationToken, BlacklistedToken, PasswordResetToken entities and repositories (package entity/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)
  • EmailService interface and EmailServiceImpl (@Async("emailTaskExecutor") with JavaMailSender; uses executor from AsyncConfig in feature/core-architecture; app.base-url property for link generation)
  • ActivationTokenService/ActivationTokenServiceImpl, BlacklistedTokenService/BlacklistedTokenServiceImpl, PasswordResetTokenService/PasswordResetTokenServiceImpl
  • TokenCleanupScheduler (@Scheduled(cron = "0 0 3 * * *") daily at 3 AM; three separate @Transactional methods, one per table; uses SchedulingConfig from feature/core-architecture)
  • InvalidTokenException handler already present in GlobalExceptionHandler (wired by feature/core-architecture)
  • MailHog service added to docker-compose.yml; application-dev.yml points to localhost:1025; application-prod.yml reads MAIL_HOST/MAIL_PORT/MAIL_USERNAME/MAIL_PASSWORD from env; .env.example updated with MAIL_* and APP_BASE_URL
  • Unit tests: ActivationTokenServiceImplTest, BlacklistedTokenServiceImplTest, PasswordResetTokenServiceImplTest, EmailServiceImplTest, SecureTokenGeneratorTest, TokenCleanupSchedulerTest
  • Repository tests (Testcontainers): ActivationTokenRepositoryTest, BlacklistedTokenRepositoryTest, PasswordResetTokenRepositoryTest
  • Fixed @ActiveProfiles("test") on all three @SpringBootTest full-context classes (RepositoryAuditAspectTest, SpringBootTutorialApplicationTests, SecurityAuthorizationTest) so application-test.yml (which stubs spring.mail.host) is loaded and JavaMailSender is auto-configured
  • JwtService: added jti claim (random UUID per token via .id(UUID.randomUUID().toString())), extractJti(String token), and extractExpiration(String token); removed isTokenValid() (replaced by JwtException in filter + blacklist check)
  • AuthService.logout(String rawToken) contract added to the interface
  • AuthServiceImpl.logout(): extracts jti/username/expiration from token, loads AppUser, delegates to BlacklistedTokenService.blacklist()
  • AuthController: POST /api/v1/auth/logout strips Authorization: Bearer <token> header and calls authService.logout()
  • JwtAuthFilter: injects BlacklistedTokenService; checks isBlacklisted(jti) before populating the SecurityContext; catches DataAccessException alongside JwtException for fail-open behaviour on DB outages
  • Tests: JwtServiceTest rewritten for jti model; AuthServiceImplTest extended with @Mock BlacklistedTokenService + two logout() tests; AuthControllerTest gains _08_/_09_ for POST /logout; SecurityAuthorizationTest._16_ exercises the full blacklist lifecycle (login -> GET 200 -> logout -> GET 401); all @WebMvcTest slices updated with @MockitoBean BlacklistedTokenService

feature/user-management

Admin-facing user management: list, view, activate/deactivate accounts, assign roles; plus account activation and password-reset flows driven by email tokens.

Model

  • AppUser gains a @ManyToMany with Role (entity) via role_user join table, migration V7

Endpoints

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

Tasks

  • Migration V7__role_user.sql (role_user join table, seeds ADMIN/USER roles, migrates enum data)
  • AppUser refactored from @Enumerated Role to @ManyToMany Set<Role> + boolean enabled
  • UserDetailsServiceImpl maps roles to ROLE_ prefixed authorities, propagates disabled(!enabled)
  • AppUserRepository.findByUsername annotated @EntityGraph(attributePaths="roles") to avoid LazyInitializationException in JwtAuthFilter
  • AuthServiceImpl.register() sets enabled=false, loads USER role from DB, sends activation email
  • UserController (ADMIN-only, @PreAuthorize at class level)
  • Account activation flow (register -> activation email -> GET /auth/activate?token=)
  • Password-reset flow (forgot-password -> email -> POST /auth/reset-password)
  • UserResponse exposes account_locked field so the Angular users list can show lock status on initial load
  • Unit and integration tests (service, repository, controller, security authorization)

Order of work

  1. feature/core-architecture → Pull Request to develop
  2. feature/products (depends on core-architecture) → Pull Request to develop
  3. feature/customers → Pull Request to develop
  4. feature/orders (depends on products and customers) → Pull Request to develop
  5. feature/auth (secures everything) → Pull Request to develop
  6. feature/roles-permissions (fine-grained RBAC) → Pull Request to develop
  7. feature/token-management (token lifecycle, async email, scheduled cleanup) → Pull Request to develop
  8. feature/user-management (admin user CRUD, activation/reset flows) → Pull Request to develop
  9. develop → master once everything is tested and validated

Code conventions

  • Root package: com.edgareldy.springboottutorial
  • DTOs: Java record rather 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 of service/, its implementation (XxxServiceImpl) lives in service/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), including GlobalExceptionHandler for errors
  • DTO fields are exposed in JSON as snake_case via @JsonProperty on 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-case when composed
  • Every endpoint documented with @Operation(summary = "...")
  • All REST endpoints are versioned under the /api/v1 prefix (e.g. /api/v1/categories), so the API can introduce a breaking /api/v2 later without touching existing clients
  • Test naming convention: every test method is named _NN_Should<Outcome>_When<Condition>, where NN is 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.

Concepts covered

  • 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, custom PermissionEvaluator)
  • 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)

How to follow this tutorial

  1. Clone the repository and check out develop
  2. Create/checkout the feature/core-architecture branch and follow its task checklist
  3. Continue with feature/products, feature/customers, feature/orders, feature/auth, feature/roles-permissions, feature/token-management, feature/user-management in that order
  4. Open a Pull Request to develop at the end of each branch
  5. Copy .env.example to .env and fill in the required variables
  6. Run the project with docker-compose up, then open Swagger UI at http://localhost:8080/swagger-ui.html
  7. MailHog UI is available at http://localhost:8025 to inspect outgoing emails in local dev

About

Spring Boot 3.5.x CRUD REST API: JPA/Hibernate, Flyway, MapStruct, JWT auth, method-level security (RBAC), Spring AOP, Caffeine cache, async email, scheduled cleanup, Swagger UI and GitHub Actions CI

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages