VOOZH about

URL: https://tech-insider.org/spring-boot-tutorial-rest-api-java-2026-2/

⇱ Spring Boot Tutorial: REST API in 13 Steps [2026]


Skip to content
April 25, 2026
22 min read

Spring Boot remains the dominant Java framework in 2026, powering more than 70% of enterprise Java workloads according to the JetBrains Developer Ecosystem 2024 survey, and the upcoming 2025 edition tracks even higher adoption among teams shipping cloud-native services. With Spring Boot 4.0.6 landing in April 2026 on top of Spring Framework 7, the platform has been retooled around virtual threads, ahead-of-time (AOT) compilation, and structured logging – and the gap between "hello world" and a production-ready REST API has never been shorter.

This Spring Boot tutorial walks you through building a complete Bookstore REST API in 13 steps. You will move from installing Java 21 and the Spring Initializr scaffolding all the way to JPA persistence with PostgreSQL, validation, exception handling, JWT-based security, OpenAPI docs, Testcontainers integration tests, GraalVM native images, and a Dockerized deployment. Every code block is copy-paste ready against Spring Boot 3.5.x and 4.0.x, the two versions in active support as of April 2026.

Why Spring Boot Still Wins in 2026

Spring Boot was first released in April 2014 to eliminate the XML-heavy ceremony of classic Spring. Twelve years and four major versions later, it is still the framework that pays the bills at most banks, telcos, retailers, and SaaS vendors. The official Spring Boot GitHub repository sits among the most-starred Java projects on the platform, and the wider Spring ecosystem (Spring Framework, Security, Data, Cloud, Batch, Kafka) is updated on a synchronized release train that lands every May and November.

The 2026 generation matters because three long-running pain points have effectively disappeared. Virtual threads – the Project Loom feature shipped in Java 21 – let a single Spring Boot service handle tens of thousands of concurrent requests without reactive code. AOT compilation with GraalVM cuts startup from seconds to milliseconds, which finally makes Spring viable for serverless. And the Spring Framework 7 baseline brings native Jakarta EE 11 APIs, removing the last javax-vs-jakarta migration debt that haunted teams on legacy stacks.

If you are weighing alternatives, our C# vs Java 2026 comparison and the Kotlin vs Java breakdown cover language-level trade-offs in depth. For a non-JVM perspective, our Python vs Java benchmarks show where each language’s runtime shines.

Spring Boot 2026 Version Matrix and Support Windows

Before writing a single line of code, choose a version that will still receive security patches when your application reaches production. Spring Boot follows a strict 12-month OSS support window from initial GA, with an additional 12 months of commercial support available through VMware Tanzu and HeroDevs.

VersionGA DateOSS End-of-SupportJava BaselineSpring FrameworkRecommended For
Spring Boot 3.3.xMay 2024June 30, 2025 (ended)Java 176.1Legacy maintenance only
Spring Boot 3.4.xNovember 2024December 31, 2025 (ended)Java 176.2Commercial support only
Spring Boot 3.5.xMay 2025June 30, 2026Java 176.2Stable LTS-style choice
Spring Boot 4.0.xNovember 2025December 31, 2026Java 17–257.0New greenfield projects
Spring Boot 4.1.xMay 2026 (planned)June 2027 (planned)Java 17–257.1Bleeding edge / preview

For this tutorial we target Spring Boot 4.0.6 on Java 21. Java 21 is the current LTS release, supported through September 2031, and it gives you virtual threads, pattern matching for switch, and record patterns out of the box. If you cannot move off Java 17, every code sample below works on Spring Boot 3.5.x with two changes: drop the jakarta.* imports back to jakarta.* (they are identical) and replace the Spring Framework 7 RestClient.Builder autoconfiguration with the 6.2 equivalent.

Prerequisites: Tools and Versions You Need

This Spring Boot tutorial assumes a basic command-line comfort level, but no prior Spring experience. You will need the following installed before Step 1.

  • Java Development Kit 21 (Temurin 21.0.5+ recommended) – download from Adoptium or use SDKMAN.
  • Maven 3.9.9+ or Gradle 8.10+ – Maven is used in this tutorial because Spring Initializr defaults to it.
  • Docker Desktop 4.36+ or Docker Engine 27+ – for PostgreSQL and Testcontainers.
  • An IDE – IntelliJ IDEA Community 2024.3 or VS Code with the Spring Boot Extension Pack.
  • HTTPie 3.2+ or curl – for testing API endpoints.
  • Git 2.45+ – for source control and CI.
  • 8 GB RAM minimum, 16 GB recommended once Testcontainers and IntelliJ run together.

Verify your toolchain with the following commands before continuing. If any version is older than the floor listed above, upgrade now – older Java releases will silently miss virtual thread features, and older Maven versions cannot resolve the Spring Boot 4.0 BOM correctly.

