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.
Plain-English First
A REST API is a way for programs to talk to each other over HTTP. Spring Boot makes building one fast — you write Java methods, add a few annotations, and Spring turns them into HTTP endpoints automatically.
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.
REST Architectural Constraints — The 6 Principles with Production Implications
REST is defined by six architectural constraints. Understanding each helps you design APIs that scale, evolve, and remain maintainable.
#
Constraint
Description
Production Implication
1
Client-Server
Separation of concerns: client handles UI, server handles data storage.
Enables independent evolution of frontend and backend. Choose API versioning strategy early (URL path vs header).
2
Stateless
Each request from client contains all information needed to process it. No server-side session.
Horizontal scaling is trivial. Any server can handle any request. Use JWT or session tokens passed on every request. Avoid server-side session stores.
3
Cacheable
Responses must implicitly or explicitly label themselves as cacheable or non-cacheable.
Use Cache-Control, ETag, Last-Modified headers to reduce load. For read-heavy APIs, set caching headers aggressively. For sensitive data, set Cache-Control: no-store.
4
Uniform Interface
Resources identified in requests; manipulation through representations; self-descriptive messages; HATEOAS (optional but encouraged).
Use consistent resource naming (/users, /users/{id}), standard HTTP methods, and meaningful status codes. HATEOAS is often omitted in practice but improves discoverability.
5
Layered System
Client cannot tell whether it is connected directly to the end server or to an intermediary.
Allows load balancers, reverse proxies, API gateways. Ensure your API works behind a CDN or gateway without modification (e.g., use relative URLs or trust X-Forwarded-* headers).
6
Code on Demand (optional)
Server can transfer executable code to client (e.g., JavaScript).
Rarely used in REST APIs. If used, ensure strict security controls to prevent XSS.
These constraints are guidelines, not rules carved in stone. Many successful APIs relax HATEOAS and Code on Demand. Statelessness and uniform interface, however, are non-negotiable for scalable REST APIs.
Statelessness is the key to scalability
If you store session state on the server, you force sticky sessions — the load balancer must always route a given client to the same server. This defeats the purpose of horizontal scaling. True statelessness means any server can handle any request. Move required state to the client (cookies, tokens) or an external store (Redis) that all servers share.
Production Insight
The uniform interface constraint is what makes REST APIs self-documenting and predictable. When every resource follows the same pattern (GET /resource, POST /resource, DELETE /resource/{id}), new developers can work with any endpoint without reading every line of code.
Violating uniform interface by mixing conventions (some endpoints use /getUser, others /users/{id}) creates confusion and bugs.
Rule: Enforce a consistent URL and method pattern across your entire API.
Key Takeaway
REST has six constraints; statelessness, uniform interface, and cacheability are the most impactful for production APIs.
Use caching headers to reduce load; maintain uniform resource naming; keep the client-server separation clean.
HATEOAS and code on demand are optional — many production APIs skip them without consequence.
CRUD Operations via HTTP Methods
REST maps the four basic CRUD operations to HTTP methods. The table below summarises the mapping, including idempotency (whether repeating the same request produces the same side effect) and whether a request body is expected.
Operation
HTTP Method
URL Pattern
Idempotent
Request Body
Success Response
Create
POST
/resource
No
JSON representation
201 Created + Location header + body
Read (all)
GET
/resource
Yes
No
200 OK + list
Read (one)
GET
/resource/{id}
Yes
No
200 OK + single resource
Update (full)
PUT
/resource/{id}
Yes
Full representation
200 OK or 204 No Content
Update (partial)
PATCH
/resource/{id}
No (usually)
Partial representation
200 OK
Delete
DELETE
/resource/{id}
Yes
No
204 No Content
Idempotency is critical for network reliability. If a PUT request times out, the client can safely retry because the second request will have the same effect as the first. POST creates new resources with each invocation and is not idempotent — retries may create duplicates unless you implement idempotency keys (a header like Idempotency-Key).
URL Pattern conventions: use plural nouns for resource collections (/users, /products), and /{id} for individual resources. Avoid verbs in URLs (/createUser, /deleteUser?id=5).
Idempotency is not automatic
PUT is idempotent only if the server guarantees that replacing the resource with the same representation always leaves it in the same state. If your PUT handler increments a counter or logs each request, it is not idempotent. For POST, implement idempotency keys for critical operations like payments or user creation to prevent duplicates.
Production Insight
Idempotency is a contract between client and server. When a client retries a request after a timeout, they assume the server will handle it safely. If your PUT creates side effects beyond resource replacement (e.g., sending notifications), it breaks idempotency.
Rule: Design mutating endpoints to be idempotent where possible. For non-idempotent POST, accept an idempotency key header and deduplicate server-side.
Key Takeaway
CRUD maps to HTTP: POST (create), GET (read), PUT (replace), PATCH (partial update), DELETE (delete).
Idempotent methods (GET, PUT, DELETE) allow safe retries. POST and PATCH are not idempotent by default.
Use plural nouns for collections, avoid verbs in URLs, and return appropriate status codes (201, 200, 204).
HTTP Status Codes Quick Reference
HTTP status codes are part of your API contract. Clients and load balancers use them to determine success, failure, or the need for retries. Below is a reference of the most common codes used in REST APIs, grouped by class.
#### 2xx Success | Code | Name | Meaning | When to Use | |------|------|---------|-------------| | 200 | OK | Standard success for GET, PUT, PATCH. | Return the representation. | | 201 | Created | Resource was created. | POST endpoints; include Location header. | | 204 | No Content | Success with no response body. | DELETE, or PUT when returning nothing. |
#### 3xx Redirection | Code | Name | Meaning | When to Use | |------|------|---------|-------------| | 301 | Moved Permanently | Resource URL changed. | API version migration; provide new URL in Location header. | | 302 | Found (Temporary Redirect) | Resource temporarily at different URL. | Maintenance redirects. | | 304 | Not Modified | Use caching: client has current version. | Conditional GET with ETag / Last-Modified. |
#### 4xx Client Error | Code | Name | Meaning | When to Use | |------|------|---------|-------------| | 400 | Bad Request | Malformed request or validation failure. | Validation errors, missing parameters, bad JSON. | | 401 | Unauthorized | Authentication required or failed. | Missing or invalid JWT. | | 403 | Forbidden | Authenticated but not allowed. | Role-based access control violations. | | 404 | Not Found | Resource does not exist. | GET /users/{id} when id not found. | | 409 | Conflict | Resource state conflict. | Duplicate email on create (with unique constraint). | | 422 | Unprocessable Entity | Semantic error (business rule violation). | Age below 18, status change not allowed. | | 429 | Too Many Requests | Rate limit exceeded. | Return Retry-After header. |
#### 5xx Server Error | Code | Name | Meaning | When to Use | |------|------|---------|-------------| | 500 | Internal Server Error | Unexpected server failure. | Catch-all in global handler (log full error, return safe message). | | 502 | Bad Gateway | Upstream service returned invalid response. | When your API depends on external service that fails. | | 503 | Service Unavailable | Temporary overload or maintenance. | Return Retry-After header. | | 504 | Gateway Timeout | Upstream service timed out. | Proxied requests that exceed timeout. |
Production tip: Do not expose 500 series stack traces to clients. Use a global exception handler to convert all unhandled exceptions into a generic 500 response with a unique error ID for logging correlation.
Status codes are part of your API contract
Mobile and frontend teams build their error handling around your status codes. Using 200 for everything and putting error details in the body forces them to parse every response. Use the correct code and they can write cleaner error handlers.
Production Insight
Enforcing consistent status codes across your entire API makes client error handling predictable. A client that always receives 400 for validation, 404 for not found, and 422 for business rules can write a simple switch statement.
Inconsistent or non-standard codes (e.g., returning 200 with an error flag) increase client complexity and the likelihood of bugs.
Rule: Follow RFC 7231 status code definitions. Never invent your own codes (e.g., 499). Use the standard ones that match the semantics.
Key Takeaway
Use 2xx for success (200, 201, 204), 4xx for client errors (400, 401, 403, 404, 409, 422), and 5xx for server errors (500, 502, 503, 504).
Never expose internal error details on 5xx responses.
Consistent status codes simplify client error handling and improve API usability.
Advantages and Disadvantages of REST
REST is the dominant architectural style for web APIs, but it is not without trade-offs. Understanding its strengths and weaknesses helps you decide when it is the right fit and what alternatives (GraphQL, gRPC) might better suit your use case.
Advantages
Disadvantages
Simplicity — Uses standard HTTP methods and status codes. Developers already know them.
Over-fetching / Under-fetching — A fixed response shape may include too much or too little data for a specific client.
Scalability — Statelessness makes horizontal scaling easy.
Multiple round trips — Getting related resources often requires multiple requests (e.g., /users then /users/{id}/orders).
Caching — HTTP caching (Cache-Control, ETags) can drastically reduce load.
No strong typing — JSON is loosely typed; schema validation depends on documentation or code generation.
Separation of concerns — Client and server evolve independently.
Versioning overhead — Changes to resources often require new versions (e.g., /v2/users).
Ubiquity — Every language and platform has HTTP libraries; no special client needed.
Real-time support weak — REST is request-response; push notifications require WebSocket or SSE.
Tooling — Loads of tools: cURL, Postman, Swagger, monitoring, proxies.
Bandwidth overhead — HTTP headers can be large, especially with verbose JSON.
Statelessness — Simplifies server implementation and failure recovery.
Search and complex queries — REST struggles with flexible filtering and nested queries (often result in verbose query parameters).
When to choose an alternative: - GraphQL — If your clients need exactly the data they ask for and you want a single endpoint. Overhead: more complex caching, no native HTTP caching, requires a resolver layer. - gRPC — If you need high performance, strong typing, and bidirectional streaming (microservice-to-microservice). Overhead: HTTP/2 mandatory, not browser-friendly without proxy. - WebSocket — If you need real-time bidirectional communication (chat, live notifications).
For the vast majority of public and internal APIs, REST remains the best choice due to its simplicity, maturity, and tooling ecosystem.
Production Insight
The decision between REST, GraphQL, and gRPC is a trade-off between client flexibility (GraphQL), performance (gRPC), and simplicity (REST).
For an API consumed by multiple frontend teams with varying data requirements, GraphQL may reduce the number of round trips. For internal microservice communication, gRPC offers better performance and contract enforcement.
Rule: Start with REST. Move to alternatives only when you have a clear, measured need that REST cannot efficiently address.
Key Takeaway
REST is simple, scalable, and ubiquitous but suffers from over/under-fetching, multiple round trips, and lack of strong typing.
Consider GraphQL for flexible client queries, gRPC for inter-service communication, but stick with REST for general-purpose APIs.
The uniformity of REST comes at the cost of not being optimised for every client's needs.
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)
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.
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.
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.
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.
Three files, three concerns:
application.properties — shared defaults, virtual threads flag, common Actuator config
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 + profilesPROPERTIES
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# ── application.properties (shared defaults) ─────────────────────────────────
spring.application.name=user-service
# Virtual threads — enables ProjectLoomfor I/O-heavy RESTAPIs on Java21+
# 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
# NoSQL 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 — 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")
publicclassUserDto {
@Schema(description = "User ID", example = "42")
privateLong id;
@Schema(description = "Full name", example = "Alice Chen")
privateString name;
@Schema(description = "Email address", example = "alice@thecodeforge.io")
privateString email;
privateInteger age;
@Schema(description = "Account status", example = "ACTIVE",
allowableValues = {"ACTIVE", "INACTIVE", "SUSPENDED"})
privateString status;
@JsonFormat(shape = JsonFormat.Shape.STRING)
privateInstant createdAt;
@JsonFormat(shape = JsonFormat.Shape.STRING)
privateInstant updatedAt;
}
// ── CreateUserRequest.java ─────────────────────────────────────────────────────package io.thecodeforge.userservice.dto;
import jakarta.validation.constraints.*;
import lombok.Data;
@DatapublicclassCreateUserRequest {
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
privateString name;
@NotBlank(message = "Email is required")
@Email(message = "Must be a valid email address")
privateString email;
@NotNull(message = "Age is required")
@Min(value = 18, message = "User must be at least 18")
@Max(value = 120, message = "Age is not realistic")
privateInteger age;
}
// ── UpdateUserRequest.java ─────────────────────────────────────────────────────package io.thecodeforge.userservice.dto;
import jakarta.validation.constraints.*;
import lombok.Data;
@DatapublicclassUpdateUserRequest {
@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
) {
publicstatic <T> PagedResponse<T> from(Page<T> page) {
returnnewPagedResponse<>(
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.
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 is defined in the DTOs section above.// The @Valid annotation on @RequestBody triggers these constraints.// ── GlobalExceptionHandler.java (validation portion) ──────────────────────────
@RestControllerAdvicepublicclassGlobalExceptionHandler {
// Returns ALL field errors at once — not just the first
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
publicMap<String, String> handleValidationErrors(
MethodArgumentNotValidException ex) {
Map<String, String> errors = newLinkedHashMap<>();
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.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
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 packagepublicclassResourceNotFoundExceptionextendsRuntimeException {
publicResourceNotFoundException(String resource, Long id) {
super(resource + " with id " + id + " not found");
}
}
publicclassBusinessRuleExceptionextendsRuntimeException {
publicBusinessRuleException(String message) {
super(message);
}
}
// Standardised error body — used for every error responsepublic record ApiError(int status, String message, Instant timestamp) {}
// ── GlobalExceptionHandler.java ───────────────────────────────────────────────
@RestControllerAdvicepublicclassGlobalExceptionHandler {
privatestaticfinalLogger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
publicMap<String, String> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> errors = newLinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(
err -> errors.put(err.getField(), err.getDefaultMessage())
);
return errors;
}
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
publicApiErrorhandleNotFound(ResourceNotFoundException ex) {
returnnewApiError(404, ex.getMessage(), Instant.now());
}
@ExceptionHandler(BusinessRuleException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
publicApiErrorhandleBusinessRule(BusinessRuleException ex) {
returnnewApiError(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)
publicApiErrorhandleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
returnnewApiError(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 patternsJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// POST returning 201 + Location header (correct REST practice)
@PostMappingpublicResponseEntity<UserDto> createUser(@RequestBody @ValidCreateUserRequest req) {
UserDto created = userService.create(req);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.getId())
.toUri();
returnResponseEntity.created(location).body(created);
}
// GET with conditional 200/404 — Optional.map makes this clean
@GetMapping("/{id}")
publicResponseEntity<UserDto> getUser(@PathVariableLong id) {
return userService.findOptional(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// File download — Content-Disposition and content type set explicitly
@GetMapping("/export")
publicResponseEntity<byte[]> exportCsv() {
byte[] csv = reportService.generateCsv();
returnResponseEntity.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.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
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 belowpublicclassUserService {
privatefinalUserRepository repo;
privatefinalUserMapper mapper;
publicUserService(UserRepository repo, UserMapper mapper) {
this.repo = repo;
this.mapper = mapper;
}
publicUserDtofindById(Long id) {
return repo.findById(id)
.map(mapper::toDto)
.orElseThrow(() -> newResourceNotFoundException("User", id));
}
public java.util.Optional<UserDto> findOptional(Long id) {
return repo.findById(id).map(mapper::toDto);
}
publicPagedResponse<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);
returnPagedResponse.from(result);
}
@Transactional// overrides class-level readOnly — this method writespublicUserDtocreate(CreateUserRequest req) {
if (repo.existsByEmail(req.getEmail())) {
thrownewBusinessRuleException("Email already registered");
}
return mapper.toDto(repo.save(mapper.toEntity(req)));
}
@TransactionalpublicUserDtoupdate(Long id, UpdateUserRequest req) {
var user = repo.findById(id)
.orElseThrow(() -> newResourceNotFoundException("User", id));
mapper.updateEntityFromRequest(req, user);
return mapper.toDto(repo.save(user));
}
@Transactionalpublicvoiddelete(Long id) {
if (!repo.existsById(id)) {
thrownewResourceNotFoundException("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;
publicinterfaceUserRepositoryextendsJpaRepository<User, Long> {
booleanexistsByEmail(String email);
// Derived query — Spring Data generates the SQL from the method namebooleanexistsById(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.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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
)
publicinterfaceUserMapper {
// Entity → DTO// User.UserStatus enum → String: MapStruct calls .name() automatically// Instant fields: serialisation handled by @JsonFormat on the DTOUserDtotoDto(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 ACTIVEUsertoEntity(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)
voidupdateEntityFromRequest(UpdateUserRequest request, @MappingTargetUser 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 endpointJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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")
publicclassUserController {
privatefinalUserService userService;
publicUserController(UserService userService) {
this.userService = userService;
}
// GET /api/v1/users?page=0&size=20&sortBy=createdAt&sortDir=desc
@GetMappingpublicPagedResponse<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 sizereturn 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.
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")
publicclassUserController {
@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}")
publicResponseEntity<UserDto> getUserById(
@Parameter(description = "User ID", example = "42")
@PathVariableLong id) {
returnResponseEntity.ok(userService.findById(id));
}
@Operation(
summary = "Create a new user",
description = "Creates a user. Returns 409 Conflict if the email already exists."
)
@PostMappingpublicResponseEntity<UserDto> createUser(
@RequestBody @ValidCreateUserRequest request) {
UserDto created = userService.create(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.getId())
.toUri();
returnResponseEntity.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;
@ConfigurationpublicclassOpenApiConfig {
@BeanpublicOpenAPIuserServiceOpenAPI() {
returnnewOpenAPI()
.info(newInfo()
.title("User Service API")
.description("REST API for user management")
.version("v1.0")
.contact(newContact()
.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.
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 methodspublicclassSecurityConfig {
privatefinalJwtAuthenticationFilter jwtFilter;
publicSecurityConfig(JwtAuthenticationFilter jwtFilter) {
this.jwtFilter = jwtFilter;
}
@BeanpublicSecurityFilterChainfilterChain(HttpSecurity http) throwsException {
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;
@ComponentpublicclassJwtAuthenticationFilterextendsOncePerRequestFilter {
@OverrideprotectedvoiddoFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throwsServletException, 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 requiredif (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.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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;
@ConfigurationpublicclassCorsConfigimplementsWebMvcConfigurer {
// 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}")
privateString[] allowedOrigins;
@OverridepublicvoidaddCorsMappings(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 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 dothis
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
@ComponentpublicclassDownstreamServiceHealthIndicatorimplementsHealthIndicator {
privatefinalDownstreamServiceClient client;
publicDownstreamServiceHealthIndicator(DownstreamServiceClient client) {
this.client = client;
}
@OverridepublicHealthhealth() {
try {
client.ping(); // throws if service is unreachablereturnHealth.up()
.withDetail("service", "downstream-api")
.withDetail("status", "reachable")
.build();
} catch (Exception e) {
returnHealth.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.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
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;
importstatic org.mockito.Mockito.when;
importstatic org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
importstatic org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
importstatic org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(UserController.class)
classUserControllerTest {
@AutowiredMockMvc mockMvc;
@MockBeanUserService userService;
@AutowiredObjectMapper objectMapper; // same instance Spring uses — consistent serialisation
@Test
@WithMockUser// satisfies Spring Security's authentication requirement in the web layervoidgetUser_found_returns200() throwsException {
UserDto dto = newUserDto(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
@WithMockUservoidgetUser_notFound_returns404() throwsException {
when(userService.findById(99L))
.thenThrow(newResourceNotFoundException("User", 99L));
mockMvc.perform(get("/api/v1/users/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("User with id 99 not found"));
}
@Test
@WithMockUservoidcreateUser_invalidEmail_returns400() throwsException {
CreateUserRequest req = newCreateUserRequest();
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
@WithMockUservoidcreateUser_underageUser_returns400() throwsException {
CreateUserRequest req = newCreateUserRequest();
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.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
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;
importstatic org.assertj.core.api.Assertions.assertThat;
importstatic org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
importstatic org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest// full application context
@AutoConfigureMockMvc// MockMvc wired against the real context
@Testcontainers// Testcontainers lifecycle managementclassUserControllerIntegrationTest {
// Shared across all tests in this class — started once// Each test cleans up via @BeforeEach, not by restarting the container
@ContainerstaticPostgreSQLContainer<?> postgres =
newPostgreSQLContainer<>("postgres:16")
.withDatabaseName("userdb_test")
.withUsername("test")
.withPassword("test");
// Wire the random-port container to Spring's datasource config
@DynamicPropertySourcestaticvoidconfigureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@AutowiredMockMvc mockMvc;
@AutowiredObjectMapper objectMapper;
@AutowiredUserRepository userRepository;
@BeforeEachvoidcleanDatabase() {
userRepository.deleteAll();
}
@Test
@WithMockUservoidcreateUser_validRequest_returns201WithLocation() throwsException {
CreateUserRequest req = newCreateUserRequest();
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 PostgreSQLassertThat(userRepository.count()).isEqualTo(1);
}
@Test
@WithMockUservoidcreateUser_duplicateEmail_returns422() throwsException {
CreateUserRequest req = newCreateUserRequest();
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
@WithMockUservoidgetUser_nonExistent_returns404() throwsException {
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.
Practice Projects to Build Your Skills
Building a single REST API tutorial is not enough to master production patterns. These five projects progress from fundamentals to advanced patterns. Each forces you to apply the core concepts from this guide without hand-holding.
1. User Management API (CRUD + Security) - Implement full CRUD for users with JWT authentication. - Add role-based access (ADMIN, USER). - Harden with rate limiting (bucket4j) and audit logging. - Write @WebMvcTest and @SpringBootTest + Testcontainers tests.
2. Product Catalog with Pagination and Search - Expose /products with pagination, sorting, and filtering by category/price range. - Use Spring Data JPA Specifications for dynamic queries. - Add a health check that verifies database connectivity. - Containerize with Docker and configure production profile.
3. Order Processing with Validation and Business Rules - Build an order creation endpoint that validates stock, user credit, and shipping address. - Use custom @RestControllerAdvice for 422 Unprocessable Entity. - Implement idempotency keys on POST /orders. - Test concurrency scenarios (two users ordering last item).
4. File Upload and Download Service - Accept multipart file uploads, store them (local or S3), return a download URL. - Set Content-Disposition header for downloads. - Add file type validation and size limits with @Validated. - Test upload endpoint with MockMultipartFile.
5. Real-Time Dashboard API (REST + WebSocket) - Expose REST endpoints for CRUD on dashboard widgets. - Add a WebSocket endpoint /ws/dashboard that pushes updates when data changes. - Use Spring's SimpMessagingTemplate to broadcast changes. - Write integration tests that connect to WebSocket and verify push notifications.
Each project should be started from Spring Initializr with the appropriate dependencies. Use the directory structure and patterns from this guide. Implement MapStruct DTOs, global exception handling, pagination, and Testcontainers integration tests in every project until they become muscle memory.
Production Insight
The best way to internalize these patterns is to build them yourself. Copy-paste from the guide and then refactor — change field names, add new endpoints, break tests on purpose. The mistakes you make during practice are lessons that don't cost you a production incident.
Rule: Build at least three of these projects before starting your first production REST API. You will catch architectural issues during practice that are expensive to fix in production.
Key Takeaway
Practice builds fluency. Implement User Management, Product Catalog, Order Processing, File Upload, and Real-Time Dashboard APIs.
Each project reinforces a different set of patterns: CRUD, pagination, validation, idempotency, file handling, WebSocket.
Start each from scratch with Initializr rather than forking existing code — you learn more when you make your own mistakes.
● Production incidentPOST-MORTEMseverity: high
The ddl-auto=create-drop Disaster
Symptom
After a routine deployment restart, all user data vanished. The application came up with empty tables.
Assumption
The team assumed the database configuration was correct because the application started without errors in development.
Root cause
The property spring.jpa.hibernate.ddl-auto=create-drop was present in the production profile. Hibernate dropped and recreated all tables on shutdown/startup.
Fix
Changed 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 APIs4 entries
Symptom · 01
API returns 500 with stack trace visible to client
→
Fix
Check for missing @RestControllerAdvice or unhandled exceptions. Ensure a generic Exception handler logs server-side but returns a safe message.
Symptom · 02
POST/PUT/DELETE requests return 403 Forbidden
→
Fix
Verify CSRF is disabled in SecurityConfig for your stateless JWT API. CSRF is for browser cookie auth.
Symptom · 03
Endpoint times out with large dataset
→
Fix
Check if endpoint returns an unbounded list. Implement pagination with Pageable and cap page size server-side.
Symptom · 04
Health check passes but service is failing
→
Fix
Verify /actuator/health includes database and critical dependency checks, not just JVM liveness.
★ Spring Boot REST API Quick DebugImmediate checks for common production issues.
Database connection failures on startup−
Immediate action
Check datasource URL, credentials, and network connectivity.
Commands
docker compose logs db | tail -50
psql $DB_URL -c 'SELECT 1'
Fix now
Verify spring.datasource.* properties match environment. Ensure DB is reachable from the application's network.
High latency on specific endpoint+
Immediate action
Identify 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 now
Add @EntityGraph for fetch joins or implement pagination with Pageable.
Memory pressure / OOM errors+
Immediate action
Check 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 now
Replace findAll() with paginated queries. Ensure DTOs are used to avoid loading full entity graphs.
HTTP Method to Status Code Reference
Operation
HTTP Method
Success Status
Notes
Get resource
GET
200 OK
Include pagination headers for collections
Create resource
POST
201 Created
Return Location header and the created resource
Replace resource
PUT
200 OK
Full replacement — all fields required
Partial update
PATCH
200 OK
Only provided fields are updated
Delete resource
DELETE
204 No Content
No response body
Key takeaways
1
@RestController + @RequestMapping at class level, verb-specific annotations at method level. Keep the structure consistent across your codebase.
2
Use DTOs
never expose JPA entities as request or response bodies. Define UserDto, CreateUserRequest, UpdateUserRequest, and PagedResponse<T> explicitly.
3
Validate with @Valid + Bean Validation constraints. Handle MethodArgumentNotValidException in @RestControllerAdvice and return all field errors at once, not just the first.
4
Keep controllers thin
routing and HTTP translation only. Service layer owns business logic; @Transactional belongs on service methods.
5
Centralise all error handling in @RestControllerAdvice. Standardise the error response shape across the entire API using a shared ApiError record.
6
Return ResponseEntity when you need to set headers or choose status codes at runtime. Use @ResponseStatus for fixed codes.
7
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.
8
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.
9
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.
10
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.
11
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.
12
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
10 patterns
×
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 PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What is the difference between @Controller and @RestController?
Q02JUNIOR
How does Spring Boot handle JSON serialisation?
Q03SENIOR
What is @RestControllerAdvice?
Q04SENIOR
When do you use ResponseEntity vs @ResponseStatus?
Q05JUNIOR
What is the difference between @PathVariable and @RequestParam?
Q06SENIOR
How does Spring Boot's auto-configuration work and what happens if you a...
Q07SENIOR
Explain the difference between @WebMvcTest, @DataJpaTest, and @SpringBoo...
Q08SENIOR
Why should you disable CSRF in a stateless REST API using JWT authentica...
Q09SENIOR
A findAll() endpoint that works fine in development starts timing out in...
Q01 of 09JUNIOR
What is the difference between @Controller and @RestController?
ANSWER
@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.
Q02 of 09JUNIOR
How does Spring Boot handle JSON serialisation?
ANSWER
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.
Q03 of 09SENIOR
What is @RestControllerAdvice?
ANSWER
@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.
Q04 of 09SENIOR
When do you use ResponseEntity vs @ResponseStatus?
ANSWER
@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.
Q05 of 09JUNIOR
What is the difference between @PathVariable and @RequestParam?
ANSWER
@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.
Q06 of 09SENIOR
How does Spring Boot's auto-configuration work and what happens if you add spring-boot-starter-security to the classpath?
ANSWER
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).
Q07 of 09SENIOR
Explain the difference between @WebMvcTest, @DataJpaTest, and @SpringBootTest. When would you use each?
ANSWER
@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.
Q08 of 09SENIOR
Why should you disable CSRF in a stateless REST API using JWT authentication?
ANSWER
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.
Q09 of 09SENIOR
A findAll() endpoint that works fine in development starts timing out in production with 500,000 records. Walk me through diagnosing and fixing it.
ANSWER
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.
01
What is the difference between @Controller and @RestController?
JUNIOR
02
How does Spring Boot handle JSON serialisation?
JUNIOR
03
What is @RestControllerAdvice?
SENIOR
04
When do you use ResponseEntity vs @ResponseStatus?
SENIOR
05
What is the difference between @PathVariable and @RequestParam?
JUNIOR
06
How does Spring Boot's auto-configuration work and what happens if you add spring-boot-starter-security to the classpath?
SENIOR
07
Explain the difference between @WebMvcTest, @DataJpaTest, and @SpringBootTest. When would you use each?
SENIOR
08
Why should you disable CSRF in a stateless REST API using JWT authentication?
SENIOR
09
A findAll() endpoint that works fine in development starts timing out in production with 500,000 records. Walk me through diagnosing and fixing it.
SENIOR
FAQ · 10 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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.
Was this helpful?
03
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.
Was this helpful?
04
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.
Was this helpful?
05
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.
Was this helpful?
06
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.
Was this helpful?
07
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).
Was this helpful?
08
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.
Was this helpful?
09
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.
Was this helpful?
10
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.