Skip to content
Home Java Spring Boot REST API: Complete Production Guide (2026)

Spring Boot REST API: Complete Production Guide (2026)

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Spring Boot → Topic 4 of 15
Build a production-ready Spring Boot REST API from scratch — project setup, JPA entities, DTOs with MapStruct, pagination, Swagger, Spring Security JWT, CORS, Actuator health checks, and integration testing with Testcontainers.
🧑‍💻 Beginner-friendly — no prior Java experience needed
In this tutorial, you'll learn
Build a production-ready Spring Boot REST API from scratch — project setup, JPA entities, DTOs with MapStruct, pagination, Swagger, Spring Security JWT, CORS, Actuator health checks, and integration testing with Testcontainers.
  • @RestController + @RequestMapping at class level, verb-specific annotations at method level. Keep the structure consistent across your codebase.
  • Use DTOs — never expose JPA entities as request or response bodies. Define UserDto, CreateUserRequest, UpdateUserRequest, and PagedResponse<T> explicitly.
  • Validate with @Valid + Bean Validation constraints. Handle MethodArgumentNotValidException in @RestControllerAdvice and return all field errors at once, not just the first.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • A REST API exposes resources over HTTP using stateless request/response cycles.
  • Spring Boot auto-configures Jackson (JSON), Tomcat (server), and validation, letting you focus on business logic.
  • Production readiness requires DTOs to hide your schema, pagination to prevent OOM, and global error handling.
  • Performance insight: Unbounded findAll() queries degrade silently; paginate from day one with Pageable.
  • Production insight: ddl-auto=create-drop in production wipes your database on restart—use validate and schema migration tools.
  • Biggest mistake: Exposing JPA entities directly as API responses leaks internal fields and causes serialization failures.
🚨 START HERE
Spring Boot REST API Quick Debug
Immediate checks for common production issues.
🟡Database connection failures on startup
Immediate ActionCheck datasource URL, credentials, and network connectivity.
Commands
docker compose logs db | tail -50
psql $DB_URL -c 'SELECT 1'
Fix NowVerify `spring.datasource.*` properties match environment. Ensure DB is reachable from the application's network.
🟠High latency on specific endpoint
Immediate ActionIdentify if it's a database query issue (N+1) or missing pagination.
Commands
Enable SQL logging: `spring.jpa.show-sql=true` and check for excessive queries.
Use `EXPLAIN ANALYZE` on slow queries identified in logs.
Fix NowAdd `@EntityGraph` for fetch joins or implement pagination with `Pageable`.
🔴Memory pressure / OOM errors
Immediate ActionCheck for unbounded result sets from repository calls.
Commands
jcmd <pid> GC.heap_dump /tmp/heap.hprof
Analyze heap dump for large collections of entities.
Fix NowReplace `findAll()` with paginated queries. Ensure DTOs are used to avoid loading full entity graphs.
Production IncidentThe ddl-auto=create-drop DisasterA staging environment with real customer data was wiped on restart because a tutorial's application.properties was copied verbatim.
SymptomAfter a routine deployment restart, all user data vanished. The application came up with empty tables.
AssumptionThe team assumed the database configuration was correct because the application started without errors in development.
Root causeThe property spring.jpa.hibernate.ddl-auto=create-drop was present in the production profile. Hibernate dropped and recreated all tables on shutdown/startup.
FixChanged the production property to spring.jpa.hibernate.ddl-auto=validate. Implemented Flyway for all schema migrations.
Key Lesson
Never use create-drop outside local development.Use validate in production and let Flyway/Liquibase own schema changes.Profile-specific configuration must be audited before deployment.
Production Debug GuideSymptom to resolution for Spring Boot REST APIs
API returns 500 with stack trace visible to clientCheck for missing @RestControllerAdvice or unhandled exceptions. Ensure a generic Exception handler logs server-side but returns a safe message.
POST/PUT/DELETE requests return 403 ForbiddenVerify CSRF is disabled in SecurityConfig for your stateless JWT API. CSRF is for browser cookie auth.
Endpoint times out with large datasetCheck if endpoint returns an unbounded list. Implement pagination with Pageable and cap page size server-side.
Health check passes but service is failingVerify /actuator/health includes database and critical dependency checks, not just JVM liveness.

A tutorial REST API has a controller, a service, and a repository. A production REST API has all of that plus security, pagination, DTO mapping, health checks, and integration tests that use a real database.

This guide builds from Spring Initializr to a deployable API. Each section explains the pattern, its purpose, and the production incidents that justify its existence.

Package naming follows io.thecodeforge.*. All examples are runnable. No hand-waving.

What is a REST API?

REST (Representational State Transfer) is an architectural style for distributed systems, not a protocol. A REST API exposes resources over HTTP — Users, Orders, Products. The URL identifies the resource; the HTTP method identifies the action.

The four core methods map to CRUD: GET retrieves data without side effects, POST creates a new resource, PUT replaces an existing resource in full, and DELETE removes it. PATCH handles partial updates.

A critical REST constraint is statelessness: each request must carry all information needed to process it. The server stores no session state between requests. This is what makes REST APIs horizontally scalable — any server in a cluster can handle any request without shared session state.

Spring Boot provides all the scaffolding: Jackson for JSON serialisation, Spring MVC for routing, Bean Validation for input checking, and embedded Tomcat so you can run your API with a single java -jar command.

By 2026, Spring Boot 3.3.x runs on Java 21 and virtual threads (Project Loom) are a first-class production option. Enabling them is a single property: spring.threads.virtual.enabled=true. For I/O-heavy REST APIs — the kind that spend most of their time waiting on database queries — virtual threads eliminate the need to tune thread pool sizes manually. The JVM schedules thousands of lightweight virtual threads on a small pool of carrier threads. You write the same blocking JDBC code you always have; the runtime handles the concurrency. This is worth knowing because the tuning advice of 'set server.tomcat.threads.max=200' that dominated Spring Boot guides for a decade is now secondary to just enabling virtual threads.

📊 Production Insight
Statelessness is the foundation of horizontal scaling. Any instance can handle any request without shared session state.
If you violate statelessness by storing session data, you force sticky sessions and break failover.
Rule: Design for statelessness from the start; move any required state to external stores like Redis.
🎯 Key Takeaway
REST is an architectural style centered on stateless resource manipulation via HTTP methods.
Spring Boot provides the auto-configured scaffolding to implement it quickly.
The constraint of statelessness is what enables reliable horizontal scaling.

Project Setup — Spring Initializr, pom.xml, and Directory Structure

Every production Spring Boot REST API starts the same way: Spring Initializr (start.spring.io) and a deliberate dependency selection. I've onboarded dozens of developers onto Spring Boot projects and the number-one stumbling block is always the same — they find a tutorial that starts at the controller and have no idea how the project came together.

Go to start.spring.io. Choose Maven, Java, Spring Boot 3.3.x. Group: io.thecodeforge. Artifact: user-service. Then add these dependencies:

  • Spring Web — the full MVC stack, embedded Tomcat, Jackson
  • Spring Data JPA — Hibernate ORM, @Entity support, JpaRepository
  • Validation — Jakarta Bean Validation, @NotBlank, @Email, @Size
  • H2 Database — in-memory DB for development (scope: runtime)
  • Lombok — eliminates boilerplate getters/setters/constructors
  • Spring Boot DevTools — hot reload during development

For production you'll swap H2 for PostgreSQL — add the driver then, not now. Starting with H2 means zero database setup to get running.

The directory structure that falls out of Initializr is a convention, not a suggestion. I've inherited codebases where someone decided to reorganise it. Every time, it creates confusion about where things live and breaks the assumptions Spring's component scanning relies on. Stick to the convention.

`` user-service/ ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── io/thecodeforge/userservice/ │ │ │ ├── UserServiceApplication.java ← @SpringBootApplication entry point │ │ │ ├── config/ ← SecurityConfig, CorsConfig, OpenApiConfig │ │ │ ├── controller/ ← @RestController classes │ │ │ ├── dto/ ← Request/response DTOs, PagedResponse │ │ │ ├── exception/ ← ResourceNotFoundException, GlobalExceptionHandler │ │ │ ├── mapper/ ← MapStruct interfaces │ │ │ ├── model/ ← @Entity classes │ │ │ ├── repository/ ← JpaRepository interfaces │ │ │ └── service/ ← @Service classes │ │ └── resources/ │ │ ├── application.properties ← shared defaults │ │ ├── application-dev.properties ← H2, verbose SQL, DDL auto-create │ │ └── application-prod.properties ← PostgreSQL, validate, minimal logging │ └── test/ │ └── java/ │ └── io/thecodeforge/userservice/ │ ├── controller/ ← @WebMvcTest tests │ ├── repository/ ← @DataJpaTest tests │ └── integration/ ← @SpringBootTest + Testcontainers └── pom.xml ``

Spring's component scanning starts from the package containing @SpringBootApplication and scans downward. Everything under io.thecodeforge.userservice is picked up automatically. If you move UserServiceApplication.java to a sub-package or put controllers in a sibling package, you break auto-scanning and beans go missing with zero helpful error message. This is the number-one structure mistake I see from developers migrating from other frameworks.

