A REST API for managing tasks, built with Java 21 and Spring Boot 4. I built it to practise the things a backend job actually asks for: a layered structure, input validation, consistent error responses, tests and a real database running in Docker.
The domain is deliberately small (a task has a title, a description, a completed flag and a creation date) so the focus stays on how the code is organised rather than on business logic.
- Java 21, Spring Boot 4 (Web MVC, Data JPA, Validation)
- H2 in memory for local development and tests
- PostgreSQL 17 through Docker Compose
- springdoc-openapi for the Swagger UI
- JUnit 5, Mockito and MockMvc
- GitHub Actions to build and run the tests on every push
| Method | Path | What it does | Status on success |
|---|---|---|---|
| GET | /api/tasks |
List all tasks | 200 |
| GET | /api/tasks/{id} |
Get one task | 200 |
| POST | /api/tasks |
Create a task | 201 |
| PUT | /api/tasks/{id} |
Replace a task | 200 |
| DELETE | /api/tasks/{id} |
Delete a task | 204 |
Errors follow the ProblemDetail format (RFC 9457). Sending an empty title, for
example, returns:
{
"title": "Invalid request",
"status": 400,
"detail": "Validation failed",
"instance": "/api/tasks",
"errors": { "title": "Title is required" }
}Asking for a task that does not exist returns a 404 with the same structure.
You need Java 21. Docker is only required for the PostgreSQL setup.
With H2 (no setup, data is lost on restart):
./mvnw spring-boot:runWith PostgreSQL:
docker compose up -d
./mvnw spring-boot:run -Dspring-boot.run.profiles=postgresOn Windows, use .\mvnw instead of ./mvnw.
Once it is running:
- Swagger UI: http://localhost:8080/swagger-ui.html
- H2 console (default profile only): http://localhost:8080/h2-console, JDBC URL
jdbc:h2:mem:taskdb, usersa, no password
./mvnw testTaskServiceTesttests the service on its own, with the repository mocked.TaskControllerTestuses@WebMvcTestto check status codes, validation and the error handler without starting the whole application.
src/main/java/dev/sergiorascon/taskmanager
├── config OpenAPI configuration
├── controller REST endpoints
├── dto Request and response records
├── exception Custom exception and global error handler
├── model JPA entity
├── repository Spring Data repository
└── service Business logic
The API never exposes the entity. Requests and responses use their own records
(TaskRequest, TaskUpdateRequest, TaskResponse). That way a client cannot send
an id or a createdAt and have it saved, and I can change the table without
breaking the API.
Validation lives in the DTOs. Format rules (required title, maximum lengths) are
Bean Validation annotations, triggered by @Valid in the controller. An invalid
request is rejected before it reaches the service, and every failing field is
reported at once. Rules that depend on the state of the data would go in the
service instead.
completed is a Boolean in the update request, not a boolean. PUT replaces
the whole task, so the field has to be there. While testing I found that with a
primitive, a missing field ended up as a generic "malformed JSON" error. With
Boolean plus @NotNull the client gets a clear message saying which field is
missing.
One place for errors. A @RestControllerAdvice turns exceptions into
ProblemDetail responses, so every error has the same shape. For malformed JSON I
return a fixed message rather than the exception text, which can expose internal
details.
Deleting a missing task returns 404. The service looks the task up before deleting it. Otherwise the client would get a 204 for something that never existed.
Constructor injection. Dependencies are final fields received through the
constructor. It makes them explicit and lets the unit tests create the service with
a mock using a plain new.
Two profiles. The default profile uses H2 so the project runs and the tests pass
with nothing installed, which is also what the CI uses. The postgres profile
points to the database in Docker. There, ddl-auto=update creates the table, which
is fine for development; in production I would manage the schema with migrations.
- Flyway migrations instead of
ddl-auto - Pagination and filtering by
completedonGET /api/tasks - A
PATCHendpoint to mark a task as done without sending every field - Integration tests against a real PostgreSQL with Testcontainers