$ java --version
openjdk 21.0.5 2024-10-15 LTS
OpenJDK Runtime Environment Temurin-21.0.5+11 (build 21.0.5+11-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.5+11 (build 21.0.5+11-LTS, mixed mode)

$ mvn --version
Apache Maven 3.9.9 (8e8579a9e76f7d015ee5ec7bfcdc97d260186937)
Maven home: /usr/local/Cellar/maven/3.9.9/libexec
Java version: 21.0.5

$ docker --version
Docker version 27.5.1, build 9f9e405

$ git --version
git version 2.46.2

Step 1: Generate the Project with Spring Initializr

Every Spring Boot project starts at start.spring.io. The web UI is convenient, but the underlying REST API is faster and scriptable, which is what professional teams use in their bootstrap scripts. Run the following command to download a fully-configured Maven project tarball:

$ curl https://start.spring.io/starter.tgz 
 -d type=maven-project 
 -d language=java 
 -d bootVersion=4.0.6 
 -d baseDir=bookstore-api 
 -d groupId=org.techinsider 
 -d artifactId=bookstore-api 
 -d name=bookstore-api 
 -d packageName=org.techinsider.bookstore 
 -d packaging=jar 
 -d javaVersion=21 
 -d dependencies=web,data-jpa,validation,security,actuator,postgresql,docker-compose,testcontainers 
 | tar -xzvf -

$ cd bookstore-api

That single command requests Spring Web (the embedded Tomcat-based MVC stack), Spring Data JPA (Hibernate 7.0 ORM), Bean Validation 3.1, Spring Security, Actuator (health and metrics endpoints), the PostgreSQL JDBC driver, Docker Compose support (auto-starts containers in dev), and Testcontainers integration. Spring Initializr writes a complete pom.xml and a stub application class.

Inspect the Generated POM

Open pom.xml and verify three things. The <parent> block points at spring-boot-starter-parent:4.0.6, the <java.version> property is 21, and the spring-boot-maven-plugin is present. That plugin is what turns your project into the famous self-executable fat JAR with java -jar.

<parent>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-parent</artifactId>
 <version>4.0.6</version>
</parent>

<properties>
 <java.version>21</java.version>
</properties>

<build>
 <plugins>
 <plugin>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-maven-plugin</artifactId>
 </plugin>
 </plugins>
</build>

Pitfall #1: If you copied the pom.xml from a 3.x tutorial, the parent will reference spring-boot-starter-parent:3.4.x. Mixing a 3.x parent with the 4.0 BOM will resolve random transitive dependencies and produce baffling NoClassDefFoundError failures at runtime. Always regenerate the POM with Initializr when bumping major versions.

Step 2: Run the Empty Application

Before adding any business code, confirm the scaffold boots cleanly. From inside bookstore-api, launch the embedded Tomcat server:

$ ./mvnw spring-boot:run

You should see the iconic ASCII-art Spring banner, followed by Hibernate, Tomcat, and Spring Security log lines. The crucial line at the bottom – Started BookstoreApiApplication in 1.834 seconds (process running for 2.117) – confirms the application is healthy. Spring Boot 4.0 with virtual threads typically boots a starter project in 1.5–2.5 seconds on Apple Silicon, compared with 3–4 seconds on Spring Boot 2.7.

By default Spring Security adds basic authentication with a randomly generated password printed to the console. Visit http://localhost:8080/actuator/health in your browser, supply user user and the printed password, and confirm the JSON response shows {"status":"UP"}. We will replace this auto-generated security in Step 10.

Step 3: Configure PostgreSQL with Docker Compose

Real services need real databases. Because we requested the docker-compose dependency, Spring Boot will auto-start any containers declared in compose.yaml at the project root. Create that file now:

services:
 postgres:
 image: postgres:17.2-alpine
 environment:
 POSTGRES_DB: bookstore
 POSTGRES_USER: bookstore
 POSTGRES_PASSWORD: secret
 ports:
 - "5432:5432"
 healthcheck:
 test: ["CMD-SHELL", "pg_isready -U bookstore"]
 interval: 5s
 timeout: 3s
 retries: 5

PostgreSQL 17.2 is the most recent stable release as of April 2026 and pairs well with Hibernate 7’s improved JSONB and identity-column support. Now point Spring at the database by adding the following lines to src/main/resources/application.properties:

spring.application.name=bookstore-api
spring.datasource.url=jdbc:postgresql://localhost:5432/bookstore
spring.datasource.username=bookstore
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=DEBUG

# Virtual threads β€” Spring Boot 4 default, but explicit is clearer
spring.threads.virtual.enabled=true

# Actuator: expose more endpoints during development
management.endpoints.web.exposure.include=health,info,metrics,prometheus,env

Restart the application with ./mvnw spring-boot:run. Spring Boot will detect compose.yaml, run docker compose up behind the scenes, wait for the pg_isready healthcheck to pass, and then connect to the database. The first run downloads the Postgres image (~120 MB) and takes 30–45 seconds; subsequent runs are sub-second.

Step 4: Model the Domain with JPA Entities

The Bookstore API has a single aggregate root: Book. Create src/main/java/org/techinsider/bookstore/book/Book.java with the following content. Note the use of Java records-style validation annotations and Hibernate 7’s improved generated-value handling.

package org.techinsider.bookstore.book;

import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;

@Entity
@Table(name = "books")
public class Book {

 @Id
 @GeneratedValue(strategy = GenerationType.IDENTITY)
 private Long id;

 @NotBlank
 @Size(max = 200)
 @Column(nullable = false)
 private String title;

 @NotBlank
 @Size(max = 100)
 @Column(nullable = false)
 private String author;

 @Pattern(regexp = "^(?:97[89])?[0-9]{9}[0-9X]$", message = "Invalid ISBN")
 @Column(unique = true, nullable = false, length = 13)
 private String isbn;

 @NotNull
 @DecimalMin(value = "0.00", inclusive = true)
 @Column(nullable = false, precision = 10, scale = 2)
 private BigDecimal price;

 @Column(nullable = false, updatable = false)
 private Instant createdAt = Instant.now();

 // getters and setters omitted for brevity β€” generate with your IDE
}

The spring.jpa.hibernate.ddl-auto=update setting tells Hibernate to create the books table on first run. In production you should switch this to validate and manage schema with Flyway or Liquibase – never let Hibernate auto-migrate live databases. Connect with psql after the next restart to verify the table exists:

$ docker compose exec postgres psql -U bookstore -d bookstore -c "d books"
 Table "public.books"
 Column | Type | Collation | Nullable | Default
-------------+-----------------------------+-----------+----------+------------------------------------
 id | bigint | | not null | generated by default as identity
 author | varchar(100) | | not null |
 created_at | timestamp(6) with time zone | | not null |
 isbn | varchar(13) | | not null |
 price | numeric(10,2) | | not null |
 title | varchar(200) | | not null |
Indexes:
 "books_pkey" PRIMARY KEY, btree (id)
 "books_isbn_key" UNIQUE CONSTRAINT, btree (isbn)

Step 5: Build the Repository Layer

Spring Data JPA generates repository implementations at runtime from interface declarations. The interface alone gives you 18 inherited methods including findAll(Pageable), save, findById, deleteById, and count. Add custom finders by following the method-name conventions or with @Query.

package org.techinsider.bookstore.book;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.math.BigDecimal;
import java.util.Optional;

public interface BookRepository extends JpaRepository<Book, Long> {

 Optional<Book> findByIsbn(String isbn);

 Page<Book> findByAuthorContainingIgnoreCase(String author, Pageable pageable);

 @Query("select b from Book b where b.price <= :max order by b.price asc")
 Page<Book> findCheaperThan(@Param("max") BigDecimal max, Pageable pageable);
}

Pitfall #2: Method-name queries break silently when you rename an entity field. Spring Data validates method names at startup, but if you ship the rename without restarting your IDE you may not notice the PropertyReferenceException until CI fails. Add an integration test that calls every repository method to catch this early.

Step 6: Add the Service Layer with Business Rules

Resist the urge to expose BookRepository directly from a controller. A thin service layer gives you a place to enforce business rules, batch operations into transactions, and unit-test logic without an HTTP context. Create BookService.java:

package org.techinsider.bookstore.book;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

@Service
@Transactional
public class BookService {

 private final BookRepository repository;

 public BookService(BookRepository repository) {
 this.repository = repository;
 }

 public Page<Book> list(Pageable pageable) {
 return repository.findAll(pageable);
 }

 public Book getById(Long id) {
 return repository.findById(id)
 .orElseThrow(() -> new BookNotFoundException(id));
 }

 public Book create(Book book) {
 repository.findByIsbn(book.getIsbn())
 .ifPresent(b -> { throw new DuplicateIsbnException(book.getIsbn()); });
 return repository.save(book);
 }

 public Book update(Long id, Book changes) {
 Book existing = getById(id);
 existing.setTitle(changes.getTitle());
 existing.setAuthor(changes.getAuthor());
 existing.setPrice(changes.getPrice());
 return existing; // dirty-checked by Hibernate, no save() needed
 }

 public void delete(Long id) {
 if (!repository.existsById(id)) {
 throw new BookNotFoundException(id);
 }
 repository.deleteById(id);
 }

 public Page<Book> cheaperThan(BigDecimal max, Pageable pageable) {
 return repository.findCheaperThan(max, pageable);
 }
}

The two custom exceptions – BookNotFoundException and DuplicateIsbnException – should extend RuntimeException. Annotate them with @ResponseStatus(HttpStatus.NOT_FOUND) and HttpStatus.CONFLICT respectively, or handle them centrally with @ControllerAdvice in Step 8.

Step 7: Expose the REST Controller

Now we wire HTTP. Spring MVC’s @RestController annotation combines @Controller and @ResponseBody, returning JSON by default thanks to Jackson 2.18 on the classpath. Pagination comes free via Pageable argument resolution.

package org.techinsider.bookstore.book;

import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.net.URI;

@RestController
@RequestMapping("/api/v1/books")
public class BookController {

 private final BookService service;

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

 @GetMapping
 public Page<Book> list(Pageable pageable,
 @RequestParam(required = false) BigDecimal maxPrice) {
 return maxPrice == null
 ? service.list(pageable)
 : service.cheaperThan(maxPrice, pageable);
 }

 @GetMapping("/{id}")
 public Book get(@PathVariable Long id) {
 return service.getById(id);
 }

 @PostMapping
 public ResponseEntity<Book> create(@Valid @RequestBody Book book) {
 Book saved = service.create(book);
 return ResponseEntity
 .created(URI.create("/api/v1/books/" + saved.getId()))
 .body(saved);
 }

 @PutMapping("/{id}")
 public Book update(@PathVariable Long id, @Valid @RequestBody Book book) {
 return service.update(id, book);
 }

 @DeleteMapping("/{id}")
 public ResponseEntity<Void> delete(@PathVariable Long id) {
 service.delete(id);
 return ResponseEntity.noContent().build();
 }
}

Restart the server and POST a book. The default Spring Security configuration will reject anonymous writes, so temporarily disable it for testing by adding a SecurityFilterChain bean that permits all requests (we will tighten this in Step 10).

$ http POST :8080/api/v1/books 
 title="Effective Java" 
 author="Joshua Bloch" 
 isbn=9780134685991 
 price=42.99

HTTP/1.1 201 Created
Location: /api/v1/books/1
Content-Type: application/json

{
 "id": 1,
 "title": "Effective Java",
 "author": "Joshua Bloch",
 "isbn": "9780134685991",
 "price": 42.99,
 "createdAt": "2026-04-25T14:32:18.512Z"
}

Step 8: Centralize Error Handling with @ControllerAdvice

Production APIs return consistent error payloads. Spring 6 adopted RFC 7807 Problem Details as the default error format, and Spring Boot 4 makes it the recommended pattern. Create GlobalExceptionHandler.java:

package org.techinsider.bookstore.book;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

 @ExceptionHandler(BookNotFoundException.class)
 public ProblemDetail handleNotFound(BookNotFoundException ex) {
 ProblemDetail pd = ProblemDetail.forStatusAndDetail(
 HttpStatus.NOT_FOUND, ex.getMessage());
 pd.setTitle("Book Not Found");
 return pd;
 }

 @ExceptionHandler(DuplicateIsbnException.class)
 public ProblemDetail handleDuplicate(DuplicateIsbnException ex) {
 ProblemDetail pd = ProblemDetail.forStatusAndDetail(
 HttpStatus.CONFLICT, ex.getMessage());
 pd.setTitle("Duplicate ISBN");
 return pd;
 }

 @ExceptionHandler(MethodArgumentNotValidException.class)
 public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
 String details = ex.getBindingResult().getFieldErrors().stream()
 .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
 .collect(Collectors.joining(", "));
 ProblemDetail pd = ProblemDetail.forStatusAndDetail(
 HttpStatus.BAD_REQUEST, details);
 pd.setTitle("Validation Failed");
 return pd;
 }
}