pom.xml · XML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

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

  <groupId>io.thecodeforge</groupId>
  <artifactId>user-service</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <name>user-service</name>

  <properties>
    <java.version>21</java.version>
    <mapstruct.version>1.5.5.Final</mapstruct.version>
  </properties>

  <dependencies>
    <!-- Core web stack: Spring MVC + embedded Tomcat + Jackson -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- JPA / Hibernate -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- Jakarta Bean Validation (@NotBlank, @Email, @Size...) -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- Security (JWT-based auth) -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- Actuator: /health, /metrics, /info endpoints -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <!-- Springdoc: auto-generated Swagger UI -->
    <dependency>
      <groupId>org.springdoc</groupId>
      <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
      <version>2.5.0</version>
    </dependency>

    <!-- H2 in-memory DB for development -->
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <scope>runtime</scope>
    </dependency>

    <!-- PostgreSQL for production -->
    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <scope>runtime</scope>
    </dependency>

    <!-- Lombok: removes boilerplate -->
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
    </dependency>

    <!-- MapStruct: compile-time DTO mapper -->
    <dependency>
      <groupId>org.mapstruct</groupId>
      <artifactId>mapstruct</artifactId>
      <version>${mapstruct.version}</version>
    </dependency>

    <!-- Testing -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.security</groupId>
      <artifactId>spring-security-test</artifactId>
      <scope>test</scope>
    </dependency>
    <!-- Testcontainers for real PostgreSQL in tests -->
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>postgresql</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>junit-jupiter</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
        <configuration>
          <!-- Exclude Lombok from the final JAR -->
          <excludes>
            <exclude>
              <groupId>org.projectlombok</groupId>
              <artifactId>lombok</artifactId>
            </exclude>
          </excludes>
        </configuration>
      </plugin>
      <!-- MapStruct annotation processor: Lombok MUST come before MapStruct -->
      <!-- Wrong order = MapStruct cannot see Lombok-generated getters/setters -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <configuration>
          <annotationProcessorPaths>
            <path>
              <groupId>org.projectlombok</groupId>
              <artifactId>lombok</artifactId>
            </path>
            <path>
              <groupId>org.mapstruct</groupId>
              <artifactId>mapstruct-processor</artifactId>
              <version>${mapstruct.version}</version>
            </path>
          </annotationProcessorPaths>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>
📊 Production Insight
The directory structure generated by Initializr is a convention Spring's component scanning relies on.
Reorganizing it breaks auto-configuration and confuses new team members about where components belong.
Rule: Stick to the convention. It's not a suggestion—it's infrastructure.
🎯 Key Takeaway
Start every project from Spring Initializr with deliberate dependency selection.
The generated directory structure is a convention that Spring's component scanning depends on—do not reorganize it.
Annotation processor order in Maven matters: always declare Lombok before MapStruct.

The Entity Class and application.properties — Foundation That Everything Builds On

Before your controller can serve users, you need a User entity and database configuration. This is where I see the most copy-paste mistakes in real codebases: people copy an entity from a tutorial without understanding what each annotation does, then wonder why their auto-generated IDs are wrong or why their timestamps aren't populating.

@Entity tells Hibernate this class maps to a database table. @Id marks the primary key. @GeneratedValue(strategy = GenerationType.IDENTITY) delegates ID generation to the database — the auto_increment or SERIAL column. Don't use GenerationType.AUTO in production; it's unpredictable across databases.

@CreatedDate and @LastModifiedDate with @EnableJpaAuditing on your application class gives you automatic audit timestamps. I've had to add these retroactively to a production system after a compliance audit required 'when was this record created?' — a 3-line addition that would have been free from day one.

For application.properties: the most important setting that's often wrong in tutorials is spring.jpa.hibernate.ddl-auto. Setting it to create-drop in production will wipe your database on restart. Use validate in production — Flyway or Liquibase manages the schema; Hibernate just checks it.

Two profiles matter: dev (H2, verbose SQL logging, DDL auto-create) and prod (real database, minimal logging, DDL validate only). The base application.properties sets shared defaults; profile-specific files override what differs.

User.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
package io.thecodeforge.userservice.model;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.Instant;

@Entity
@Table(
    name = "users",
    uniqueConstraints = @UniqueConstraint(columnNames = "email")
)
@EntityListeners(AuditingEntityListener.class) // enables @CreatedDate / @LastModifiedDate
@Getter @Setter @NoArgsConstructor
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    // IDENTITY = database auto_increment / SERIAL
    // Never use AUTO in production — behaviour differs across databases
    private Long id;

    @Column(name = "full_name", nullable = false, length = 100)
    private String name;

    @Column(nullable = false, unique = true, length = 255)
    private String email;

    @Column(nullable = false)
    private Integer age;

    @Enumerated(EnumType.STRING)  // store 'ACTIVE' not '0' — readable, refactor-safe
    @Column(nullable = false, length = 20)
    private UserStatus status = UserStatus.ACTIVE;

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

    @LastModifiedDate
    @Column(nullable = false)
    private Instant updatedAt;

    public enum UserStatus {
        ACTIVE, INACTIVE, SUSPENDED
    }
}
⚠ ddl-auto=create-drop in production wipes your database
I've seen this happen twice in my career — both times in staging environments that had real customer data. Someone copied a tutorial's application.properties verbatim. On the next restart, Hibernate dropped and recreated every table. The data was gone. Use validate in production without exception. Let Flyway or Liquibase own schema migrations.
📊 Production Insight
The ddl-auto setting is a critical production safety switch. create-drop is for development only—it destroys data on shutdown.
Using it in production bypasses all change management and auditing.
Rule: In production, use validate and manage schema changes with a migration tool like Flyway.
🎯 Key Takeaway
Entity classes map to database tables via @Entity; use GenerationType.IDENTITY for predictable ID generation.
The ddl-auto property must be validate in production to prevent data loss.
Enable JPA auditing with @EnableJpaAuditing for automatic @CreatedDate and @LastModifiedDate population.

application.properties — Dev and Prod Profiles

The properties files are not boilerplate — they are the configuration contract between your code and its environment. I treat them as first-class artefacts, reviewed as carefully as code.

The base application.properties holds settings that are identical across all environments: application name, Actuator exposure defaults, pagination defaults. Profile-specific files override only what differs. This means when someone reads application-prod.properties, they see exactly what production differs from the default — no noise, no duplication.

  • application.properties — shared defaults, virtual threads flag, common Actuator config
  • application-dev.properties — H2 datasource, verbose SQL logging, DDL auto-create for rapid iteration
  • application-prod.properties — PostgreSQL datasource (values from environment variables, never hardcoded), DDL validate, logging tuned down

The ${...} syntax in the prod properties file reads from environment variables at startup. This is the 12-factor app approach — configuration comes from the environment, not from files committed to source control. Your database password should never be in a .properties file in your repository.

Activate a profile with -Dspring.profiles.active=prod on the JVM startup command, or SPRING_PROFILES_ACTIVE=prod as an environment variable. The environment variable approach is cleaner for container deployments.

application.properties + profiles · PROPERTIES
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
# ── application.properties (shared defaults) ─────────────────────────────────
spring.application.name=user-service

# Virtual threads — enables Project Loom for I/O-heavy REST APIs on Java 21+
# Eliminates manual thread pool tuning for blocking JDBC workloads
spring.threads.virtual.enabled=true

# Actuator: expose health and info by default; production overrides this
management.endpoints.web.exposure.include=health,info
management.endpoint.health.show-details=when_authorized

# ── application-dev.properties (development) ──────────────────────────────────
# H2 in-memory: zero setup, resets on restart — fine for dev, dangerous to mistake for prod
spring.datasource.url=jdbc:h2:mem:userdb;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# H2 console at /h2-console for inspecting the in-memory database
spring.h2.console.enabled=true

# Hibernate creates schema on startup, drops on shutdown — fine for dev, catastrophic in prod
spring.jpa.hibernate.ddl-auto=create-drop

# Show generated SQL — essential for catching N+1 queries during development
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

logging.level.io.thecodeforge=DEBUG

# More actuator exposure during development
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always

# ── application-prod.properties (production) ──────────────────────────────────
# Read connection details from environment variables — never hardcode secrets in files
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver

# Connection pool tuning — HikariCP defaults are sensible; adjust based on your DB's max_connections
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=30000

# validate: Hibernate checks schema matches entity model but makes NO changes
# Schema changes are Flyway's job — not Hibernate's
spring.jpa.hibernate.ddl-auto=validate

# No SQL logging in production — it fills logs and exposes query structure
spring.jpa.show-sql=false

logging.level.root=WARN
logging.level.io.thecodeforge=INFO

# Actuator: expose only what's needed, on a separate internal port
management.endpoints.web.exposure.include=health,info,metrics
management.server.port=8081
📊 Production Insight
Hardcoding database credentials in application-prod.properties and committing it to source control is one of the most common and most damaging security mistakes in Spring Boot projects.
Environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault) are the right solution.
Rule: ${ENV_VAR} syntax in properties files; credentials come from the environment, never from the repository.
🎯 Key Takeaway
Use profile-specific properties files to isolate environment differences cleanly.
Never hardcode production credentials—read them from environment variables using ${ENV_VAR} syntax.
Enable virtual threads with spring.threads.virtual.enabled=true on Java 21 for I/O-heavy APIs.

DTOs — What the API Actually Returns

Before we get to MapStruct, we need the DTOs themselves defined. This is the section most tutorials skip because they're busy showing controller annotations, and it leads to the most common production mistake: returning entities directly.

A DTO (Data Transfer Object) is a plain Java class that represents exactly what you want to expose in your API — nothing more, nothing less. It is not your entity. It does not extend your entity. It has no Hibernate annotations, no lazy proxies, no bidirectional relationship fields that will cause Jackson to recurse until it hits a StackOverflowError.

For this guide we need four
  • UserDto — what callers receive when they GET a user
  • CreateUserRequest — what callers send when they POST a new user
  • UpdateUserRequest — what callers send when they PUT/PATCH an existing user
  • PagedResponse<T> — the paginated list wrapper that includes metadata

Design UserDto to expose only the fields an API consumer legitimately needs. The createdAt and updatedAt timestamps are useful for clients. The database-internal status enum is exposed as a string. The password — if you ever add one — is never in the DTO, ever.

UserDto.java + request DTOs + PagedResponse.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// ── UserDto.java — the response body for all User endpoints ──────────────────
package io.thecodeforge.userservice.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "User response body")
public class UserDto {

    @Schema(description = "User ID", example = "42")
    private Long id;

