A Spring Boot REST service for managing tasks, their watchers, and users. The project is API-contract-first (OpenAPI) and built around a feature-based hexagonal architecture (ports & adapters), with the architecture rules enforced automatically by ArchUnit tests.
- What it does
- Tech stack
- Architecture
- API
- Data model
- Getting started
- Configuration
- Testing
- Monitoring
- Repository layout
The service exposes a small task-management domain:
- Tasks — create tasks and list them (with their watchers). A task may optionally reference an owning user by id.
- Task watchers — register an email address to watch a given task. Creating a watcher validates
that the task exists. A
TaskWatcheris part of theTaskaggregate (its lifecycle is bound to the task viaON DELETE CASCADE). - Users — create, read, list, and soft-deactivate users (a user is never physically deleted;
activeis flipped tofalse).
When a task is created, a notification is dispatched asynchronously through an outbound notification port (currently a stub email adapter).
| Area | Technology |
|---|---|
| Language | Java 25 |
| Framework | Spring Boot 4.1.1 (Web MVC, Security, Data JPA, Actuator) |
| Database | PostgreSQL + Flyway migrations |
| Mapping | MapStruct (entity ↔ API model) |
| Boilerplate | Lombok |
| API contract | OpenAPI 3.1 + openapi-generator-maven-plugin |
| Docs / UI | springdoc Swagger UI |
| Metrics | Micrometer + Prometheus registry |
| Architecture tests | ArchUnit (JUnit 5) |
| Build | Maven (wrapper included) |
The service follows a hexagonal (ports & adapters) architecture, organised by feature rather than by technical layer.
flowchart LR
Client -->|REST / JSON| Web[Web adapters]
subgraph Service[task-manager-service]
Web --> AppT["task · application (use cases)"]
Web --> AppU["user · application (use cases)"]
AppT --> DomT["task · domain"]
AppU --> DomU["user · domain"]
AppT -->|outbound ports| PersT["task · persistence adapter"]
AppU -->|outbound ports| PersU["user · persistence adapter"]
AppT -->|outbound port| Notify["notification adapter (async)"]
end
PersT -->|JDBC / JPA| DB[(PostgreSQL)]
PersU -->|JDBC / JPA| DB
The application core (domain + use cases) holds the business rules and depends only on ports — plain Java interfaces it owns. Infrastructure (web, persistence, notification) lives in adapters that depend on the core, never the other way around. All dependencies point inward, which keeps the core framework-agnostic and makes cyclic dependencies structurally impossible.
- Inbound ports (
application.port.in) — use cases the core offers (e.g.TaskUseCase). Implemented by services, called by web adapters. - Outbound ports (
application.port.out) — capabilities the core needs (e.g.TaskRepositoryPort,TaskNotificationPort). Implemented by adapters.
There are two features, each a self-contained hexagon:
task— owns bothTaskandTaskWatcher(one aggregate).user— ownsUser.
The task feature needs to check that a referenced user exists, which naively would mean
task → user. Instead the dependency is inverted: the task feature declares its own
UserExistencePort, and the user feature provides UserExistenceAdapter to implement it.
flowchart LR
subgraph task
TS[TaskService] --> UEP[[UserExistencePort]]
end
subgraph user
UEA[UserExistenceAdapter] -.implements.-> UEP
end
The result is a single, deliberate compile-time edge user → task — an acyclic graph. To keep
it that way at the persistence level too, TaskEntity references the user by UUID userId
(by id across the aggregate boundary), not by a JPA @ManyToOne association.
API note: because tasks reference users by id, the task payload exposes
userIdonly. To get the full user, callGET /api/v1/users/{userId}.
com.paxier.task_manager_service
├── api/.. # GENERATED from OpenAPI (interfaces + models) — shared transport
├── task/ # FEATURE: tasks + task watchers
│ ├── domain/ # TaskDetails, TaskWatcherCommand
│ ├── application/
│ │ ├── port/in/ # TaskUseCase, TaskWatcherUseCase
│ │ ├── port/out/ # TaskRepositoryPort, TaskWatcherRepositoryPort,
│ │ │ # TaskNotificationPort, UserExistencePort
│ │ └── service/ # TaskService, TaskWatcherService
│ └── adapter/
│ ├── in/web/ # TaskRestController, TaskWatcherController
│ └── out/
│ ├── persistence/ # entities, JPA repositories, persistence adapters, mapper
│ └── notification/ # EmailNotificationAdapter (@Async)
├── user/ # FEATURE: users
│ ├── application/ # UserUseCase, UserRepositoryPort, UserService
│ └── adapter/
│ ├── in/web/ # UserController
│ └── out/persistence/ # entity, repository, adapters (incl. UserExistenceAdapter), mapper
├── shared/web/ # GlobalExceptionHandler (cross-cutting)
└── config/ # SecurityConfig, AsyncConfig
HexagonalArchitectureTest fails the build if the architecture is violated. It checks, among others:
- No cyclic dependencies between top-level slices.
- Hexagon layering:
adapter → application → domain(never reversed). - Framework isolation: the application core must not depend on
spring-web,spring-data, orjakarta.persistence. - Feature boundaries:
taskmust not depend onuser;usermay reachtaskonly through its published ports.
Base path: /api/v1.
These two are often confused, so to be precise:
- OpenAPI is the specification format — the file
api/openapi.yaml. It is the single source of truth for this project. - Swagger UI is only a viewer that renders an OpenAPI spec as interactive docs. It does not define anything; it just displays the contract.
In other words, API-first here is driven by the OpenAPI spec and the code generator — not by Swagger. Swagger UI is purely the presentation layer on top.
- Contract first: you write/edit
api/openapi.yaml— this is the contract. - The build generates code: the
openapi-generator-maven-plugin(inpom.xml) produces from it- the controller interfaces (
TasksApi,UsersApi,TaskWatchersApi) → packageapi - the models (
Task,User,TaskWatcher,TaskStatus) → packageapi.model - configured with
interfaceOnly=true(interfaces only, no ready-made controllers).
- the controller interfaces (
- You only implement: your controllers (
implements TasksApi) fill the generated interfaces with behaviour. If a controller deviates from the contract, compilation fails — the contract is binding. - Swagger UI shows the contract:
springdoc-openapi-starter-webmvc-uiserves the UI at/swagger. It is deliberately configured (springdoc.swagger-ui.url: /openapi.yaml) to render the hand-written spec, not a view reverse-derived from the code — keeping the docs consistent with the actual contract.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/tasks |
List all tasks (with watchers) |
| POST | /api/v1/tasks |
Create a task |
| POST | /api/v1/task-watcher |
Add a watcher (email) to a task |
| GET | /api/v1/users |
List all users |
| POST | /api/v1/users |
Create a user |
| GET | /api/v1/users/{userId} |
Get a user by id |
| DELETE | /api/v1/users/{userId} |
Deactivate a user (soft delete) |
Interactive docs (Swagger UI): http://localhost:8080/swagger
Example — create a task:
curl -X POST http://localhost:8080/api/v1/tasks \
-H 'Content-Type: application/json' \
-d '{"title": "Write docs", "status": "OPEN"}'Flyway migrations live in
task-manager-service/src/main/resources/db/migration
and run on startup. All tables are in the task schema.
| Table | Columns |
|---|---|
task.task |
id, title, description, status, due_date, user_id |
task.task_watcher |
id, email, task_id (FK → task.task, ON DELETE CASCADE) |
task.app_user |
id, first_name, last_name, email (unique), active |
status is one of OPEN, IN_PROGRESS, DONE.
- JDK 25
- Docker (for PostgreSQL via Compose), or a local PostgreSQL instance
cd task-manager-service
docker compose up -dThis starts PostgreSQL on localhost:5432 with database task-manager-db
(user username / password password).
cd task-manager-service
./mvnw spring-boot:runThe app runs on http://localhost:8080, applies Flyway migrations, and serves Swagger UI at
/swagger.
Security: for local development all endpoints are open (
permitAll). Lock this down before any non-local deployment.
Defaults live in
application.yaml. Key settings:
| Setting | Default |
|---|---|
spring.datasource.url |
jdbc:postgresql://localhost:5432/task-manager-db |
spring.datasource.username |
username |
spring.datasource.password |
password |
spring.jpa.hibernate.ddl-auto |
validate (schema owned by Flyway) |
springdoc.swagger-ui.path |
/swagger |
Override per environment with standard Spring mechanisms (env vars, SPRING_* properties, or a
profile-specific application-<profile>.yaml).
cd task-manager-service
./mvnw testThe suite covers:
- Unit tests — use cases with mocked ports (e.g.
TaskServiceTest). - Web slice tests — controllers via
@WebMvcTestwith a mocked use-case port. - Persistence tests — JPA repositories via
@DataJpaTest. - Architecture tests —
HexagonalArchitectureTest(ArchUnit) guards the rules above.
Actuator endpoints (Prometheus-compatible metrics):
/actuator/health/actuator/metrics/actuator/prometheus
api/ OpenAPI specification (source of truth)
task-manager-service/ Spring Boot application
└── src/main/java/com/paxier/task_manager_service/
├── task/ Task + TaskWatcher feature (hexagon)
├── user/ User feature (hexagon)
├── shared/ Cross-cutting web concerns
└── config/ Spring configuration