Now an invalid POST returns a clean JSON problem document with the correct HTTP status, instead of Spring’s verbose default 500 stack trace. Pair this with the application/problem+json media type if your clients enforce strict content negotiation.

Step 9: Validate Input with Jakarta Bean Validation

The @Valid annotation on the controller method already triggers field-level validation defined on the Book entity (@NotBlank, @Size, @Pattern). For more complex rules, switch to a dedicated DTO record so you can validate input separately from persistence concerns:

public record CreateBookRequest(
 @NotBlank @Size(max = 200) String title,
 @NotBlank @Size(max = 100) String author,
 @Pattern(regexp = "^(?:97[89])?[0-9]{9}[0-9X]$") String isbn,
 @NotNull @DecimalMin("0.00") @Digits(integer = 8, fraction = 2) BigDecimal price
) {}

Records are immutable and pattern-match cleanly with Java 21 switch expressions. They also avoid the "leaky entity" antipattern where attackers can force-set internal fields like id or createdAt by including them in the JSON body.

Pitfall #3: Forgetting @Valid on the request body silently disables validation. Spring will not warn you. The defensive practice is to write a single test that POSTs an empty body and asserts a 400 response – every controller method should pass that test.

Step 10: Lock Down the API with JWT and Spring Security

Spring Security 6.4 (bundled with Spring Boot 4) ships native OAuth2 Resource Server support for JWTs, eliminating the third-party libraries previously required. Add the OAuth2 resource server starter:

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Configure your security chain to require a valid JWT for write operations while permitting public reads:

package org.techinsider.bookstore.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

 @Bean
 public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
 http
 .csrf(csrf -> csrf.disable())
 .authorizeHttpRequests(auth -> auth
 .requestMatchers(HttpMethod.GET, "/api/v1/books/**").permitAll()
 .requestMatchers("/actuator/health", "/actuator/info").permitAll()
 .anyRequest().authenticated())
 .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
 return http.build();
 }
}

Point Spring at your authorization server’s JWKS endpoint via application.properties:

spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.example.com/realms/bookstore

For local development, run Keycloak 26 in Docker, create a bookstore realm, and request access tokens with the password grant. Real production deployments will integrate with Auth0, Okta, AWS Cognito, or Azure Entra ID – all of them are JWT-compliant and require zero code changes.

Step 11: Document the API with OpenAPI 3.1

Self-documenting APIs are not optional in 2026. Add the springdoc-openapi starter and you get a full OpenAPI 3.1 spec at /v3/api-docs plus a Swagger UI at /swagger-ui.html generated from your controllers and DTOs.

<dependency>
 <groupId>org.springdoc</groupId>
 <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
 <version>2.7.0</version>
</dependency>

Annotate your controller with @Tag and @Operation for human-readable summaries:

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

 @Operation(summary = "List books", description = "Returns a paginated list of books, optionally filtered by maxPrice")
 @GetMapping
 public Page<Book> list(...) { ... }
}