    @Schema(description = "Full name", example = "Alice Chen")
    private String name;

    @Schema(description = "Email address", example = "alice@thecodeforge.io")
    private String email;

    private Integer age;

    @Schema(description = "Account status", example = "ACTIVE",
            allowableValues = {"ACTIVE", "INACTIVE", "SUSPENDED"})
    private String status;

    @JsonFormat(shape = JsonFormat.Shape.STRING)
    private Instant createdAt;

    @JsonFormat(shape = JsonFormat.Shape.STRING)
    private Instant updatedAt;
}

// ── CreateUserRequest.java ─────────────────────────────────────────────────────
package io.thecodeforge.userservice.dto;

import jakarta.validation.constraints.*;
import lombok.Data;

@Data
public class CreateUserRequest {

    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    private String name;

    @NotBlank(message = "Email is required")
    @Email(message = "Must be a valid email address")
    private String email;

    @NotNull(message = "Age is required")
    @Min(value = 18, message = "User must be at least 18")
    @Max(value = 120, message = "Age is not realistic")
    private Integer age;
}

// ── UpdateUserRequest.java ─────────────────────────────────────────────────────
package io.thecodeforge.userservice.dto;

import jakarta.validation.constraints.*;
import lombok.Data;

@Data
public class UpdateUserRequest {

    @Size(min = 2, max = 100)
    private String name;   // null = not updating this field

    @Min(18) @Max(120)
    private Integer age;   // null = not updating this field

    // Email intentionally excluded — treat email changes as a separate
    // verified flow, not a plain field update
}

// ── PagedResponse.java ─────────────────────────────────────────────────────────
// Wraps Spring's Page<T> into a clean JSON response.
// Spring's raw Page serialisation includes internal implementation fields
// that clients should not depend on. This wrapper exposes only what matters.
package io.thecodeforge.userservice.dto;

import org.springframework.data.domain.Page;
import java.util.List;

public record PagedResponse<T>(
    List<T> content,
    int page,
    int size,
    long totalElements,
    int totalPages,
    boolean last
) {
    public static <T> PagedResponse<T> from(Page<T> page) {
        return new PagedResponse<>(
            page.getContent(),
            page.getNumber(),
            page.getSize(),
            page.getTotalElements(),
            page.getTotalPages(),
            page.isLast()
        );
    }
}
🔥Forge Tip
Always use DTOs as request and response bodies — never expose JPA entities directly. Entities have bidirectional relationships, lazy-loaded proxies, and internal fields that do not belong in an API contract. Jackson will either serialise them incorrectly or throw a StackOverflowError on circular references.
📊 Production Insight
The PagedResponse<T> wrapper is small code that pays large dividends. Spring's raw Page serialisation includes fields like pageable.sort.sorted — implementation internals that clients start depending on, which then become a migration problem when you change the pagination library.
Expose a clean, explicit contract via a wrapper record.
Rule: Own your serialisation shape. Never let framework internals leak into your API response.
🎯 Key Takeaway
Define explicit DTOs for every request and response — UserDto, CreateUserRequest, UpdateUserRequest, PagedResponse.
PagedResponse<T> wraps Spring's Page<T> to expose clean pagination metadata without framework internals.
DTOs are your API contract; entities are your persistence contract — keep them separate.

Your First REST Controller — @RestController and @RequestMapping

@RestController is a composed annotation: @Controller + @ResponseBody. Every method's return value is serialised directly to the HTTP response body — Jackson handles the Java-to-JSON conversion. Without @ResponseBody, Spring would try to resolve the return value as a Thymeleaf view name.

@RequestMapping at the class level sets the base path. @GetMapping, @PostMapping, @PutMapping, @PatchMapping, and @DeleteMapping at the method level narrow the mapping to a specific HTTP verb. Prefer these verb-specific annotations over @RequestMapping(method=...) — they are shorter and self-documenting.

Three key parameter annotations: @PathVariable extracts values embedded in the URL path (e.g., /users/{id}), @RequestParam reads query string values (e.g., ?page=2), and @RequestBody deserialises the JSON request body into a Java object.

Controller methods are thin by design. The one below does nothing except translate HTTP to a service call and back. That is its entire job. If you find yourself writing conditional logic, calculating values, or making multiple service calls inside a controller method — those belong in the service layer, not here.

UserController.java · JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
package io.thecodeforge.userservice.controller;

import io.thecodeforge.userservice.dto.CreateUserRequest;
import io.thecodeforge.userservice.dto.UpdateUserRequest;
import io.thecodeforge.userservice.dto.UserDto;
import io.thecodeforge.userservice.service.UserService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;

    // Constructor injection — explicit, immutable, testable
    // No @Autowired needed: Spring injects the single constructor automatically
    public UserController(UserService userService) {
        this.userService = userService;
    }

    // GET /api/v1/users?page=0&size=20&sortBy=createdAt&sortDir=desc
    @GetMapping
    public PagedResponse<UserDto> getAllUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "createdAt") String sortBy,
            @RequestParam(defaultValue = "desc") String sortDir) {
        return userService.findAll(page, size, sortBy, sortDir);
    }

    // GET /api/v1/users/42
    @GetMapping("/{id}")
    public UserDto getUserById(@PathVariable Long id) {
        return userService.findById(id);
    }

    // POST /api/v1/users -> 201 Created
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserDto createUser(@RequestBody @Valid CreateUserRequest request) {
        return userService.create(request);
    }

    // PUT /api/v1/users/42 — full replacement
    @PutMapping("/{id}")
    public UserDto updateUser(@PathVariable Long id,
                              @RequestBody @Valid UpdateUserRequest request) {
        return userService.update(id, request);
    }

    // DELETE /api/v1/users/42 -> 204 No Content
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteUser(@PathVariable Long id) {
        userService.delete(id);
    }
}
📊 Production Insight
Controllers are HTTP adapters, not business logic containers. Their job is to translate HTTP to method calls and results to HTTP.
Putting logic here makes it untestable without an HTTP stack and impossible to reuse across different entrypoints (e.g., messaging).
Rule: Keep controllers thin; they should orchestrate, not compute.
🎯 Key Takeaway
@RestController combines @Controller and @ResponseBody for direct JSON serialization.
Use verb-specific annotations (@GetMapping, @PostMapping) for clarity and self-documentation.
The controller's role is HTTP translation—nothing more.

Request Validation with @Valid and Bean Validation

Never trust client input. Spring Boot integrates with Jakarta Bean Validation (the successor to javax.validation). Add spring-boot-starter-validation to your pom.xml, annotate your request DTO with constraints, and add @Valid to the @RequestBody parameter. Spring runs validation automatically before your method body executes.

If validation fails, Spring throws MethodArgumentNotValidException. You catch this in a @RestControllerAdvice and return a clean, structured error body with field-level detail — not a 500 with a stack trace.

Most-used constraints: @NotBlank (not null, not empty, not whitespace — use this for Strings, not @NotNull), @NotNull (for non-String types), @Size(min=2, max=50), @Email, @Min, @Max, @Pattern(regexp=...). Custom constraints are built with @Constraint + a ConstraintValidator<A,T> implementation.

One thing most tutorials don't show: when you have multiple validation errors on a single request, MethodArgumentNotValidException carries all of them in its BindingResult. Your error handler should iterate and return all field errors at once — not just the first one. Making the client fix errors one at a time is a frustrating API experience.

CreateUserRequest.java + GlobalExceptionHandler.java (validation handling) · JAVA
12345678910111213141516171819
// CreateUserRequest.java is defined in the DTOs section above.
// The @Valid annotation on @RequestBody triggers these constraints.

// ── GlobalExceptionHandler.java (validation portion) ──────────────────────────
@RestControllerAdvice
public class GlobalExceptionHandler {

    // Returns ALL field errors at once — not just the first
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidationErrors(
            MethodArgumentNotValidException ex) {
        Map<String, String> errors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(
            err -> errors.put(err.getField(), err.getDefaultMessage())
        );
        return errors;
    }
}
🔥Forge Tip
Always use DTOs as request and response bodies — never expose JPA entities directly. Entities have bidirectional relationships, lazy-loaded proxies, and internal fields that do not belong in an API contract. Jackson will either serialise them incorrectly or throw a StackOverflowError on circular references.
📊 Production Insight
Validation is your API's first line of defense against corrupt data and injection attacks.
Without @Valid, malformed or malicious payloads can reach your business logic and database.
Rule: Validate at the boundary. Use @Valid on every @RequestBody and handle MethodArgumentNotValidException globally.
🎯 Key Takeaway
Use Jakarta Bean Validation (@NotBlank, @Email) on DTOs and trigger it with @Valid on @RequestBody.
Handle validation failures in @RestControllerAdvice to return structured, field-level error messages.
Return all validation errors in a single response — not one at a time.

Global Exception Handling with @RestControllerAdvice

@RestControllerAdvice intercepts exceptions thrown by any controller and lets you shape the error response in one central place. This is non-negotiable in any professional codebase — scattered try-catch blocks in controllers are a maintenance nightmare.

Standardise your error response shape early. Pick a record or class (e.g., {status, message, timestamp, errors}) and use it everywhere. API clients — especially mobile apps — depend on a predictable error format to parse and display errors to users.

Pattern to follow: create a ResourceNotFoundException extends RuntimeException and handle it as a 404. Create a BusinessRuleException for domain violations and map it to 422 Unprocessable Entity. Handle Exception generically as a 500 but log the full stack trace server-side while returning only a safe message to the client.

One pattern that trips people up: the order of @ExceptionHandler methods doesn't matter because Spring matches on the most specific exception type. What does matter is that you never let a generic Exception handler swallow exceptions silently — always log them before returning the sanitised response.

GlobalExceptionHandler.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
package io.thecodeforge.userservice.exception;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;

