A reactive RESTful API built with Spring Boot, created as a Kata to consolidate practices learned from weekly Katas at IONOS — TDD, hexagonal architecture, observability, and clean code disciplines applied end-to-end.
Built through modern pair programming: an AI agent drives (writes the code) while the human navigates (steers direction, reviews decisions, and enforces quality).
- Java 25, Spring Boot 3.5.11, Spring WebFlux, Spring Data R2DBC
- PostgreSQL 17.8, Flyway migrations
- Maven 3.9.12, Spring Boot Buildpacks (Paketo)
- Log4j2, Micrometer Tracing, OpenTelemetry, Prometheus, Grafana, Tempo
Hexagonal Architecture (Ports & Adapters), enforced by ArchUnit tests.
com.jfi.api.employee/
├── domain/ # Domain model
├── port/in/ # Driving ports
├── port/out/ # Driven ports
├── usecase/ # Use case implementations
└── adapter/
├── in/rest/ # REST adapter
└── out/persistence/ # Persistence adapter
com.jfi.api.infrastructure/ # Cross-cutting concerns
See Architecture diagram for the full component view and dependency rules.
Prerequisites: Java 25, Docker (for PostgreSQL and observability stack)
# Run application (starts Netty on port 8080, auto-starts PostgreSQL via Docker Compose)
./mvnw spring-boot:run
# Run all tests
./mvnw test
# Run a single test class
./mvnw test -Dtest=EmployeeServiceImplTest
# Build
./mvnw clean package
# Build container image
./mvnw spring-boot:build-imageGitHub Actions workflow with two jobs: test and publish. Triggered on push to main and pull requests, with path filtering so docs-only changes don't trigger builds. PRs only run tests; image publishing is push-to-main only.
Actions are pinned to commit SHAs for supply chain security. Dependabot keeps Maven and GitHub Actions dependencies up to date weekly.
See CI pipeline diagram for the full workflow and version tagging details.
Three-pillar observability: structured logging (Log4j2 + MDC), metrics (Prometheus + Grafana), and distributed tracing (Micrometer Tracing + OTel → Tempo).
Every request is correlated with requestId, traceId, and spanId across the reactive chain. Sampling and log levels are profile-aware (dev/staging/prod).
See Observability diagram for the full stack view and profile configuration.
With the application running:
- Swagger UI: http://localhost:8080/webjars/swagger-ui/index.html
- OpenAPI spec: http://localhost:8080/v3/api-docs
Generate the OpenAPI spec as a file:
./mvnw verify
# Output: target/openapi.jsonGotchas and insights from building this API.
Spring WebFlux adds significant complexity (reactive types everywhere, context propagation gotchas, MDC workarounds, reactive test utilities) with no real benefit for standard request/response workloads. Spring MVC with virtual threads would have been the pragmatic choice. WebFlux is justified only for streaming use cases — SSE, WebSockets, change streams, or backpressure-sensitive pipelines.
spring.reactor.context-propagation=auto must be set explicitly. Without it, traceId and spanId are not auto-populated into MDC, and log correlation silently breaks. Every reactive operator that needs MDC access must use Mono.deferContextual().
Excluding spring-boot-starter-logging from a single starter is fragile — Maven resolution order determines which starter pulls Logback first. The reliable approach is to declare the bare spring-boot-starter with the exclusion, which globally prevents Logback from leaking through any other starter.
MDC.clear() wipes auto-propagated values like traceId and spanId from context propagation. Use MDC.remove(key) for owned keys only.
Flyway does not support R2DBC. Add the JDBC driver (org.postgresql:postgresql) alongside r2dbc-postgresql. Spring Boot auto-configures a separate JDBC DataSource for Flyway while the application uses R2DBC.
org.testcontainers:r2dbc is required in addition to org.testcontainers:postgresql for @ServiceConnection to wire the R2DBC ConnectionFactory. Without it, the test container starts but the application can't connect.
TCP FIN signals don't reach the Ryuk container inside the VM, so containers are never cleaned up. Use Spring Boot's @TestConfiguration + @Bean + @ServiceConnection instead of JUnit's @Testcontainers/@Container — Spring's lifecycle post-processor calls container.close() directly.
Spring Data's *Impl convention clashes with custom repository implementations. Name outbound adapters *Adapter with @Component instead of @Repository.
Requests rejected by the framework (405 Method Not Allowed, unmapped 404s) never reach @RestController methods or @ControllerAdvice. Only a WebFilter sees them — this is why RequestLoggingFilter exists at the filter level.
Tempo v2.10+ defaults to a Kafka-based ingest path, causing InstancesCount <= 0 errors in single-binary mode. Pin to 2.6.1 and set ingester.lifecycler.ring.replication_factor=1 for single-instance deployments.
The AOT cache step exits with code 1 despite success. Disabled via BP_JVM_CDS_ENABLED=false until paketo-buildpacks/spring-boot#581 is resolved.
Flyway 11.7.2 (managed by Spring Boot 3.5) warns that PostgreSQL 18 is untested. Pinned to PostgreSQL 17.8 — keep docker-compose and Testcontainers config in sync since they are coupled.