Whitelist the docs endpoints in SecurityConfig with permitAll() for paths matching /v3/api-docs/** and /swagger-ui/**, otherwise users will hit a 401 before they ever see the UI.

Step 12: Write Tests with JUnit 5 and Testcontainers

Spring Boot ships JUnit 5.11, Mockito 5.14, AssertJ, and Testcontainers 1.20 in the spring-boot-starter-test dependency. The combination gives you three test layers:

  • Unit tests – pure Java, no Spring context, using Mockito to stub the repository.
  • Slice tests – @WebMvcTest spins up only the MVC layer with a MockMvc client. Boots in ~600 ms.
  • Integration tests – @SpringBootTest with @Testcontainers running PostgreSQL in Docker. Boots in ~6 s but proves the SQL really works.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class BookControllerIT {

 @Container
 static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17.2-alpine")
 .withDatabaseName("bookstore")
 .withUsername("test")
 .withPassword("test");

 @DynamicPropertySource
 static void registerProps(DynamicPropertyRegistry registry) {
 registry.add("spring.datasource.url", postgres::getJdbcUrl);
 registry.add("spring.datasource.username", postgres::getUsername);
 registry.add("spring.datasource.password", postgres::getPassword);
 }

 @Autowired TestRestTemplate rest;

 @Test
 void createsAndRetrievesBook() {
 Book book = new Book("Effective Java", "Joshua Bloch",
 "9780134685991", new BigDecimal("42.99"));
 ResponseEntity<Book> created = rest.postForEntity("/api/v1/books", book, Book.class);
 assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
 assertThat(created.getBody().getId()).isPositive();
 }
}

Pitfall #4: Testcontainers requires a running Docker daemon. CI runners that strip Docker access (some restricted Kubernetes-based runners) will skip the test silently. Detect this with DockerClientFactory.instance().isDockerAvailable() in a @BeforeAll block and fail loudly. For tips on Pytest patterns that translate well to JUnit, see our Pytest tutorial.

Step 13: Containerize and Deploy with Docker and CI/CD

Spring Boot’s Maven plugin can build an OCI image in one command without writing a Dockerfile. The image uses the Paketo Buildpacks builder under the hood and produces small, layered, non-root images that score well on Trivy and Grype scans.

$ ./mvnw spring-boot:build-image 
 -Dspring-boot.build-image.imageName=ghcr.io/techinsider/bookstore-api:1.0.0

[INFO] Successfully built image 'ghcr.io/techinsider/bookstore-api:1.0.0'
[INFO] Total time: 01:42 min

Run the resulting image with the JVM tuned for containers:

$ docker run --rm -p 8080:8080 
 -e SPRING_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/bookstore 
 -e SPRING_DATASOURCE_USERNAME=bookstore 
 -e SPRING_DATASOURCE_PASSWORD=secret 
 ghcr.io/techinsider/bookstore-api:1.0.0

For continuous deployment, add a GitHub Actions workflow that builds, tests, and pushes the image on every tag. Our GitHub Actions tutorial covers the full pipeline including caching the Maven dependency tree (~70% faster builds) and Docker layer caching.

Going Native: GraalVM AOT Compilation

Spring Boot 4.0 elevates GraalVM AOT compilation from experimental to first-class. A native image of the Bookstore API starts in roughly 70 milliseconds and consumes about 90 MB of RAM, compared with the JVM’s 1.5–2 second cold start and 250–350 MB heap. The trade-off is build time – native compilation can take 90–120 seconds – and limited reflection support.

MetricJVM (Spring Boot 4.0)Native Image (GraalVM 23)Improvement
Cold start1,834 ms72 ms25Γ— faster
Resident memory312 MB94 MB3.3Γ— smaller
Image size284 MB118 MB2.4Γ— smaller
Throughput (req/s, steady state)27,50021,800JVM 26% faster
Build time11 s108 sJVM 9.8Γ— faster

Build a native image with the native Maven profile (auto-added by Initializr when you check the box):

$ ./mvnw -Pnative native:compile
$ ./target/bookstore-api
2026-04-25T15:02:11.481Z INFO 1 --- [ main] o.t.bookstore.BookstoreApiApplication : Started BookstoreApiApplication in 0.072 seconds (process running for 0.087)

Use native images for short-lived workloads – AWS Lambda, Google Cloud Run cold starts, batch jobs, CLI tools. Stick with the JVM for long-running monoliths where peak throughput matters more than startup latency.

Observability with Actuator, Micrometer, and Prometheus

Spring Boot Actuator is a production swiss army knife. With one starter you get health checks, metrics, environment introspection, thread dumps, heap dumps, log-level changes at runtime, and integration with Prometheus, Datadog, New Relic, Dynatrace, and OpenTelemetry. Add the Prometheus registry:

<dependency>
 <groupId>io.micrometer</groupId>
 <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

Hit http://localhost:8080/actuator/prometheus and you will see hundreds of pre-instrumented metrics: JVM heap by generation, GC pause times, HikariCP connection pool stats, HTTP request latency by URI and status code, and Tomcat thread pool utilization. Custom application metrics are a single-line annotation:

@Timed("books.create")
@PostMapping
public ResponseEntity<Book> create(@Valid @RequestBody Book book) { ... }

For distributed tracing, add the OpenTelemetry starter and Spring Boot will auto-instrument every controller method, JDBC call, RestClient request, and Kafka publish. Traces stream to any OTLP-compatible backend.

Spring Boot vs Quarkus vs Micronaut: 2026 Performance

Spring Boot’s competitors have caught up on startup time and memory thanks to AOT, but Spring still wins on ecosystem breadth. Below are observed numbers running an identical CRUD service against PostgreSQL with 50 virtual users (k6 load test, AWS c6i.large).

FrameworkLatest VersionJVM Cold StartNative Cold StartThroughput (req/s)Maven Central Downloads/mo
Spring Boot4.0.61.8 s72 ms27,500~85 million
Quarkus3.181.4 s48 ms26,200~6 million
Micronaut4.71.1 s52 ms25,800~3 million
Helidon4.10.9 s41 ms24,100<1 million

Pick Quarkus if your team is Red Hat-aligned and you live on Kubernetes; Micronaut if compile-time DI and lower memory matter more than ecosystem; Helidon if you are deeply invested in Oracle Cloud. For everything else – and for the largest pool of available developers – Spring Boot is still the safe default.

Common Pitfalls and How to Avoid Them

The following six pitfalls account for the vast majority of Spring Boot support tickets we see in the field. Treat this list as the post-mortem you do not want to write.

  • Pitfall #5: N+1 query explosions. JPA lazy associations look free in code but emit one query per entity at render time. Always project DTOs with explicit JPQL JOIN FETCH when serializing collections.
  • Pitfall #6: Forgetting @Transactional on multi-step service methods. A repository save outside a transaction commits immediately and cannot be rolled back when the next step fails.
  • Pitfall #7: Component-scanning the wrong package. Putting @SpringBootApplication in the wrong package scans nothing or everything. Keep it at the root package of your code (org.techinsider.bookstore) so all sub-packages are auto-discovered.
  • Pitfall #8: Mixing reactive and blocking code. WebFlux and WebMvc are mutually exclusive in a single application. Decide up front; with virtual threads on Java 21+, classic WebMvc is now a perfectly valid choice for high-concurrency workloads.
  • Pitfall #9: Hard-coding secrets. Never commit DB passwords or JWT secrets to application.properties. Use application-prod.yml, environment variables, or – better – Spring Cloud Config and HashiCorp Vault.
  • Pitfall #10: Skipping the BOM. Manually pinning Spring Framework or Hibernate versions inside a Spring Boot project breaks dependency resolution. Always rely on the spring-boot-starter-parent BOM to manage versions.

Troubleshooting Spring Boot: 8 Common Errors

When something goes wrong, the diagnostic surface area is large. Here are the eight failures developers hit most often, and the precise fix for each.

  • Port 8080 already in use: Another Spring Boot, Tomcat, or Jenkins instance owns the port. Kill it with lsof -i :8080 + kill -9 PID or change the port via server.port=8081.
  • Failed to determine a suitable driver class: The PostgreSQL driver is missing or the URL prefix is wrong. Confirm org.postgresql:postgresql is on the classpath and the URL starts with jdbc:postgresql://.
  • Could not autowire. No beans of type ...: Missing @Component, @Service, or @Repository on the class, or the class lives outside the component-scan path.
  • BeanCurrentlyInCreationException: Circular dependency between two beans. Refactor to constructor injection on one side and setter injection on the other, or extract a third bean.
  • Whitelabel Error Page (404): The endpoint URL doesn’t match any controller. Confirm @RequestMapping path, HTTP method, and verify the controller is on the scan path with /actuator/mappings.
  • org.hibernate.LazyInitializationException: Accessing a lazy collection outside a transaction or after the session has closed. Either fetch eagerly with JOIN FETCH or use @Transactional(readOnly = true) on the read path.
  • HikariPool-1 - Connection is not available: Connection pool exhausted. Raise spring.datasource.hikari.maximum-pool-size, hunt for missing transactions, and inspect /actuator/metrics/hikaricp.connections.active.
  • java.lang.OutOfMemoryError: Metaspace: Hot-reloading frameworks like DevTools accumulate ClassLoaders. Restart the JVM, raise -XX:MaxMetaspaceSize=512m, or disable DevTools in CI builds.

Advanced Tips: Pushing Spring Boot to Production

Once the basics work, these patterns separate hobby projects from systems that survive a Black Friday spike.

  • Connection pool tuning: Default HikariCP is 10 connections. For services on c6i.xlarge or larger, set maximum-pool-size to the number of CPU cores plus disk spindles, then raise it gradually until the database CPU saturates.
  • Schema migrations with Flyway: Add flyway-core, place SQL files in src/main/resources/db/migration, and switch ddl-auto to validate. Flyway runs automatically on startup and refuses to deploy when migrations are inconsistent.
  • Async work with virtual threads: Annotate methods with @Async and configure a VirtualThreadTaskExecutor. Java 21 lets a single service handle 50,000+ concurrent inflight tasks.
  • Caching with Caffeine: Add spring-boot-starter-cache + com.github.ben-manes.caffeine:caffeine, annotate hot methods with @Cacheable, and watch p99 latency drop 80%+.
  • Rate limiting with Bucket4j: Wrap controllers with a HandlerInterceptor that consumes tokens from a Bucket4j bucket. Combine with Redis for cluster-wide quotas.
  • Background tasks with Spring Scheduling: The @Scheduled annotation runs methods on cron expressions. For distributed locking across instances, layer on ShedLock or Quartz.
  • Graceful shutdown: Spring Boot 4 enables server.shutdown=graceful by default with a 30-second drain. Tune spring.lifecycle.timeout-per-shutdown-phase to match your load balancer’s deregistration window.
  • Structured logging: Set logging.structured.format.console=ecs to emit Elastic Common Schema JSON logs that Logstash, Loki, and Datadog parse without configuration.

The Complete Bookstore API Project Structure

After working through all 13 steps you should have the following directory layout. Anything more is over-engineering for a service of this size; anything less means a missing concern.

bookstore-api/
β”œβ”€β”€ compose.yaml
β”œβ”€β”€ pom.xml
β”œβ”€β”€ mvnw
β”œβ”€β”€ mvnw.cmd
└── src/
 β”œβ”€β”€ main/
 β”‚ β”œβ”€β”€ java/org/techinsider/bookstore/
 β”‚ β”‚ β”œβ”€β”€ BookstoreApiApplication.java
 β”‚ β”‚ β”œβ”€β”€ book/
 β”‚ β”‚ β”‚ β”œβ”€β”€ Book.java
 β”‚ β”‚ β”‚ β”œβ”€β”€ BookController.java
 β”‚ β”‚ β”‚ β”œβ”€β”€ BookNotFoundException.java
 β”‚ β”‚ β”‚ β”œβ”€β”€ BookRepository.java
 β”‚ β”‚ β”‚ β”œβ”€β”€ BookService.java
 β”‚ β”‚ β”‚ β”œβ”€β”€ CreateBookRequest.java
 β”‚ β”‚ β”‚ β”œβ”€β”€ DuplicateIsbnException.java
 β”‚ β”‚ β”‚ └── GlobalExceptionHandler.java
 β”‚ β”‚ └── security/
 β”‚ β”‚ └── SecurityConfig.java
 β”‚ └── resources/
 β”‚ β”œβ”€β”€ application.properties
 β”‚ └── db/migration/V1__create_books_table.sql
 └── test/
 └── java/org/techinsider/bookstore/
 β”œβ”€β”€ book/
 β”‚ β”œβ”€β”€ BookControllerTest.java # @WebMvcTest slice
 β”‚ β”œβ”€β”€ BookServiceTest.java # pure unit
 β”‚ └── BookControllerIT.java # @SpringBootTest + Testcontainers
 └── BookstoreApiApplicationTests.java

The full source code, including the OpenAPI configuration, Flyway migrations, and a GitHub Actions workflow, is available as a reference implementation. Clone it, run ./mvnw spring-boot:run, and you have a production-ready REST API in under five minutes.

Frequently Asked Questions

What is the latest Spring Boot version in 2026?

Spring Boot 4.0.6 is the most recent release as of April 2026, with end-of-OSS-support scheduled for December 31, 2026. Spring Boot 3.5.x is the parallel stable line for teams still on Java 17, supported through June 30, 2026. Spring Boot 4.1 is planned for May 2026.

Which Java version do I need for Spring Boot?

Spring Boot 4.0 supports Java 17 through Java 25. Java 21 is the recommended LTS choice in 2026 because it unlocks virtual threads, pattern matching for switch, and record patterns. Spring Boot 3.5 still runs on Java 17 if you cannot upgrade your runtime.

Spring Boot vs Quarkus – which is better?

Spring Boot wins on ecosystem breadth, available developer talent, and starter library coverage. Quarkus wins marginally on native image startup and memory footprint. For greenfield Kubernetes-native projects, Quarkus is competitive; for everything else – and for the largest hiring pool – Spring Boot remains the safer default.

How long does it take to learn Spring Boot?

A developer with one year of Java experience can build a CRUD REST API in a weekend by following this tutorial. Reaching production fluency – Security, observability, testing, deployment – typically takes 2–3 months of daily use. The Spring ecosystem (Cloud, Batch, Integration, Kafka) is much larger than Spring Boot itself.

Is Spring Boot still relevant in 2026?

Yes – by a wide margin. The JetBrains Developer Ecosystem 2024 survey shows Spring Boot used by more than 70% of Java developers, and JetBrains, Red Hat, and Oracle all maintain first-class IDE and runtime support. Spring’s six-month release cadence keeps pace with Java itself, and the Spring AI project gives the framework first-class integration with Claude, GPT-5, and Gemini.

Maven or Gradle for Spring Boot projects?

Either works equally well – Spring Initializr generates both. Maven is simpler and dominates enterprise Java; Gradle is faster on large multi-module builds and offers a more powerful DSL. New teams typically start with Maven; teams with 50+ modules typically end up on Gradle.

Should I use WebMvc or WebFlux?

Use classic WebMvc (the stack in this tutorial) for the vast majority of CRUD APIs. With virtual threads on Java 21, WebMvc handles tens of thousands of concurrent requests without the cognitive overhead of reactive code. Reach for WebFlux only when you need backpressure on streaming data or are integrating with reactive databases like R2DBC.

How do I deploy Spring Boot to production?

Build a fat JAR with ./mvnw package and run it with java -jar, build an OCI image with ./mvnw spring-boot:build-image and ship it to Kubernetes, or compile a native image with GraalVM and deploy it to AWS Lambda or Cloud Run. All three patterns are first-class in 2026; the right choice depends on your traffic profile and existing infrastructure.

Related Coverage

Spring Boot in 2026 is leaner, faster, and more opinionated than at any point in its history. The combination of Java 21 virtual threads, GraalVM native images, structured logging, and out-of-the-box JWT security closes the gap between greenfield prototype and revenue-bearing production code. Work through the 13 steps above, keep the eight troubleshooting fixes bookmarked, and you will ship your first Spring Boot REST API the same week you start.

πŸ‘ Sofia LindstrΓΆm

Sofia LindstrΓΆm

Editor-in-Chief

Sofia LindstrΓΆm is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles
πŸ‘ Tech Insider
Tech
Insider

Tech Insider delivers in-depth coverage of the technologies shaping the future: AI, cybersecurity, cloud computing, hardware, and the trends that matter.

Company

Explore

Categories

Β© 2026 Tech Insider Media AB. All rights reserved.