// Typed exceptions — define these in the exception package
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String resource, Long id) {
        super(resource + " with id " + id + " not found");
    }
}

public class BusinessRuleException extends RuntimeException {
    public BusinessRuleException(String message) {
        super(message);
    }
}

// Standardised error body — used for every error response
public record ApiError(int status, String message, Instant timestamp) {}

// ── GlobalExceptionHandler.java ───────────────────────────────────────────────
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(
            err -> errors.put(err.getField(), err.getDefaultMessage())
        );
        return errors;
    }

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ApiError handleNotFound(ResourceNotFoundException ex) {
        return new ApiError(404, ex.getMessage(), Instant.now());
    }

    @ExceptionHandler(BusinessRuleException.class)
    @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
    public ApiError handleBusinessRule(BusinessRuleException ex) {
        return new ApiError(422, ex.getMessage(), Instant.now());
    }

    // Catch-all: log the real cause server-side; return a safe message to the client
    // Never expose stack traces, class names, or internal paths to the client
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiError handleGeneric(Exception ex) {
        log.error("Unhandled exception", ex);
        return new ApiError(500, "An unexpected error occurred", Instant.now());
    }
}
📊 Production Insight
Without a global exception handler, raw stack traces leak to clients—exposing class names, internal paths, and potential vulnerabilities.
Mobile and frontend teams depend on a consistent error shape for error parsing and user messaging.
Rule: Standardize your error response schema and handle every exception type—from validation to business rules to generic failures.
🎯 Key Takeaway
Centralize error handling with @RestControllerAdvice to avoid scattered try-catch blocks.
Define typed exceptions (e.g., ResourceNotFoundException) and map them to proper HTTP status codes.
Always log the full error server-side but return a safe, generic message to the client.

ResponseEntity — Runtime Control Over Status and Headers

Return ResponseEntity<T> instead of T when you need to set the HTTP status code at runtime or add response headers. The most common use case is POST returning 201 Created with a Location header pointing to the newly created resource — this is standard REST practice that clients and API gateways expect.

Other patterns: 204 No Content for DELETE (no body), 202 Accepted for async operations where processing continues after the response, and conditional responses that return 200 or 404 based on whether the resource exists.

ResponseEntity.ok(body), ResponseEntity.created(uri).body(body), ResponseEntity.noContent().build() — these builder methods are cleaner than constructors. You rarely need ResponseEntity<Void> explicitly; @ResponseStatus on the method is simpler for fixed status codes.

The Location header on 201 Created is worth taking seriously. It tells the client exactly where the new resource lives without them having to construct the URL. API gateways and some HTTP clients use the Location header to follow creation with a fetch. Returning 201 with no Location header is technically valid but wastes an opportunity to give clients what they need without a second round-trip.

UserController.java — ResponseEntity patterns · JAVA
1234567891011121314151617181920212223242526272829
// POST returning 201 + Location header (correct REST practice)
@PostMapping
public ResponseEntity<UserDto> createUser(@RequestBody @Valid CreateUserRequest req) {
    UserDto created = userService.create(req);
    URI location = ServletUriComponentsBuilder
            .fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(created.getId())
            .toUri();
    return ResponseEntity.created(location).body(created);
}

// GET with conditional 200/404 — Optional.map makes this clean
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
    return userService.findOptional(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
}

// File download — Content-Disposition and content type set explicitly
@GetMapping("/export")
public ResponseEntity<byte[]> exportCsv() {
    byte[] csv = reportService.generateCsv();
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=users.csv")
            .contentType(MediaType.parseMediaType("text/csv"))
            .body(csv);
}
📊 Production Insight
Using the correct HTTP status code is part of your API contract. It allows clients and gateways to understand outcomes without parsing the body.
Returning 200 for everything, including creation or deletion, forces clients to inspect the body for success signals—a brittle pattern.
Rule: Use ResponseEntity to set status codes and headers dynamically; use @ResponseStatus for fixed codes.
🎯 Key Takeaway
Use ResponseEntity when you need to set the status code or headers at runtime (e.g., 201 Created with a Location header).
For fixed status codes, @ResponseStatus on the method is simpler.
Proper status codes are a critical part of your API contract with clients.

Service and Repository Layers — Keeping Controllers Thin

The controller's sole responsibility is HTTP translation: parse the request, call the service, map the result to a response, set the status. Business logic in controllers is the most common architectural mistake in Spring Boot codebases. It makes business logic untestable without an HTTP stack, and impossible to reuse.

The service layer owns business logic and transaction boundaries. Mark service methods @Transactional — the transaction wraps the whole unit of work atomically. If you put @Transactional on the controller, exceptions thrown after a partial write will not roll back cleanly.

Spring Data JPA gives you repositories for free: extend JpaRepository<Entity, ID> and you get findById, findAll, save, delete, and page/sort without writing a query. For custom queries, use @Query with JPQL. For truly dynamic filtering, use Spring Data JPA Specifications.

One pattern worth calling out that most tutorials gloss over: @Transactional(readOnly = true) at the class level as the default, with @Transactional (writeable) overriding it on methods that mutate state. This is not just a code style choice — Hibernate skips dirty checking on read-only transactions, which is a meaningful performance optimisation for services that have a high read-to-write ratio. Some connection pools and database proxies also route read-only transactions to read replicas. The annotation does real work.

UserService.java + UserRepository.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
package io.thecodeforge.userservice.service;

import io.thecodeforge.userservice.dto.*;
import io.thecodeforge.userservice.exception.BusinessRuleException;
import io.thecodeforge.userservice.exception.ResourceNotFoundException;
import io.thecodeforge.userservice.mapper.UserMapper;
import io.thecodeforge.userservice.repository.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true) // default: read-only; write methods override below
public class UserService {

    private final UserRepository repo;
    private final UserMapper mapper;

    public UserService(UserRepository repo, UserMapper mapper) {
        this.repo = repo;
        this.mapper = mapper;
    }

    public UserDto findById(Long id) {
        return repo.findById(id)
                .map(mapper::toDto)
                .orElseThrow(() -> new ResourceNotFoundException("User", id));
    }

    public java.util.Optional<UserDto> findOptional(Long id) {
        return repo.findById(id).map(mapper::toDto);
    }

    public PagedResponse<UserDto> findAll(int page, int size, String sortBy, String sortDir) {
        int cappedSize = Math.min(size, 100);
        Sort sort = sortDir.equalsIgnoreCase("asc")
                ? Sort.by(sortBy).ascending()
                : Sort.by(sortBy).descending();
        Page<UserDto> result = repo.findAll(PageRequest.of(page, cappedSize, sort))
                .map(mapper::toDto);
        return PagedResponse.from(result);
    }

    @Transactional // overrides class-level readOnly — this method writes
    public UserDto create(CreateUserRequest req) {
        if (repo.existsByEmail(req.getEmail())) {
            throw new BusinessRuleException("Email already registered");
        }
        return mapper.toDto(repo.save(mapper.toEntity(req)));
    }

    @Transactional
    public UserDto update(Long id, UpdateUserRequest req) {
        var user = repo.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("User", id));
        mapper.updateEntityFromRequest(req, user);
        return mapper.toDto(repo.save(user));
    }

    @Transactional
    public void delete(Long id) {
        if (!repo.existsById(id)) {
            throw new ResourceNotFoundException("User", id);
        }
        repo.deleteById(id);
    }
}

// ── UserRepository.java ───────────────────────────────────────────────────────
package io.thecodeforge.userservice.repository;

import io.thecodeforge.userservice.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;

public interface UserRepository extends JpaRepository<User, Long> {

    boolean existsByEmail(String email);

    // Derived query — Spring Data generates the SQL from the method name
    boolean existsById(Long id);

    // JPQL for anything the method name syntax can't express cleanly
    @Query("SELECT u FROM User u WHERE u.status = :status ORDER BY u.createdAt DESC")
    List<User> findByStatus(@Param("status") User.UserStatus status);
}
📊 Production Insight
Transactions must wrap the entire unit of work. Placing @Transactional on a controller method means a service failure after a partial write may not roll back.
This leads to data corruption—partial updates committed to the database.
Rule: @Transactional belongs on service methods, not controllers. The service defines the transaction boundary.
🎯 Key Takeaway
Controllers are for HTTP translation; services own business logic and transaction boundaries.
Use @Transactional(readOnly = true) at the class level and override with @Transactional on write methods.
Spring Data JPA repositories provide CRUD and custom queries with minimal boilerplate.

DTO Mapping with MapStruct — Never Expose Your Entity Directly

One of the most consequential architectural decisions in a REST API is whether controllers return entity objects directly or DTOs. Return the entity directly and you expose your entire database schema to API consumers, leak internal fields like passwords or audit metadata, and create a tight coupling between your database model and your API contract. Change a column name and you break your API. Add a new sensitive field and it's immediately serialised to JSON.

The solution is a DTO layer. Every controller returns DTOs; every request body is a DTO. The mapper converts between them. MapStruct generates this mapping code at compile time — no reflection, no runtime overhead, and full IDE navigation to see exactly what maps to what.

The integration I always add that most tutorials skip: MapStruct + Lombok require a specific annotation processor order in Maven. If you declare them in the wrong order, Lombok runs after MapStruct and MapStruct can't see the generated getters. The pom.xml in the setup section has the correct order — always Lombok first, then MapStruct.

For fields that need custom mapping (a full name split from a single column, a status enum to a display string, a nested entity to a nested DTO), use @Mapping(target="field", expression="java(...)") or define a default method in the mapper interface. MapStruct is not magic — it generates target.setName(source.getName()) calls. If the field names differ or the types don't match, you tell it explicitly.

UserMapper.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142
package io.thecodeforge.userservice.mapper;

import io.thecodeforge.userservice.dto.CreateUserRequest;
import io.thecodeforge.userservice.dto.UpdateUserRequest;
import io.thecodeforge.userservice.dto.UserDto;
import io.thecodeforge.userservice.model.User;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.NullValuePropertyMappingStrategy;

