Enterprise Java

Building and testing a Modern Java 25 / Spring Boot 4.0.6 Backend: Docker, PostgreSQL, OpenAPI, Serenity BDD, Cucumber, and JUnit 6

Abstract

This article walks through a compact but realistic backend application built with Java 25 and Spring Boot 4.0.6. The project uses PostgreSQL running in Docker, Gradle 9.5.1 as the build system, Eclipse 2026-03 as the IDE, springdoc OpenAPI/Swagger UI for interactive API documentation, and a test stack built around JUnit 6, Cucumber, and Serenity BDD. The purpose is not to teach every tool from first principles. Instead, the article provides a working integration baseline: a small book-catalog REST service that can be started, inspected through Swagger UI, tested through unit tests, and documented through Serenity reports. For developers who learn best by running real code and modifying it, this kind of baseline can be more useful than another isolated “hello world” example.

Technology baseline

AreaTechnology used in the project
Language / runtimeJava 25
Application frameworkSpring Boot 4.0.6
BuildGradle 9.5.1, Groovy DSL, Java toolchains
DatabasePostgreSQL 18.x running in Docker
API documentationspringdoc OpenAPI / Swagger UI
TestingJUnit 6 / JUnit Jupiter, Cucumber, Serenity BDD, Serenity REST
IDEEclipse 2026-03
Application exampleBook catalog REST API with create, read, update, delete, and search operations

Why another Spring Boot tutorial?

There is no shortage of Spring Boot examples. The problem is that many examples become outdated quickly. A tutorial written for Spring Boot 3.x, an older Gradle version, an older Docker Desktop release, or an older testing stack may still teach useful concepts, but it may also leave the reader solving version conflicts that the article never mentions.

That version drift matters. Modern backend development is less about a single framework and more about the collaboration between framework, runtime, build system, database, test tools, API documentation, local development infrastructure, and IDE support. If one component moves forward while the others are frozen in older assumptions, developers often spend hours on dependency alignment, package moves, plugin versions, and configuration details.

This article therefore takes a different angle. It demonstrates a small backend project where current versions work together. The goal is to provide a verified starting point rather than an encyclopedic treatment of every tool.

The application used in the example

The sample application is intentionally simple: a book catalog service backed by PostgreSQL. It is simple enough to understand quickly, but it is not merely a console example. It has a database schema, a JPA entity, record-based request and response DTOs, a repository, a transactional service layer, a REST controller, centralized exception handling, OpenAPI documentation, unit tests, Cucumber scenarios, and Serenity-generated reports.

The service exposes operations to list books, create a new book, retrieve a book by id, update an existing book, delete a book, search by author, and find a book by ISBN. This is enough surface area to demonstrate persistence, validation, error handling, documented REST endpoints, and several styles of automated testing.

The table used by the application is intentionally small but includes realistic constraints:

CREATE TABLE book (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    author VARCHAR(120) NOT NULL,
    isbn VARCHAR(20) NOT NULL,
    published_date DATE,
    price NUMERIC(10, 2),
    stock_quantity INTEGER NOT NULL,
    description VARCHAR(1000),
    CONSTRAINT uk_book_isbn UNIQUE (isbn),
    CONSTRAINT ck_book_price_nonnegative CHECK (price IS NULL OR price >= 0),
    CONSTRAINT ck_book_stock_nonnegative CHECK (stock_quantity >= 0)
);

Starting PostgreSQL with Docker

The database runs in a Docker container so that the example does not depend on a manually installed local PostgreSQL server. This keeps the development environment reproducible and makes it easier to reset or recreate the database when experimenting.

A minimal development setup can start with pulling the PostgreSQL image and launching a named container. In real production environments, credentials should be injected through a secure mechanism rather than typed directly on the command line. For a local tutorial project, however, a direct command is sufficient to show the moving parts.

docker pull postgres:latest

docker run --name pg-akrivitsky   -e POSTGRES_USER=akrivitsky   -e POSTGRES_PASSWORD=your_password   -e POSTGRES_DB=swagger_demo   -p 5432:5432   -v pgdata_akrivitsky:/var/lib/postgresql   -d postgres:latest

The persistent volume is mapped to /var/lib/postgresql, which is the data directory location expected by the newer PostgreSQL Docker image used in this setup. After the container starts, the version can be verified with psql:

docker exec -it pg-akrivitsky psql -U akrivitsky -d swagger_demo
SELECT version();

Gradle and Java 25

The project is a Gradle build using the Groovy DSL. The important point is that the build declares Java 25 through the Gradle toolchain mechanism. This is cleaner than relying on whatever JDK happens to be first on the PATH.

The settings file can use the Foojay toolchain resolver convention plugin. Together with the toolchain declaration, this allows Gradle to resolve the requested JDK if it is not already installed in the local environment.

// settings.gradle
plugins {
    id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}

rootProject.name = 'SpringBoot4FeaturesSwaggerSerenityPostgres'
include 'lib'

// lib/build.gradle
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

