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
- 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 |
| 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). |
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.
spring-boot-tutorial/
├── src/
│ ├── main/
│ │ ├── java/edgareldy/springboottutorial/
│ │ │ ├── SpringBootTutorialApplication.java
│ │ │ ├── config/
│ │ │ │ ├── OpenApiConfig.java
│ │ │ │ ├── SecurityConfig.java
│ │ │ │ ├── JacksonConfig.java
│ │ │ │ ├── CorsConfig.java
│ │ │ │ └── CacheConfig.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
│ │ │ │ ├── ErrorResponse.java
│ │ │ │ └── GlobalExceptionHandler.java
│ │ │ ├── security/
│ │ │ │ ├── JwtService.java
│ │ │ │ ├── JwtAuthFilter.java
│ │ │ │ └── UserDetailsServiceImpl.java
│ │ │ ├── event/
│ │ │ │ ├── OrderCreatedEvent.java
│ │ │ │ └── OrderCreatedEventListener.java
│ │ │ └── aspect/
│ │ │ ├── LoggingAspect.java
│ │ │ └── ExecutionTimeAspect.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/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<Void>withsuccess=false, an explicitmessage, and structured details (ErrorResponse) placed indatawhen relevant (field validation, etc.). - Success response example:
{
"success": true,
"message": "Product created successfully",
"data": { "id": 12, "productName": "Mechanical keyboard", "unitPrice": 79.99, "categoryId": 3 },
"timestamp": "2026-07-02T10:15:30Z"
}- Error response example:
{
"success": false,
"message": "Product not found",
"data": null,
"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(* 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,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 -
application-test.yml: Testcontainers datasource -
application-prod.yml: datasource via environment variables, JSON logs - Flyway script
V1__init_schema.sql(categories, products, customers, orders tables with FKs) -
GlobalExceptionHandler(@RestControllerAdvice): handlesResourceNotFoundException(404),MethodArgumentNotValidException(400, field details),BusinessRuleException(422), genericException(500) - Standard
ErrorResponseDTO:timestamp,status,error,message,path, optionalfieldErrorslist - 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) -
OpenApiConfig: title, description, version, JWT security scheme in Swagger UI - Actuator: expose
health,info,metricsin dev;healthonly in prod - Structured logging (
logback-spring.xmland Actuator ECS/JSON config, combined) -
CorsConfig: allowlocalhost:4200in dev - Multi-stage
Dockerfile(Maven build + lightweight JRE image) -
docker-compose.yml:appservice +dbservice (PostgreSQL 16) with volumes and environment variables -
.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.
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 |
| 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, 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
| 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
| 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(with summarized customer/product 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) -
OrderController - 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)
feature/core-architecture→ Pull Request todevelopfeature/products(depends onconfig) → Pull Request todevelopfeature/customers→ Pull Request todevelopfeature/orders(depends onproductsandcustomers) → Pull Request todevelopfeature/auth(secures everything) → Pull Request todevelopdevelop→masteronce everything is tested and validated
- Root package:
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 an
ApiResponse<T>(see Standard response format), includingGlobalExceptionHandlerfor errors - 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
- 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
- Transactions (
@Transactional) - Schema migrations (Flyway)
- API documentation (OpenAPI / Swagger UI)
- Observability (Actuator, structured logging)
- Security (Spring Security, JWT, role-based authorization)
- 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) - 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/authin that order - Open a Pull Request to
developat the end of each branch - Run the project with
docker-compose up, then open Swagger UI athttp://localhost:8080/swagger-ui.html