// componentModel = "spring" → MapStruct generates a @Component;
// Spring injects it wherever you @Autowire UserMapper
@Mapper(
    componentModel = "spring",
    // For partial updates: skip null fields — don't overwrite with null
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
public interface UserMapper {

    // Entity → DTO
    // User.UserStatus enum → String: MapStruct calls .name() automatically
    // Instant fields: serialisation handled by @JsonFormat on the DTO
    UserDto toDto(User user);

    // CreateUserRequest → Entity
    @Mapping(target = "id",        ignore = true) // DB generates this
    @Mapping(target = "createdAt", ignore = true) // @CreatedDate generates this
    @Mapping(target = "updatedAt", ignore = true) // @LastModifiedDate generates this
    @Mapping(target = "status",    constant = "ACTIVE") // new users always start ACTIVE
    User toEntity(CreateUserRequest request);

    // Partial update: apply non-null fields from request onto the existing entity in-place
    // @MappingTarget tells MapStruct to mutate the existing object, not create a new one
    // NullValuePropertyMappingStrategy.IGNORE (class-level) skips null source fields
    @Mapping(target = "id",        ignore = true)
    @Mapping(target = "email",     ignore = true) // email changes require a separate verified flow
    @Mapping(target = "status",    ignore = true) // status changes require explicit business logic
    @Mapping(target = "createdAt", ignore = true)
    @Mapping(target = "updatedAt", ignore = true)
    void updateEntityFromRequest(UpdateUserRequest request, @MappingTarget User user);
}
📊 Production Insight
Exposing entities directly couples your API contract to your database schema. A column rename becomes a breaking API change.
Entities also contain Hibernate-specific fields and lazy proxies that Jackson cannot serialize correctly, leading to runtime errors.
Rule: Always use DTOs. MapStruct generates the mapping code at compile time with zero runtime overhead.
🎯 Key Takeaway
Never expose JPA entities as API responses—use DTOs to decouple your database schema from your API contract.
MapStruct generates type-safe mapping code at compile time, avoiding reflection and runtime errors.
Configure Lombok and MapStruct annotation processors in the correct order (Lombok first) in Maven.

Pagination with Pageable and Page — Never Return Unbounded Lists

Returning all records from findAll() with no limit is one of the most common production performance disasters I've encountered. The endpoint works fine in development with 50 test records. It silently degrades as the table grows — 10,000 rows feels slow, 500,000 rows times out, 5 million rows causes an OOM. The fix is always the same: paginate from day one.

Spring Data JPA has first-class pagination. Pageable in the method signature, Page<T> as the return type. The repository's findAll(Pageable pageable) does the right thing — generates LIMIT ? OFFSET ? SQL. The Page<T> response includes the data and the metadata: total elements, total pages, current page number, page size. Clients need the metadata to build pagination controls.

The PagedResponse<T> wrapper defined in the DTOs section is used here. Spring's raw Page serialisation includes internal fields from the Pageable implementation — pageable.sort.sorted, pageable.sort.unsorted, pageable.offset — that clients start depending on and that you cannot change without breaking them. The wrapper exposes only the fields that are part of your contract.

Capping the page size server-side is not optional. If you allow clients to pass size=10000000, someone will. Whether malicious or accidental, one request like that will saturate your database connection pool. The cap is a server-side business decision, not a trust relationship.

UserController.java — pagination endpoint · JAVA
123456789101112131415161718192021222324252627282930
package io.thecodeforge.userservice.controller;

import io.thecodeforge.userservice.dto.PagedResponse;
import io.thecodeforge.userservice.dto.UserDto;
import io.thecodeforge.userservice.service.UserService;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    // GET /api/v1/users?page=0&size=20&sortBy=createdAt&sortDir=desc
    @GetMapping
    public PagedResponse<UserDto> getUsers(
            @RequestParam(defaultValue = "0")         int page,
            @RequestParam(defaultValue = "20")        int size,
            @RequestParam(defaultValue = "createdAt") String sortBy,
            @RequestParam(defaultValue = "desc")      String sortDir) {

        // Service handles the cap — Math.min(size, 100)
        // Never trust the client's requested size
        return userService.findAll(page, size, sortBy, sortDir);
    }
}
⚠ Always cap the page size server-side
If you let clients pass size=10000000, someone will. Whether malicious or accidental, it will saturate your database connection pool and OOM your service. Cap it in the service layer: int cappedSize = Math.min(size, 100). The maximum page size should be a business decision, not a trust relationship with your clients.
📊 Production Insight
Unbounded queries are a scalability time bomb. They work in development with small datasets but degrade silently as data grows.
A single request can consume all database connections and memory, causing cascading failures.
Rule: Paginate from day one. Use Pageable and Page<T>, and always cap the maximum page size server-side.
🎯 Key Takeaway
Never return unbounded lists—paginate using Spring Data's Pageable and Page<T>.
Cap the page size server-side (e.g., Math.min(size, 100)) to prevent resource exhaustion.
Use a PagedResponse wrapper to expose clean pagination metadata to clients.

API Documentation with Springdoc OpenAPI — Auto-Generated Swagger UI

Interactive API documentation is not a nice-to-have — it's the contract between your backend team and every consumer of your API. I've worked on platforms where missing API docs caused mobile teams to make assumptions about field names and types that led to six production bugs in two months. We added Springdoc after the second incident. Never had that class of bug again.

Springdoc-OpenAPI reads your Spring MVC annotations at startup and generates an OpenAPI 3.0 spec automatically. Add the dependency, and /swagger-ui.html is live with zero additional config. The interactive UI lets you execute requests directly from the browser — essential for QA and frontend teams who shouldn't need Postman to test your API.

The annotations that matter: @Operation(summary = "...", description = "...") on controller methods, @Parameter for path/query params, @Schema(description = "...", example = "...") on DTO fields. Don't annotate every field — focus on the non-obvious ones. Swagger docs that annotate String name // the user's name are noise. Annotate the constraints, the format, the edge cases.

In production, lock down the Swagger UI behind authentication or restrict it to internal networks. Your API docs expose your endpoint structure, parameter names, and response shapes — useful to your team, useful to attackers. The quickest approach is to conditionally disable Springdoc via a property in the prod profile: springdoc.api-docs.enabled=false. Or keep it enabled but accessible only on your internal management network.

UserController.java — Springdoc annotations + OpenApiConfig.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
package io.thecodeforge.userservice.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.thecodeforge.userservice.dto.CreateUserRequest;
import io.thecodeforge.userservice.dto.UserDto;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/users")
@Tag(name = "Users", description = "User management operations")
public class UserController {

    @Operation(
        summary = "Get user by ID",
        description = "Returns a single user. Returns 404 if the user does not exist.",
        responses = {
            @ApiResponse(responseCode = "200", description = "User found",
                content = @Content(schema = @Schema(implementation = UserDto.class))),
            @ApiResponse(responseCode = "404", description = "User not found")
        }
    )
    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getUserById(
            @Parameter(description = "User ID", example = "42")
            @PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }

    @Operation(
        summary = "Create a new user",
        description = "Creates a user. Returns 409 Conflict if the email already exists."
    )
    @PostMapping
    public ResponseEntity<UserDto> createUser(
            @RequestBody @Valid CreateUserRequest request) {
        UserDto created = userService.create(request);
        URI location = ServletUriComponentsBuilder
                .fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(created.getId())
                .toUri();
        return ResponseEntity.created(location).body(created);
    }
}

// ── OpenApiConfig.java — API-level metadata ───────────────────────────────────
package io.thecodeforge.userservice.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI userServiceOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("User Service API")
                        .description("REST API for user management")
                        .version("v1.0")
                        .contact(new Contact()
                                .name("TheCodeForge")
                                .url("https://thecodeforge.io")));
    }
}
📊 Production Insight
API documentation is a contract. Without it, consumers make assumptions that lead to integration bugs.
Swagger UI provides an interactive 'Try it out' experience that reduces miscommunication between backend and frontend/mobile teams.
Rule: Auto-generate docs with Springdoc. In production, disable or restrict access to Swagger UI — your endpoint structure is not public information.
🎯 Key Takeaway
API documentation is a contract—use Springdoc OpenAPI to auto-generate interactive Swagger UI.
Annotate endpoints with @Operation and DTOs with @Schema for clear, executable documentation.
Disable or restrict Swagger UI in production (springdoc.api-docs.enabled=false) to avoid exposing your API structure.

Spring Security — Protecting Your Endpoints

Adding spring-boot-starter-security to your classpath immediately locks down every endpoint behind HTTP Basic Auth. That's intentional — security on by default is the right default. The question is how to configure it for your actual auth model.

For a REST API serving mobile apps and SPAs, JWT (JSON Web Tokens) is the standard choice. Stateless — no server-side session, no sticky sessions needed for horizontal scaling. The client authenticates once, receives a token, and includes it in the Authorization: Bearer <token> header on every subsequent request.

A full JWT implementation — including token generation, refresh token rotation, and token revocation — deserves its own dedicated guide. The subtleties there (algorithm choice, key rotation, refresh token storage) go deeper than a single section can do justice to. What I will cover here is the SecurityFilterChain configuration that every REST API needs, and the skeleton of a JWT filter so you understand where it plugs in.

The critical thing most tutorials get wrong: CSRF protection. CSRF is relevant for browser session-based auth where the browser automatically sends cookies. For a stateless JWT API where the client explicitly sets the Authorization header, CSRF is not applicable — but you need to explicitly disable it, or Spring Security will block all POST/PUT/DELETE requests from your API clients with a 403.