The build then applies the Spring Boot plugin, dependency management, Java, Eclipse, and Serenity plugin support. The exact dependency list is not complicated, but the versions are important because Spring Boot 4, springdoc 3.x, Serenity, Cucumber, and the JUnit platform must all cooperate.

plugins {
    id 'java'
    id 'eclipse'
    id 'org.springframework.boot' version '4.0.6'
    id 'io.spring.dependency-management' version '1.1.7'
    id 'net.serenity-bdd.serenity-gradle-plugin' version '5.3.9'
}

ext {
    serenityVersion = '5.3.9'
    cucumberVersion = '7.21.1'
}

Spring Boot 4.0.6 project structure

The application follows a conventional layered structure. The root package contains the Spring Boot application class. Subpackages hold configuration, controller, DTO, entity, exception, repository, and service code. Tests are separated into ordinary unit and slice tests, Cucumber step definitions, a Cucumber runner, and direct Serenity REST tests.

This structure is not the only possible layout, but it is easy to understand and works well for a tutorial project. It also maps naturally to common enterprise development expectations: controller logic is separate from service logic, persistence is hidden behind Spring Data JPA repositories, and exception handling is centralized.

src/main/java/dev/anatoly/swaggerdemo
├── SwaggerDemoApplication.java
├── config/OpenApiConfig.java
├── controller/BookController.java
├── dto/BookCreateRequest.java
├── dto/BookUpdateRequest.java
├── dto/BookResponse.java
├── entity/Book.java
├── exception/GlobalExceptionHandler.java
├── exception/ResourceNotFoundException.java
├── repository/BookRepository.java
└── service/BookService.java

Modern Java style in the DTO layer

The request and response objects are implemented as Java records. Records are not new to Java 25, but they represent a concise and reliable style for immutable data carriers. In a REST application they are especially convenient because they avoid boilerplate getters, setters, equals, hashCode, and toString code.

Validation annotations can be applied directly to record components. springdoc can also read OpenAPI metadata from annotations placed on those components.

public record BookCreateRequest(
    @NotBlank
    @Schema(example = "Effective Java")
    String title,

    @NotBlank
    @Schema(example = "Joshua Bloch")
    String author,

    @NotBlank
    @ISBN
    @Schema(example = "9780134685991")
    String isbn,

    @PastOrPresent
    LocalDate publishedDate,

    @DecimalMin("0.00")
    BigDecimal price,

    @Min(0)
    Integer stockQuantity,

    String description
) {}

Repository and service layer

The repository extends JpaRepository and uses Spring Data derived queries for the two non-basic lookup methods. This keeps the persistence layer compact and lets Spring Data create the implementation automatically.

The service layer is transactional. Read operations can use read-only transactions, while create, update, and delete operations run in ordinary transactional context. Mapping between entity and record DTO is done manually to keep the example transparent and avoid reflection-heavy mapping frameworks.

public interface BookRepository extends JpaRepository<Book, Long> {
    Optional<Book> findByIsbn(String isbn);
    List<Book> findByAuthorContainingIgnoreCase(String author);
}

REST API and OpenAPI documentation

The REST controller exposes the book-catalog operations and annotates them with OpenAPI metadata. Swagger UI then becomes more than a static documentation page. It is also a convenient manual exploration tool: developers can inspect endpoints, send requests, view responses, and confirm that the application is connected to PostgreSQL.

In a production system, Swagger UI access should be controlled according to organizational security rules. For a local development baseline, it is very useful because it provides immediate feedback before the test suite is executed.

@RestController
@RequestMapping("/api/books")
@Tag(name = "Books", description = "CRUD operations for catalog entries")
public class BookController {

    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    @GetMapping
    @Operation(summary = "List all books")
    public List<BookResponse> listBooks() {
        return bookService.findAll();
    }

    @PostMapping
    @Operation(summary = "Create a new book")
    public ResponseEntity<BookResponse> createBook(@Valid @RequestBody BookCreateRequest request) {
        BookResponse created = bookService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
}

Application configuration

The application.yaml file connects the Spring Boot application to the PostgreSQL container, enables SQL initialization, configures JPA validation against the schema, and exposes the OpenAPI/Swagger endpoints. One notable property is virtual thread support, enabled with a single Spring Boot configuration line.

Virtual threads are not required for this small example, but enabling them demonstrates how the project can use a modern Java runtime feature through Spring Boot configuration.

server:
  port: 8080

spring:
  threads:
    virtual:
      enabled: true
  datasource:
    url: jdbc:postgresql://localhost:5432/swagger_demo
    username: akrivitsky
    password: your_password
  jpa:
    hibernate:
      ddl-auto: validate
  sql:
    init:
      mode: always

springdoc:
  swagger-ui:
    path: /swagger-ui.html
  api-docs:
    path: /v3/api-docs

Error handling with ProblemDetail

Modern Spring applications do not need to invent a custom error object for every REST API. Spring supports ProblemDetail, which is aligned with the standardized problem-details response style. In the sample project, a global exception handler converts application exceptions and validation failures into structured error responses.

This approach keeps controller methods cleaner and gives API clients a predictable error-response shape.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
        problem.setTitle("Resource not found");
        problem.setDetail(ex.getMessage());
        return problem;
    }
}

