Spring Boot REST API: Building Production-Ready Microservices with Java

Learn how to build production-ready Spring Boot microservices in 2026. This tutorial covers Spring Initializr, JPA entities, service layers, REST controllers, global exception handling, OpenAPI docs, JUnit 5 testing, and Docker deployment — with real code examples.

Spring Boot REST API: Building Production-Ready Microservices with Java

By Elhassane Mehdioui — Full Stack Web Developer specialised in Spring Boot


Building a Spring Boot REST API that is truly production-ready requires more than just a few @GetMapping annotations. It demands a disciplined architecture, robust error handling, comprehensive tests, living API documentation, and a reproducible deployment strategy. In this tutorial you will walk through every layer of a production-grade Spring Boot microservice — from bootstrapping with Spring Initializr all the way to running a containerised service with Docker.

Whether you are a Java developer stepping into microservices for the first time or an experienced engineer looking to sharpen your Spring Boot REST API workflow for 2026, this guide has you covered.


1. Bootstrapping the Project with Spring Initializr

The fastest and most reliable way to start a Spring Boot project is Spring Initializr. Head there and configure:

  • Project: Maven
  • Language: Java 21
  • Spring Boot: 3.3.x
  • Dependencies: Spring Web, Spring Data JPA, H2 Database (dev) / PostgreSQL (prod), Validation, Lombok, SpringDoc OpenAPI

Download the ZIP, extract it, and open it in your IDE. The generated pom.xml already includes a managed bill-of-materials (BOM) for all Spring dependencies, which eliminates version-conflict headaches.

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

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.5.0</version>
  </dependency>
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
  </dependency>
</dependencies>

2. Designing the JPA Entity

A clean domain model is the foundation of any Spring Boot JPA application. Here is a Product entity that demonstrates common patterns — auto-generated IDs, audit timestamps, and Jakarta Bean Validation constraints:

package com.example.catalog.domain;

import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.math.BigDecimal;
import java.time.Instant;

@Entity
@Table(name = "products")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class Product {

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

    @NotBlank(message = "Name must not be blank")
    @Size(max = 150)
    @Column(nullable = false, length = 150)
    private String name;

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

    @Size(max = 1000)
    private String description;

    @CreationTimestamp
    @Column(updatable = false)
    private Instant createdAt;

    @UpdateTimestamp
    private Instant updatedAt;
}

Notice that validation annotations live on the entity for database-level guarantees, but you will also apply them on the request DTO to fail fast before hitting the persistence layer.


3. Repository and the Service Layer

Spring Data JPA provides a JpaRepository that generates standard CRUD operations at runtime — no boilerplate SQL required:

public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByNameContainingIgnoreCase(String keyword);
}

The service layer is where business logic lives. Keeping it separate from the controller preserves testability and makes it easy to swap persistence implementations later:

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository repository;

    public List<Product> findAll() {
        return repository.findAll();
    }

    public Product findById(Long id) {
        return repository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id));
    }

    public Product create(ProductRequest request) {
        Product product = Product.builder()
            .name(request.name())
            .price(request.price())
            .description(request.description())
            .build();
        return repository.save(product);
    }

    public Product update(Long id, ProductRequest request) {
        Product existing = findById(id);
        existing.setName(request.name());
        existing.setPrice(request.price());
        existing.setDescription(request.description());
        return repository.save(existing);
    }

    public void delete(Long id) {
        repository.delete(findById(id));
    }
}

Using a Java record for the request DTO keeps the code concise and immutable:

public record ProductRequest(
    @NotBlank String name,
    @NotNull @DecimalMin("0.01") BigDecimal price,
    @Size(max = 1000) String description
) {}

4. Building the REST Controller with @RestController

The @RestController annotation combines @Controller and @ResponseBody, so every method return value is serialised to JSON automatically:

@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
@Tag(name = "Products", description = "Product catalogue endpoints")
public class ProductController {

    private final ProductService service;

    @GetMapping
    public ResponseEntity<List<Product>> list() {
        return ResponseEntity.ok(service.findAll());
    }

    @GetMapping("/{id}")
    public ResponseEntity<Product> get(@PathVariable Long id) {
        return ResponseEntity.ok(service.findById(id));
    }

    @PostMapping
    public ResponseEntity<Product> create(@Valid @RequestBody ProductRequest request) {
        Product created = service.create(request);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}").buildAndExpand(created.getId()).toUri();
        return ResponseEntity.created(location).body(created);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Product> update(
            @PathVariable Long id,
            @Valid @RequestBody ProductRequest request) {
        return ResponseEntity.ok(service.update(id, request));
    }

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

The @Valid annotation on @RequestBody triggers Jakarta Bean Validation before the method body executes. Any constraint violation throws a MethodArgumentNotValidException which you handle globally next.


5. Global Exception Handling with @ControllerAdvice

Centralising error handling with @ControllerAdvice ensures every endpoint returns a consistent, machine-readable error format:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse(404, ex.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
            .collect(Collectors.joining(", "));
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(new ErrorResponse(400, message));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse(500, "An unexpected error occurred"));
    }

    public record ErrorResponse(int status, String message) {}
}