SecurityConfig.java + JwtAuthenticationFilter.java (skeleton) · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
package io.thecodeforge.userservice.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize on service/controller methods
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtFilter) {
        this.jwtFilter = jwtFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // STATELESS: no HttpSession created or used — required for JWT
            .sessionManagement(sm ->
                sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // CSRF: disable for stateless JWT APIs
            // CSRF protection is for browser cookie auth.
            // When the client sends a Bearer token in the Authorization header,
            // CSRF attacks are not possible — the browser does not auto-send that header.
            .csrf(csrf -> csrf.disable())

            // CORS: configured via CorsConfig WebMvcConfigurer
            .cors(cors -> cors.configure(http))

            .authorizeHttpRequests(auth -> auth
                // Public endpoints: docs, health check, and auth
                .requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers(HttpMethod.POST, "/api/v1/auth/**").permitAll()
                // Everything else requires a valid JWT
                .anyRequest().authenticated()
            )

            // JWT filter runs before Spring's username/password filter
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

// ── JwtAuthenticationFilter.java (skeleton) ────────────────────────────────────
// Full JWT implementation (token generation, validation, refresh rotation)
// is covered in the dedicated JWT guide. This skeleton shows where it plugs in.
package io.thecodeforge.userservice.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");

        // If no Bearer token is present, continue — the authorization rules
        // in SecurityConfig will reject the request if authentication is required
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(7);
        // TODO: validate token signature, check expiry, extract claims,
        // build UsernamePasswordAuthenticationToken, set in SecurityContextHolder
        // See: thecodeforge.io/spring-boot-jwt for the full implementation

        chain.doFilter(request, response);
    }
}
🔥JWT vs Session: the stateless trade-off
JWT tokens cannot be individually revoked before their expiry time — if a token is stolen, it's valid until expiry. Counter this with short expiry (15-60 minutes) and a refresh token rotation scheme. For user logout, maintain a server-side token blacklist (Redis) or use short expiry. Session-based auth avoids this by destroying the session on logout, but requires sticky sessions or a shared session store. There's no free lunch — pick the trade-off that fits your threat model.
📊 Production Insight
Forgetting to disable CSRF for a stateless JWT API causes all state-changing requests (POST, PUT, DELETE) to return 403 Forbidden.
This happens because Spring Security enables CSRF by default for browser protection, but it's irrelevant when the client sends a Bearer token.
Rule: Explicitly disable CSRF in your SecurityConfig for JWT-based APIs.
🎯 Key Takeaway
For stateless JWT APIs, disable CSRF protection—it's for browser cookie auth, not Bearer tokens.
Configure session management as STATELESS to avoid server-side session storage.
Use @PreAuthorize for method-level authorization and secure actuator endpoints in production.

CORS Configuration — Allowing Your Frontend to Call the API

CORS (Cross-Origin Resource Sharing) is the browser mechanism that prevents JavaScript on one domain from making requests to a different domain. If your React SPA is on app.thecodeforge.io and your API is on api.thecodeforge.io, every API call will be blocked by the browser with a CORS error — even if the API itself would accept the request.

This trips up every developer the first time they connect a frontend to a backend. The error appears in the browser console, not in the server logs, which makes it look like a server problem when it's actually a browser policy enforcing the same-origin rule. The server needs to respond with the right CORS headers to tell the browser 'this cross-origin request is allowed.'

Two approaches: @CrossOrigin on individual controllers (quick, no global config needed) or global CORS configuration via WebMvcConfigurer (the right approach for production). Global config means one place to maintain the allowed origins list — you don't end up with different controllers allowing different origins because someone forgot to update an annotation they couldn't find.

One thing that catches people out: Spring Security processes requests before Spring MVC does. If you configure CORS only in WebMvcConfigurer without also telling Spring Security to use it, the preflight OPTIONS request will be rejected by the security filter before it reaches your CORS config. The .cors(cors -> cors.configure(http)) call in SecurityConfig wires them together.

CorsConfig.java · JAVA
123456789101112131415161718192021222324252627
package io.thecodeforge.userservice.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    // Drive allowed origins from config — not hardcoded in source
    // application-dev.properties:  cors.allowed-origins=http://localhost:3000
    // application-prod.properties: cors.allowed-origins=https://app.thecodeforge.io
    @Value("${cors.allowed-origins}")
    private String[] allowedOrigins;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins(allowedOrigins)
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
                .allowedHeaders("Authorization", "Content-Type", "Accept")
                .exposedHeaders("Location") // clients need Location header from POST 201
                .allowCredentials(true)     // required if frontend sends cookies alongside JWT
                .maxAge(3600);              // cache preflight response for 1 hour
    }
}
📊 Production Insight
CORS errors appear only in the browser console, not server logs, making them confusing for developers new to frontend-backend integration.
Using @CrossOrigin on every controller leads to configuration drift—some endpoints may allow different origins.
Spring Security must also be configured to pass preflight OPTIONS requests through, or CORS config in WebMvcConfigurer won't be reached.
Rule: Configure CORS globally via WebMvcConfigurer and drive allowed origins from configuration properties.
🎯 Key Takeaway
CORS is a browser security policy—configure it on the server to allow your frontend domain.
Use global configuration via WebMvcConfigurer instead of scattering @CrossOrigin annotations.
Drive allowed origins from configuration properties for environment-specific control.

Spring Boot Actuator — Health Checks and Production Observability

Every API deployed to a container orchestrator (Kubernetes, ECS, Fly.io) needs health check endpoints. Your load balancer needs to know when an instance is ready to accept traffic (readiness) and when it's still alive (liveness). Spring Boot Actuator provides these out of the box with zero implementation code.

/actuator/health is the endpoint your Kubernetes liveness probe points at. It aggregates the health of all components: database connectivity, disk space, any custom health indicators you add. If your database goes down, the health endpoint returns DOWN and the orchestrator removes the instance from the load balancer rotation.

I've seen production incidents where a service was silently broken — returning errors for all requests — because its health check only tested 'is the JVM alive?' not 'can it reach the database?'. The load balancer kept routing traffic to a broken instance for 8 minutes until an alert fired. Spring Actuator's composite health check prevents this class of incident.

Be selective about what you expose. /actuator/env exposes all environment variables including secrets. /actuator/heapdump can be used to dump the heap and read sensitive data from memory. In production, expose only /health and /info publicly; protect the rest behind an admin role. Run the management server on a separate port (8081) so it's never reachable through the public-facing load balancer.

A custom health indicator is straightforward: implement HealthIndicator, return Health.up() or Health.down() with whatever detail you want. Use these to check downstream services your API depends on — a payment gateway, an inventory service, a cache. If the dependency is down, your health check should report it.

application-prod.properties (Actuator) + CustomHealthIndicator.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445
# ── application-prod.properties: Actuator exposure ───────────────────────────
# Expose only what orchestrators need — health and metrics
management.endpoints.web.exposure.include=health,info,metrics

# show-details=when_authorized: full health detail visible only to authenticated admin
# show-details=always: exposes DB status, disk space, connection pool to anyone — don't do this
management.endpoint.health.show-details=when_authorized

# Separate actuator port — not routed through the public load balancer
management.server.port=8081

# ── CustomHealthIndicator.java ────────────────────────────────────────────────
package io.thecodeforge.userservice.health;

import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

// Example: check a downstream dependency your API relies on
// Spring Boot auto-registers this and includes it in /actuator/health
@Component
public class DownstreamServiceHealthIndicator implements HealthIndicator {

    private final DownstreamServiceClient client;

    public DownstreamServiceHealthIndicator(DownstreamServiceClient client) {
        this.client = client;
    }

    @Override
    public Health health() {
        try {
            client.ping(); // throws if service is unreachable
            return Health.up()
                    .withDetail("service", "downstream-api")
                    .withDetail("status", "reachable")
                    .build();
        } catch (Exception e) {
            return Health.down()
                    .withDetail("service", "downstream-api")
                    .withDetail("error", e.getMessage())
                    .build();
        }
    }
}
📊 Production Insight
A health check that only verifies JVM liveness is useless—it doesn't detect dependency failures like database outages.
The load balancer will keep routing traffic to a broken instance, causing user-facing errors until manual intervention.
Rule: Ensure /actuator/health includes checks for all critical dependencies (database, external services).
🎯 Key Takeaway
Use Spring Boot Actuator for health checks (/actuator/health) required by orchestrators like Kubernetes.
Customize health indicators to check critical dependencies, not just JVM liveness.
In production, expose only /health and /info publicly; run the management server on a separate internal port.

Testing with MockMvc — Controller Layer Tests

@WebMvcTest(UserController.class) loads only the web layer — controllers, filters, and exception handlers. The service layer is not loaded; replace it with @MockBean. This makes tests fast and focused: you are testing routing, validation, and exception mapping, not business logic.

For end-to-end integration tests, use @SpringBootTest + @AutoConfigureMockMvc. These load the full application context including the database (use Testcontainers for a real DB, or H2 in-memory for speed).

Key MockMvc assertions: status().isOk(), status().isBadRequest(), jsonPath("$.email").value("alice@example.com"), content().contentType(MediaType.APPLICATION_JSON). Use MockMvcResultHandlers.print() during debugging to see the full request/response.

One pattern that pays off immediately: define a shared ObjectMapper or test helper for building request bodies. Test classes that build JSON strings inline become brittle the moment you add a field to the request DTO. Use the same ObjectMapper that Spring configures (inject it with @Autowired) so your serialisation in tests matches production.

UserControllerTest.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
package io.thecodeforge.userservice.controller;