Testing strategy

The project deliberately uses more than one testing style because real projects rarely rely on one style only. The ordinary Gradle test task runs unit and Spring tests. Cucumber feature tests are placed behind a separate task so that they do not accidentally run during every ordinary test execution. Serenity is then used to generate human-readable reports.

This separation gives developers control. Fast unit tests can run frequently. Cucumber and Serenity acceptance tests can run when the application is available and when report generation is useful.

test {
    useJUnitPlatform {
        excludeEngines 'cucumber'
    }
}

task cucumberTest(type: Test) {
    useJUnitPlatform {
        includeEngines 'junit-platform-suite', 'cucumber'
    }
    systemProperty 'cucumber.features', 'classpath:features/books'
    systemProperty 'cucumber.glue', 'dev.anatoly.swaggerdemo.cucumber'
    filter {
        includeTestsMatching "*RunCucumberTest*"
    }
}

Cucumber plus Serenity BDD

Cucumber is useful when tests should read like business behavior. In this project, a feature file describes book search behavior in Given/When/Then form. Java step definitions execute those steps by calling the running REST API through Serenity REST.

The result is not only pass/fail automation. Serenity produces a report that documents the executed scenario, request, response, headers, body, and outcome. This can be valuable when test results must be understandable to people who are not reading Java source code.

Feature: Search books

  Scenario: Find books by author
    Given the book catalog service is running
    When I search books by author "Martin"
    Then the response status should be 200
    And the response should contain at least one book

Serenity REST without Gherkin

The project also demonstrates another style: direct JUnit 6 / JUnit Jupiter tests using Serenity REST, without .feature files. This is useful when the audience for the tests is mainly technical and when the extra Gherkin layer does not add much value.

In this style the test remains an ordinary Java test, but Serenity still captures the REST interaction and generates a rich HTML report.

@ExtendWith(SerenityJUnit5Extension.class)
class WhenManagingBooks {

    @Test
    void should_find_books_by_author() {
        SerenityRest.given()
            .queryParam("author", "Martin")
        .when()
            .get("http://localhost:8080/api/books/search/by-author")
        .then()
            .statusCode(200);
    }
}

Running the application

Once PostgreSQL is running, the application can be started from Eclipse by running the Spring Boot application class as a Java application. After startup, Swagger UI can be opened in the browser.

The Swagger UI page provides a convenient way to verify the endpoints manually. For example, you can create a new book by sending a JSON request to the POST /api/books endpoint and then confirm that the saved record appears in later GET responses.

http://localhost:8080/swagger-ui/index.html

# Cucumber + Serenity reports
gradlew :lib:clean :lib:cucumberTest :lib:aggregate --rerun-tasks

# Direct JUnit / Serenity REST report
gradlew :lib:clean :lib:test --tests "*WhenManagingBooks*" :lib:aggregate --rerun-tasks

What this project gives you

The practical value of the project is the integration baseline. It shows one combination of Spring Boot 4.0.6, Java 25, PostgreSQL in Docker, Gradle, OpenAPI documentation, and Serenity-based testing that works as a coherent unit.

That does not mean every production system should copy this project exactly. Production deployments require stricter secrets management, environment-specific configuration, observability, CI/CD hardening, infrastructure automation, and security review. The point is to start from a running, tested, documented example rather than from incompatible fragments.

Docker Compose or Kubernetes can be added later if the article is extended. For the present baseline, a single PostgreSQL container is enough to demonstrate local database infrastructure without making the article primarily about orchestration.

Lessons learned

First, version alignment is the hidden work in modern tutorials. Spring Boot, springdoc, Gradle, Java, Cucumber, Serenity, PostgreSQL, and the JUnit platform all evolve. A useful example must show versions that actually cooperate.

Second, API documentation and testing should not be afterthoughts. Swagger UI gives a fast manual feedback loop. Unit tests give development confidence. Cucumber and Serenity reports add readable evidence of API behavior.

Third, AI-assisted development can speed up the search for working configurations, but it does not remove the need to verify. In this project, every useful generated fragment still had to be compiled, run, tested, and corrected locally. That is the responsible version of a vibe-coding workflow: use AI as an accelerator, not as an authority.

Conclusion

A modern Java backend is rarely just Java code. It is a working relationship between the language, the framework, the build, the database, the local infrastructure, the documentation surface, and the test/reporting workflow.

This article presented a compact Spring Boot 4.0.6 / Java 25 project that connects those pieces in one place. The application is small, but the stack is representative of real enterprise development: PostgreSQL persistence, Docker-based local infrastructure, OpenAPI documentation, JUnit 6 tests, Cucumber scenarios, and Serenity BDD reporting.

For developers who prefer learning through a running project, this baseline can be cloned, inspected, tested, and modified. From there, it can grow into Docker Compose, Kubernetes, CI/CD pipelines, additional security controls, or a larger domain model.

Source code: https://github.com/akrivitsky7/springboot4-swagger-serenity-postgres-eclipse/tree/main

Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button