6. Living API Documentation with OpenAPI / Swagger

SpringDoc OpenAPI 2.x auto-generates an OpenAPI 3 specification from your controller annotations. Add a configuration bean to describe the service:

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI catalogApiInfo() {
        return new OpenAPI()
            .info(new Info()
                .title("Product Catalog API")
                .description("Spring Boot REST API — production-ready microservices example")
                .version("1.0.0")
                .contact(new Contact()
                    .name("Elhassane Mehdioui")
                    .url("https://your-portfolio.dev")));
    }
}

After starting the application, navigate to http://localhost:8080/swagger-ui.html to explore and test every endpoint interactively. The raw specification is available at /v3/api-docs for import into Postman or API gateways.


7. Testing with JUnit 5

A production service must have tests at multiple layers. Here is a slice-based controller test using @WebMvcTest (no full application context required) and a service unit test with Mockito:

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired MockMvc mvc;
    @MockBean ProductService service;
    @Autowired ObjectMapper mapper;

    @Test
    void shouldReturn200WithProductList() throws Exception {
        given(service.findAll()).willReturn(List.of(
            Product.builder().id(1L).name("Widget").price(new BigDecimal("9.99")).build()
        ));

        mvc.perform(get("/api/v1/products").accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$[0].name").value("Widget"));
    }

    @Test
    void shouldReturn400WhenNameIsBlank() throws Exception {
        var bad = new ProductRequest("", new BigDecimal("5.00"), null);

        mvc.perform(post("/api/v1/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(mapper.writeValueAsString(bad)))
            .andExpect(status().isBadRequest());
    }
}

For integration tests that exercise the full stack including the database, annotate the class with @SpringBootTest and @AutoConfigureMockMvc, and use an in-memory H2 database in the test profile.


8. Docker Deployment

Containerising the Spring Boot microservice makes it portable across environments. Create a multi-stage Dockerfile to keep the final image small:

# --- Build stage ---
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN ./mvnw -q package -DskipTests

# --- Runtime stage ---
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /workspace/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Build and run:

docker build -t catalog-api:1.0.0 .
docker run -p 8080:8080 \
  -e SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/catalog \
  -e SPRING_DATASOURCE_USERNAME=postgres \
  -e SPRING_DATASOURCE_PASSWORD=secret \
  catalog-api:1.0.0

For local development with a real PostgreSQL instance, a docker-compose.yml is more ergonomic:

version: "3.9"
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: catalog
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"

  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/catalog
      SPRING_DATASOURCE_USERNAME: postgres
      SPRING_DATASOURCE_PASSWORD: secret
    depends_on:
      - db

Run everything with docker compose up --build.


9. Production Checklist

Before promoting a Spring Boot microservice to production, verify the following:

  • Profiles: separate application-dev.yml and application-prod.yml with environment-specific datasource and logging settings.
  • Health checks: the spring-boot-starter-actuator dependency exposes /actuator/health out of the box — wire it up to your container orchestrator's liveness and readiness probes.
  • Security: add spring-boot-starter-security and protect sensitive actuator endpoints; use JWT or OAuth2 for the REST API.
  • Observability: export metrics to Prometheus via the Micrometer integration and trace requests with OpenTelemetry.
  • Database migrations: manage schema evolution with Flyway or Liquibase rather than relying on spring.jpa.hibernate.ddl-auto=update.

Conclusion

You have now seen every essential building block of a production-ready Spring Boot REST API: scaffolding with Spring Initializr, modelling data with JPA entities, encapsulating logic in a service layer, exposing clean endpoints with @RestController and @Valid, handling errors uniformly with @ControllerAdvice, auto-generating Swagger documentation via OpenAPI, writing confidence-inspiring tests with JUnit 5, and shipping a lightweight Docker image.

The Spring Boot microservices ecosystem in 2026 is mature, well-documented, and battle-tested at scale. With the patterns shown here, your Java REST API will be ready to handle real production traffic from day one.


Elhassane Mehdioui is a Full Stack Web Developer specialised in Spring Boot, React, and cloud-native Java architectures.

Need a React or Next.js interface?

Modern, responsive front-ends built to perform.

I build fast, accessible UIs with React, Next.js, TypeScript and Tailwind CSS.

HassanOSSYS-00
SECURE CHANNEL · ACTIVE
INIT://