import io.thecodeforge.userservice.dto.CreateUserRequest;
import io.thecodeforge.userservice.dto.UserDto;
import io.thecodeforge.userservice.exception.ResourceNotFoundException;
import io.thecodeforge.userservice.service.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired MockMvc mockMvc;
    @MockBean  UserService userService;
    @Autowired ObjectMapper objectMapper; // same instance Spring uses — consistent serialisation

    @Test
    @WithMockUser // satisfies Spring Security's authentication requirement in the web layer
    void getUser_found_returns200() throws Exception {
        UserDto dto = new UserDto(1L, "Alice", "alice@thecodeforge.io",
                                  28, "ACTIVE", Instant.now(), Instant.now());
        when(userService.findById(1L)).thenReturn(dto);

        mockMvc.perform(get("/api/v1/users/1"))
               .andDo(print()) // useful during development; remove in CI
               .andExpect(status().isOk())
               .andExpect(content().contentType(MediaType.APPLICATION_JSON))
               .andExpect(jsonPath("$.name").value("Alice"))
               .andExpect(jsonPath("$.email").value("alice@thecodeforge.io"));
    }

    @Test
    @WithMockUser
    void getUser_notFound_returns404() throws Exception {
        when(userService.findById(99L))
                .thenThrow(new ResourceNotFoundException("User", 99L));

        mockMvc.perform(get("/api/v1/users/99"))
               .andExpect(status().isNotFound())
               .andExpect(jsonPath("$.message").value("User with id 99 not found"));
    }

    @Test
    @WithMockUser
    void createUser_invalidEmail_returns400() throws Exception {
        CreateUserRequest req = new CreateUserRequest();
        req.setName("Bob");
        req.setEmail("not-an-email"); // will fail @Email
        req.setAge(25);

        mockMvc.perform(post("/api/v1/users")
                       .contentType(MediaType.APPLICATION_JSON)
                       .content(objectMapper.writeValueAsString(req)))
               .andExpect(status().isBadRequest())
               .andExpect(jsonPath("$.email").value("Must be a valid email address"));
    }

    @Test
    @WithMockUser
    void createUser_underageUser_returns400() throws Exception {
        CreateUserRequest req = new CreateUserRequest();
        req.setName("Charlie");
        req.setEmail("charlie@thecodeforge.io");
        req.setAge(16); // will fail @Min(18)

        mockMvc.perform(post("/api/v1/users")
                       .contentType(MediaType.APPLICATION_JSON)
                       .content(objectMapper.writeValueAsString(req)))
               .andExpect(status().isBadRequest())
               .andExpect(jsonPath("$.age").value("User must be at least 18"));
    }
}
📊 Production Insight
Controller tests with @WebMvcTest are fast and isolated—they verify HTTP handling without starting the full application.
Skipping them means integration tests become the only line of defense, slowing feedback loops.
Rule: Use @WebMvcTest for controller logic; reserve @SpringBootTest for full integration scenarios.
🎯 Key Takeaway
@WebMvcTest loads only the web layer for fast, focused controller tests.
Use @MockBean to replace service dependencies and test routing, validation, and error mapping.
Annotate tests with @WithMockUser when Spring Security is on the classpath to satisfy authentication requirements.

Integration Testing with @SpringBootTest and Testcontainers

Unit tests with MockMvc are fast and focused, but they don't catch a whole class of bugs: JPA query errors, database constraint violations, transaction rollback edge cases, Spring Security misconfiguration. Integration tests catch these by loading the real application context with a real database.

Testcontainers spins up a real PostgreSQL container for your test, runs your tests against it, and tears it down when done. The database is identical to production — same PostgreSQL version, same behaviour, same constraints. H2 in-memory is convenient but it's a different database engine. I've been burned by queries that work on H2 and fail on PostgreSQL specifically because H2 is more permissive about certain SQL.

The pattern I use: @SpringBootTest for full integration tests that verify the entire request-to-database flow. @DataJpaTest for repository-layer tests that only load the persistence layer. @WebMvcTest for controller-layer tests. Each layer has its own test class, its own scope, its own speed profile. Don't put everything in @SpringBootTest — it starts the full container and is slow.

@DynamicPropertySource is the bridge between Testcontainers and Spring. The container starts on a random port; @DynamicPropertySource tells Spring's datasource configuration where to find it. This is the cleanest way to wire them together without hardcoded ports or brittle configuration files.

UserControllerIntegrationTest.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
package io.thecodeforge.userservice.integration;

import io.thecodeforge.userservice.dto.CreateUserRequest;
import io.thecodeforge.userservice.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest              // full application context
@AutoConfigureMockMvc       // MockMvc wired against the real context
@Testcontainers             // Testcontainers lifecycle management
class UserControllerIntegrationTest {

    // Shared across all tests in this class — started once
    // Each test cleans up via @BeforeEach, not by restarting the container
    @Container
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16")
                    .withDatabaseName("userdb_test")
                    .withUsername("test")
                    .withPassword("test");

    // Wire the random-port container to Spring's datasource config
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url",      postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired MockMvc         mockMvc;
    @Autowired ObjectMapper    objectMapper;
    @Autowired UserRepository  userRepository;

    @BeforeEach
    void cleanDatabase() {
        userRepository.deleteAll();
    }

    @Test
    @WithMockUser
    void createUser_validRequest_returns201WithLocation() throws Exception {
        CreateUserRequest req = new CreateUserRequest();
        req.setName("Alice Chen");
        req.setEmail("alice@thecodeforge.io");
        req.setAge(28);

        mockMvc.perform(post("/api/v1/users")
                       .contentType(MediaType.APPLICATION_JSON)
                       .content(objectMapper.writeValueAsString(req)))
               .andExpect(status().isCreated())
               .andExpect(header().exists("Location"))
               .andExpect(jsonPath("$.id").isNumber())
               .andExpect(jsonPath("$.email").value("alice@thecodeforge.io"))
               .andExpect(jsonPath("$.status").value("ACTIVE"));

        // Verify the record actually persisted to PostgreSQL
        assertThat(userRepository.count()).isEqualTo(1);
    }

    @Test
    @WithMockUser
    void createUser_duplicateEmail_returns422() throws Exception {
        CreateUserRequest req = new CreateUserRequest();
        req.setName("Alice");
        req.setEmail("alice@thecodeforge.io");
        req.setAge(28);

        // First creation — must succeed
        mockMvc.perform(post("/api/v1/users")
                       .contentType(MediaType.APPLICATION_JSON)
                       .content(objectMapper.writeValueAsString(req)))
               .andExpect(status().isCreated());

        // Second creation with same email — BusinessRuleException → 422
        mockMvc.perform(post("/api/v1/users")
                       .contentType(MediaType.APPLICATION_JSON)
                       .content(objectMapper.writeValueAsString(req)))
               .andExpect(status().isUnprocessableEntity())
               .andExpect(jsonPath("$.message").value("Email already registered"));
    }

    @Test
    @WithMockUser
    void getUser_nonExistent_returns404() throws Exception {
        mockMvc.perform(get("/api/v1/users/9999"))
               .andExpect(status().isNotFound())
               .andExpect(jsonPath("$.message").value("User with id 9999 not found"));
    }
}
📊 Production Insight
H2 is a different database engine—it's more permissive than PostgreSQL. Queries that pass on H2 can fail in production due to SQL dialect differences or constraint handling.
Testcontainers provides a real PostgreSQL instance, ensuring tests run against the same database engine as production.
Rule: Use Testcontainers for integration tests to catch database-specific bugs before deployment.
🎯 Key Takeaway
Use @SpringBootTest with Testcontainers for integration tests against a real PostgreSQL database.
H2 in-memory is convenient but can hide database-specific bugs—Testcontainers ensures parity with production.
Structure tests by layer: @WebMvcTest for controllers, @DataJpaTest for repositories, @SpringBootTest for full integration.
🗂 HTTP Method to Status Code Reference
OperationHTTP MethodSuccess StatusNotes
Get resourceGET200 OKInclude pagination headers for collections
Create resourcePOST201 CreatedReturn Location header and the created resource
Replace resourcePUT200 OKFull replacement — all fields required
Partial updatePATCH200 OKOnly provided fields are updated
Delete resourceDELETE204 No ContentNo response body

🎯 Key Takeaways

  • @RestController + @RequestMapping at class level, verb-specific annotations at method level. Keep the structure consistent across your codebase.
  • Use DTOs — never expose JPA entities as request or response bodies. Define UserDto, CreateUserRequest, UpdateUserRequest, and PagedResponse<T> explicitly.
  • Validate with @Valid + Bean Validation constraints. Handle MethodArgumentNotValidException in @RestControllerAdvice and return all field errors at once, not just the first.
  • Keep controllers thin: routing and HTTP translation only. Service layer owns business logic; @Transactional belongs on service methods.
  • Centralise all error handling in @RestControllerAdvice. Standardise the error response shape across the entire API using a shared ApiError record.
  • Return ResponseEntity when you need to set headers or choose status codes at runtime. Use @ResponseStatus for fixed codes.
  • Start every Spring Boot project from Initializr with explicit dependency selection. The project structure it generates is a convention that Spring's component scanning depends on — don't reorganise it.
  • Use MapStruct for compile-time DTO mapping. Lombok must be declared before MapStruct in the annotation processor configuration or MapStruct cannot see the generated accessors.
  • Paginate from day one. Spring Data JPA's Page<T> + Pageable gives you pagination, sorting, and metadata with zero SQL. Always cap page size server-side. Use PagedResponse<T> to expose a clean contract.
  • For JWT APIs, disable CSRF explicitly in SecurityConfig — CSRF protection is for browser cookie auth, not Bearer token auth. Without disabling it, all state-changing requests will return 403.
  • Use @SpringBootTest + Testcontainers for integration tests against a real PostgreSQL database. H2 is a different engine — queries that work on H2 can fail on PostgreSQL.
  • Enable virtual threads with spring.threads.virtual.enabled=true on Java 21. For I/O-heavy REST APIs, this eliminates manual thread pool tuning with zero code changes.

