A product management REST API built with Java 21 and Spring Boot 3, structured with Clean / Hexagonal Architecture (Ports & Adapters) across four independent Maven modules. The goal is a small but realistic codebase that takes the dependency rule seriously: the domain knows nothing about frameworks, persistence, or HTTP.
Four Maven modules, each with a single responsibility and a strict dependency direction:
flowchart TD
API["api<br/><i>REST controllers, HTTP DTOs</i>"] --> APP["application<br/><i>use cases, ports, command/result DTOs</i>"]
INFRA["infrastructure<br/><i>JPA, Flyway, adapters</i>"] --> APP
APP --> DOM["domain<br/><i>entities, value objects, business rules</i>"]
INFRA --> DOM
Dependency rule
api → application → domain
infrastructure → application → domain
domain— pure Java. No Spring, no JPA, no annotations. Entities (Product), value objects (Money,ProductId), and business rules live here.application— orchestrates the domain. Defines input ports (interfaces the API calls) and output ports (interfaces the infrastructure implements). Use cases are the only place transactions are opened.infrastructure— implements the output ports with Spring Data JPA, maps domain ↔ JPA entities, and owns the database schema via Flyway.api— Spring MVC controllers, HTTP request/response records, validation, and centralized error handling. Talks only to the application's input ports.
The domain is testable without a Spring context, and the persistence technology could be swapped by touching only infrastructure.
| Concern | Technology |
|---|---|
| Language | Java 21 (records, sealed-friendly, pattern matching) |
| Framework | Spring Boot 3.5 (Web, Data JPA, Validation) |
| Persistence | PostgreSQL 16, Hibernate, Spring Data JPA |
| Migrations | Flyway |
| Build | Maven (multi-module reactor) |
| Boilerplate | Lombok |
| Errors | RFC 7807 ProblemDetail |
| Testing | JUnit 5, Mockito, AssertJ, Testcontainers |
- Full CRUD for products over a clean REST API (
/api/v1/products). - JWT authentication with public registration/login endpoints, BCrypt password hashing and stateless protection for product routes.
- Soft delete + revive — deleting flips an
activeflag instead of removing the row; creating a product whose name belongs to a soft-deleted row revives that row instead of failing (see Design decisions). - Pagination + price filtering —
GETsupportsminPrice/maxPriceranges and page/size, with a server-side cap of 50 items per page to protect memory. - Uniqueness, defended in depth — a duplicate-name check in the use case (friendly
409) backed by a databaseUNIQUEconstraint (the real guarantee, race-proof). - Bean Validation on request bodies, surfaced as
422with a list of field errors. - Centralized error handling with
@RestControllerAdvicemapping domain/application exceptions to proper HTTP statuses viaProblemDetail. - Versioned schema with Flyway migrations.
- Interactive API docs — OpenAPI 3 + Swagger UI (springdoc), auto-generated from the controllers.
- Unit + integration tests — domain, authentication use cases and JWT with JUnit/Mockito; security rules with MockMvc; repository against a real PostgreSQL via Testcontainers.
.
├── domain/
│ └── entity, valueobject, exception
├── application/
│ └── usecase, port/in, port/out, dto, exception
├── infrastructure/
│ └── persistence (entity, repository, mapper), db/migration
├── api/
│ └── controller, dto, mapper, exception, CleanArchitectureApplication
├── docker-compose.yml
└── pom.xml
- JDK 21+
- Maven 3.9+
- Docker (for PostgreSQL and the integration tests)
docker compose up -d
mvn spring-boot:run -pl api
docker compose downOn startup, Flyway applies the migrations and Hibernate validates that the entities match the schema.
mvn test
mvn verifyOnce the app is running, the full API is documented and explorable at:
- Swagger UI — http://localhost:8080/swagger-ui.html
- OpenAPI JSON — http://localhost:8080/v3/api-docs
Authentication base path: /api/v1/auth
| Method | Path | Description | Success | Errors |
|---|---|---|---|---|
POST |
/register |
Register a user | 201 |
409 duplicate email, 422 validation |
POST |
/login |
Authenticate and issue a JWT | 200 |
401 invalid credentials, 422 validation |
Products base path: /api/v1/products. All product routes require Authorization: Bearer <access-token>.
| Method | Path | Description | Success | Errors |
|---|---|---|---|---|
POST |
/ |
Create a product (revives a soft-deleted one with the same name) | 201 |
409 duplicate, 422 validation |
GET |
/ |
List active products — paginated, optional price range | 200 |
— |
GET |
/{id} |
Find a product by id | 200 |
404 |
PUT |
/{id} |
Update name and price | 200 |
404, 409, 422 |
DELETE |
/{id} |
Soft delete (sets active = false) |
204 |
404 |
List query parameters: minPrice, maxPrice (both optional), page (default 0), size (default 20, max 50).
curl -X POST http://localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"strongpass123"}'
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"strongpass123"}'
curl -X POST http://localhost:8080/api/v1/products \
-H "Authorization: Bearer <access-token>" \
-H "Content-Type: application/json" \
-d '{"name":"Notebook Dell","price":4999.90}'
curl "http://localhost:8080/api/v1/products?minPrice=100&maxPrice=1000&page=0&size=20" \
-H "Authorization: Bearer <access-token>"
curl -X PUT http://localhost:8080/api/v1/products/{id} \
-H "Authorization: Bearer <access-token>" \
-H "Content-Type: application/json" \
-d '{"name":"Notebook Dell XPS","price":5999.90}'
curl -X DELETE http://localhost:8080/api/v1/products/{id} \
-H "Authorization: Bearer <access-token>"Validation error (422) is returned as ProblemDetail:
{
"type": "about:blank",
"title": "Validation Error",
"status": 422,
"errors": ["price: price must be positive"]
}A few choices that go beyond a textbook CRUD:
- Soft delete that doesn't burn names. A soft-deleted row keeps its
name, which would otherwise collide with the globalUNIQUE(name)constraint forever. Instead of a partial index, creating a product with a soft-deleted name revives the existing row (reactivates it and updates the price), so there is never a duplicate and names stay reusable. - Pagination without leaking the framework. Spring Data's
Page/Pageablestay insideinfrastructure. The application exposes its ownPageResult<T>record, so the domain and application modules never depend on Spring Data — they couldn't even compile against it. The page size is clamped (1–50) in the use case, where the business rule belongs. - Memory vs. performance, separated. The
LIMITon the query is what protects memory (the database returns at most 50 rows regardless of table size).Pageadds acount(*)query for total pages — a deliberate, documented cost.
- Additional list filters (by name, created date) and sorting options
- CI pipeline (GitHub Actions running
mvn verify)
Marcelo Alves — built as a hands-on study of Clean Architecture with Spring Boot, coming from a .NET / C# background.