⚠ Common Mistakes to Avoid

    Exposing JPA entities as API response bodies
    Symptom

    Jackson throws StackOverflowError on circular references or serializes internal Hibernate fields like lazy proxies.

    Fix

    Always use DTOs for request and response bodies. Use MapStruct for compile-time mapping.

    @Transactional on controller methods
    Symptom

    Partial writes commit even if later logic fails, because the transaction boundary is wrong.

    Fix

    Move @Transactional to service methods where the full unit of work is defined.

    Returning 200 OK for every successful operation
    Symptom

    Clients cannot distinguish between creation (201), deletion (204), and simple retrieval (200).

    Fix

    Use appropriate HTTP status codes: 201 Created for POST, 204 No Content for DELETE.

    No global exception handler
    Symptom

    Exceptions bubble to the client as 500 with stack traces, exposing internal implementation details.

    Fix

    Implement @RestControllerAdvice with handlers for typed exceptions and a generic fallback.

    Setting spring.jpa.hibernate.ddl-auto=create-drop in production
    Symptom

    Database tables are dropped and recreated on every application restart, wiping all data.

    Fix

    Use validate in production. Manage schema changes with Flyway or Liquibase.

    Not capping pagination page size server-side
    Symptom

    Clients can request size=10000000, saturating database connections and causing OOM.

    Fix

    Cap page size: int cappedSize = Math.min(size, 100);

    Not disabling CSRF for stateless JWT APIs
    Symptom

    All POST/PUT/DELETE requests return 403 Forbidden because Spring Security blocks them.

    Fix

    Explicitly disable CSRF in SecurityConfig: .csrf(csrf -> csrf.disable())

    Exposing /actuator/* publicly without authentication
    Symptom

    Sensitive endpoints like /env expose environment variables including database passwords and API keys.

    Fix

    In production, expose only /health and /info publicly on a separate internal port (management.server.port=8081); protect the rest behind admin role.

    Wrong annotation processor order: MapStruct before Lombok in pom.xml
    Symptom

    MapStruct generates empty or broken mapper implementations because Lombok's getters/setters haven't been generated yet when MapStruct runs.

    Fix

    Always declare Lombok before MapStruct in the annotationProcessorPaths configuration in maven-compiler-plugin.

    Using @CrossOrigin on individual controllers instead of global CORS config
    Symptom

    Configuration drift — different controllers allow different origins; new controllers default to blocking cross-origin requests until someone remembers to add the annotation.

    Fix

    Configure CORS globally via WebMvcConfigurer. Drive allowed origins from configuration properties per environment.

Interview Questions on This Topic

  • QWhat is the difference between @Controller and @RestController?JuniorReveal
    @Controller marks a Spring MVC controller. Without @ResponseBody on each method, Spring resolves return values as view names for template engines. @RestController = @Controller + @ResponseBody — every method return value is serialised directly to the HTTP response body as JSON. Use @RestController for REST APIs.
  • QHow does Spring Boot handle JSON serialisation?JuniorReveal
    Spring Boot auto-configures Jackson as the default HttpMessageConverter. When a method returns an object, Jackson serialises it to JSON using ObjectMapper. When a @RequestBody parameter is present, Jackson deserialises the JSON body into the Java type. Customise with @JsonProperty, @JsonIgnore, @JsonFormat, or a custom ObjectMapper bean.
  • QWhat is @RestControllerAdvice?Mid-levelReveal
    @RestControllerAdvice = @ControllerAdvice + @ResponseBody. It defines global exception handlers applied across all controllers. Use it to centralise error handling: catch MethodArgumentNotValidException for 400s, ResourceNotFoundException for 404s, and Exception as a 500 fallback. Returns JSON instead of a view.
  • QWhen do you use ResponseEntity vs @ResponseStatus?Mid-levelReveal
    @ResponseStatus on a method sets a fixed status code at compile time — good for static cases. ResponseEntity<T> gives runtime control: choose the status dynamically, add headers (like Location on 201 Created), or return different status codes based on business logic. Use ResponseEntity when the status depends on data.
  • QWhat is the difference between @PathVariable and @RequestParam?JuniorReveal
    @PathVariable extracts segments from the URL path: GET /users/{id}. @RequestParam reads query string values: GET /users?page=2&size=10. Path variables identify a specific resource; query parameters filter, sort, or paginate a collection.
  • QHow does Spring Boot's auto-configuration work and what happens if you add spring-boot-starter-security to the classpath?SeniorReveal
    Spring Boot auto-configuration uses conditional annotations (@ConditionalOnClass, @ConditionalOnMissingBean) to configure beans based on what's on the classpath. Adding spring-boot-starter-security triggers SecurityAutoConfiguration, which immediately secures all endpoints with HTTP Basic Auth and a generated password. You must then customize SecurityFilterChain to fit your auth model (e.g., JWT, OAuth2).
  • QExplain the difference between @WebMvcTest, @DataJpaTest, and @SpringBootTest. When would you use each?SeniorReveal
    @WebMvcTest loads only the web layer (controllers, filters) for fast controller tests; services are mocked with @MockBean. @DataJpaTest loads only the JPA persistence layer, configuring an in-memory database and @Transactional rollback for repository tests. @SpringBootTest loads the full application context for integration tests that verify the entire request-to-database flow, often with Testcontainers for a real database. Use @WebMvcTest for controller logic, @DataJpaTest for queries, and @SpringBootTest for end-to-end scenarios.
  • QWhy should you disable CSRF in a stateless REST API using JWT authentication?Mid-levelReveal
    CSRF protection is for browser session-based authentication where cookies are sent automatically. In a stateless JWT API, the client explicitly includes the token in the Authorization header, not via cookies. CSRF attacks rely on the browser automatically sending credentials (cookies), which doesn't happen with Bearer tokens. Leaving CSRF enabled causes Spring Security to block all state-changing requests (POST, PUT, DELETE) with 403 Forbidden.
  • QA findAll() endpoint that works fine in development starts timing out in production with 500,000 records. Walk me through diagnosing and fixing it.SeniorReveal
    1. Check logs for database query timeout or connection pool exhaustion. 2. Verify the endpoint returns an unbounded list (no pagination). 3. Inspect the SQL generated—likely a SELECT * without LIMIT. 4. Fix by implementing pagination: add Pageable parameter, return Page<T>, cap page size server-side. 5. Consider adding database indexes for sort/filter fields. 6. Monitor with Actuator metrics to confirm improvement.

Frequently Asked Questions

What is a REST API in Spring Boot?

A REST API in Spring Boot is a stateless HTTP service built with @RestController. Spring Boot auto-configures Jackson for JSON, embedded Tomcat as the server, and Bean Validation for input checking. You annotate Java methods with @GetMapping, @PostMapping, etc., and Spring handles routing, serialisation, and the HTTP lifecycle.

What does @RestController do in Spring Boot?

@RestController combines @Controller and @ResponseBody. It marks a class as a Spring MVC controller where every method's return value is written directly to the HTTP response body as JSON (via Jackson). Without @ResponseBody, Spring would interpret the return value as a template view name.

How do you handle exceptions in a Spring Boot REST API?

Create a class annotated with @RestControllerAdvice containing @ExceptionHandler methods. Define typed exceptions (ResourceNotFoundException, BusinessRuleException) and map them to appropriate HTTP status codes (404, 422). Always include a generic Exception handler to catch unexpected errors and return a safe message without exposing internals.

What is ResponseEntity in Spring Boot?

ResponseEntity<T> wraps a response body with full control over the HTTP status code and headers. Use it when you need to return 201 Created with a Location header after a POST, 204 No Content after a DELETE, or when the status code must be computed at runtime based on business logic.

What is the difference between @RequestBody and @RequestParam?

@RequestBody deserialises a JSON (or XML) request body into a Java object — used with POST, PUT, and PATCH. @RequestParam reads URL query string parameters — used for filtering, sorting, and pagination on GET requests. They are not interchangeable; mixing them up is one of the most common Spring Boot beginner mistakes.

What is the difference between @RestController and @Controller in Spring Boot?

@RestController is a convenience annotation that combines @Controller and @ResponseBody. Every method's return value is serialised directly to the HTTP response body (as JSON by default via Jackson). With plain @Controller, you'd need to add @ResponseBody to every method, or return view names for template rendering. For REST APIs, always use @RestController.

When should I use ResponseEntity vs just returning T from a controller method?

Return T directly when the HTTP status is always the same (200 OK) and you don't need to add custom headers. Use ResponseEntity<T> when you need to vary the status code at runtime (201 vs 200, 204 vs 200), add response headers (Location for POST 201, Content-Disposition for file downloads), or build conditional responses (200 OK vs 404 Not Found based on whether the resource exists).

What is the purpose of @Transactional(readOnly = true) on a service class?

Marking a service class or method with @Transactional(readOnly = true) signals to Hibernate and the database driver that this transaction will not modify data. Hibernate skips dirty checking on read-only transactions (a performance optimisation for large result sets), and some databases/connection pools can route read-only transactions to read replicas. Override at the method level with @Transactional (no readOnly) for methods that write.

What is PagedResponse and why not just return Spring's Page directly?

Spring's raw Page<T> serialisation includes internal implementation fields from the Pageable object — things like pageable.sort.sorted and pageable.offset — that clients start depending on. If you change the pagination implementation, those fields change and you've made a breaking API change without intending to. PagedResponse<T> is a simple record that exposes only the fields that are part of your contract: content, page, size, totalElements, totalPages, and last.

Should I enable virtual threads in Spring Boot on Java 21?

For most REST APIs, yes. Enable them with spring.threads.virtual.enabled=true. Virtual threads (Project Loom) replace the default thread-per-request model with lightweight virtual threads scheduled by the JVM. For I/O-heavy workloads — REST APIs that spend time waiting on database queries — virtual threads eliminate the need to tune thread pool sizes manually. You write the same blocking code; the runtime handles the concurrency. The main exception is if you have code that's sensitive to thread-local state leaking across requests, which is rare in a standard Spring Boot stack.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousSpring Boot Auto-Configuration ExplainedNext →Spring Boot Annotations Cheat Sheet
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged