Spring REST

Building production-grade REST APIs with Spring — DTOs, validation, error handling, HTTP semantics, versioning, and calling external APIs

← Back to Index

What is an API, and What Makes it REST?

An API (Application Programming Interface) is a contract that defines how two pieces of software communicate. In the web world, it means: "send me an HTTP request in this exact shape, and I'll send back a response in this exact shape." The API is the boundary — your Java code is invisible behind it, and the client (a browser, a mobile app, another backend service) only ever sees the contract.

Before REST existed, most APIs used SOAP — a protocol built on XML envelopes with rigid structure, its own error format, and a WSDL contract file. SOAP worked, but it was verbose, brittle, and painful to consume. In 2000, Roy Fielding's PhD dissertation described REST (Representational State Transfer) — not a protocol, but a set of architectural constraints for designing networked systems using what HTTP already provides. REST won because it maps naturally to HTTP, returns data in formats clients already understand (JSON, XML), and requires no special libraries to consume.

A RESTful API organises everything around resources (nouns, not verbs — /users, not /getUsers), uses standard HTTP verbs to express what operation to perform on those resources, and is stateless — each request carries all the context the server needs; no server-side session remembers who you are between calls. This is what makes REST APIs scalable: any server instance can handle any request, with no sticky sessions required.

/*
 *  WITHOUT REST (RPC-style, pre-2000 approach):
 *
 *  POST /getUserById        POST /createNewUser
 *  POST /updateUserEmail    POST /deleteUserAccount
 *  POST /getAllActiveUsers   POST /searchUsersByName
 *
 *  The verb is in the URL — every operation needs its own endpoint.
 *
 *  WITH REST (resource + HTTP verb):
 *
 *  GET    /users         → list users
 *  POST   /users         → create a user
 *  GET    /users/123     → get user 123
 *  PUT    /users/123     → replace user 123
 *  PATCH  /users/123     → partially update user 123
 *  DELETE /users/123     → delete user 123
 *
 *  One resource URL, six operations — the verb is the HTTP method, not the URL.
 */
Where Spring fits in

Spring doesn't invent REST — HTTP already defines the verbs, status codes, and headers. What Spring does is make it easy to map Java methods to that contract: @RestController + the HTTP verb annotations (@GetMapping, @PostMapping, etc.) are the bridge between "Java method" and "HTTP endpoint." The rest of this page is about building that bridge correctly — and the production-grade concerns most tutorials skip.

What This Page Covers

REST principles in depth are in RESTful API Principles. Spring's request routing mechanics are in Spring MVC. This page focuses on the full stack of building a production-grade REST API in Spring: DTOs, validation, correct HTTP semantics, error responses, versioning, and calling other services.

HTTP verb semantics — everything else depends on this
VerbIdempotent?Safe?Typical status codes
GETYesYes200, 404
POSTNoNo201 + Location, 400, 409, 422
PUTYesNo200, 204, 404
PATCHNot guaranteedNo200, 400, 404, 422
DELETEYesNo204, 404

Safe = no server state changes. Idempotent = repeating the same request produces the same end state. These determine whether a client or proxy can safely retry a failed request automatically.

DTOs — Never Expose JPA Entities in Your API

This is the most common architectural mistake in Spring REST APIs: returning the JPA @Entity directly from the controller. The entity is a database mapping — it may contain password hashes, internal audit fields, lazy-loaded collections that trigger N+1 queries during JSON serialisation, or bidirectional relationships that cause infinite recursion in Jackson. A DTO is what the API contract says it is, independent of how the database stores it.

// The JPA entity — internal, never the API contract
@Entity @Table(name = "users")
public class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
    private String name;
    private String email;
    private String passwordHash;     // NEVER expose this
    private LocalDateTime createdAt;
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Order> orders;        // serialising this triggers N+1 queries
}

// Response DTO — record: immutable, concise, zero boilerplate (Java 16+)
public record UserResponse(
    Long id,
    String name,
    String email,
    LocalDateTime createdAt
) {
    // Factory method keeps mapping logic in the DTO, not scattered across controllers
    public static UserResponse from(User user) {
        return new UserResponse(user.getId(), user.getName(), user.getEmail(), user.getCreatedAt());
    }
}

// Separate request DTOs — create and update have different validation rules
public record CreateUserRequest(
    @NotBlank @Size(min = 2, max = 100) String name,
    @NotBlank @Email String email,
    @NotBlank @Size(min = 8, max = 72) String password  // 72 = bcrypt max useful input length
) {}

public record UpdateUserRequest(
    @NotBlank @Size(min = 2, max = 100) String name,
    @NotBlank @Email String email
    // no password — changes go through a dedicated /change-password endpoint
) {}
One entity ≠ one DTO — model DTOs around API use cases

A user summary in a list view needs different fields than a detail page. A create request needs a password; an update request doesn't. An admin API exposes fields the public API shouldn't. Three separate DTOs for the same entity is normal and correct; one "universal" DTO trying to serve every use case is a maintenance trap.

A Complete REST Controller

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

    private final UserService userService;

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

    // GET /api/v1/users?page=0&size=20&sort=name
    @GetMapping
    public Page<UserResponse> listUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") @Max(100) int size,
            @RequestParam(defaultValue = "id") String sort) {
        return userService.findAll(PageRequest.of(page, size, Sort.by(sort)));
    }

    // GET /api/v1/users/123
    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
        return userService.findById(id)
            .map(ResponseEntity::ok)
            .orElseGet(() -> ResponseEntity.notFound().build());
    }

    // POST /api/v1/users  →  201 Created + Location: /api/v1/users/124
    @PostMapping
    public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest req) {
        UserResponse created = userService.create(req);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}").buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(created);
    }

    // PUT /api/v1/users/123  — FULL replace: client MUST send ALL fields
    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> replaceUser(@PathVariable Long id,
                                                @Valid @RequestBody UpdateUserRequest req) {
        return userService.replace(id, req)
            .map(ResponseEntity::ok)
            .orElseGet(() -> ResponseEntity.notFound().build());
    }

    // PATCH /api/v1/users/123  — partial update: only provided fields change
    @PatchMapping("/{id}")
    public ResponseEntity<UserResponse> patchUser(@PathVariable Long id,
                                              @RequestBody Map<String, Object> updates) {
        return userService.patch(id, updates)
            .map(ResponseEntity::ok)
            .orElseGet(() -> ResponseEntity.notFound().build());
    }

    // DELETE /api/v1/users/123  →  204 No Content (idempotent: 204 even if already gone)
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.delete(id);
        return ResponseEntity.noContent().build();
    }
}
PUT vs PATCH — using PUT for a partial update silently destroys data

PUT means "replace the entire resource with this." A client that sends only the changed field via a correctly-implemented PUT endpoint gets every omitted field reset to null or default — silent data loss, not a validation error. PATCH exists for "change only these fields." The Map<String, Object> body above is the simplest approach; for complex semantics, JSON Patch (RFC 6902) or JSON Merge Patch (RFC 7396) provide a standardised format.

Use ServletUriComponentsBuilder for the Location header

ServletUriComponentsBuilder.fromCurrentRequest() builds the Location URI from the actual incoming request — correct scheme, host, port, and context path automatically, regardless of whether the app runs locally, behind a reverse proxy, or under a context path. Hard-coding URI.create("/api/v1/users/" + id) breaks the moment a reverse proxy or context path is in play.

Validation

@NotNull vs @NotBlank vs @NotEmpty — they are not interchangeable
AnnotationRejects null?Rejects ""?Rejects " "?
@NotNullYesNoNo
@NotEmptyYesYesNo
@NotBlankYesYesYes

For String fields, @NotBlank is almost always the right choice. @NotNull on a String accepts an empty or whitespace-only value — a common source of "passes validation but breaks the NOT NULL database constraint" bugs.

// Constraint annotations compose directly on record components
public record CreateUserRequest(
    @NotBlank @Size(min = 2, max = 100) String name,
    @NotBlank @Email String email,
    @NotNull @Min(18) @Max(120) Integer age,
    @NotBlank @Size(min = 8, max = 72) String password,
    @Valid @NotNull AddressRequest address  // @Valid cascades into the nested record
) {}

public record AddressRequest(
    @NotBlank String street,
    @NotBlank @Pattern(regexp = "\\d{5}", message = "Must be a 5-digit postal code") String postalCode
) {}

Error Responses with ProblemDetail (RFC 9457)

Before Spring Framework 6, every team invented its own ErrorResponse class. Spring 6 / Boot 3 ships ProblemDetail — a standardised JSON error shape that clients can parse once and understand across any conforming API.

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Validation failure on @RequestBody — thrown automatically when @Valid fails
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
        p.setTitle("Validation Error");
        p.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
            .map(e -> Map.of(
                "field", e.getField(),
                "message", e.getDefaultMessage(),
                "rejected", String.valueOf(e.getRejectedValue())))
            .toList());
        return p;
    }

    // Resource not found
    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    // Conflict — duplicate email, optimistic locking failure, etc.
    @ExceptionHandler(DuplicateResourceException.class)
    public ProblemDetail handleConflict(DuplicateResourceException ex) {
        ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
        p.setProperty("conflictingField", ex.getField());
        return p;
    }

    // Catch-all — log internally, never leak stack traces to callers
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex) {
        log.error("Unexpected error", ex);
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
            "An unexpected error occurred");
    }
}

// RFC 9457 response shape — consistent across every endpoint:
// {
//   "type":   "about:blank",
//   "title":  "Validation Error",
//   "status": 400,
//   "detail": "Validation failed",
//   "errors": [{ "field": "email", "message": "must be a well-formed email address" }]
// }

# application.properties — also enables ProblemDetail for Spring's own built-in exceptions
spring.mvc.problemdetails.enabled=true
HTTP status codes that actually carry meaning
StatusWhen to use it
200 OKSuccessful GET, PUT, PATCH with body
201 CreatedSuccessful POST — always include Location header
204 No ContentSuccessful DELETE, or write with no response body
400 Bad RequestMalformed request, failed validation
401 UnauthorizedNot authenticated ("who are you?" — misleadingly named)
403 ForbiddenAuthenticated but not authorized
404 Not FoundResource doesn't exist
409 ConflictDuplicate key, concurrent modification
422 Unprocessable EntityWell-formed but semantically invalid (end date before start date)
500 Internal Server ErrorUnhandled exception — should never happen for known error cases

API Versioning

StrategyExampleReal trade-off
URI versioning /api/v1/users Explicit, cacheable, trivially testable in a browser. Most teams choose this for operational simplicity despite the REST-purist objection that a version isn't part of a resource identifier
Header versioning X-API-Version: 2 Keeps URIs stable; invisible in endpoint lists, can't be bookmarked or tested without tooling
Content negotiation Accept: application/vnd.company.v2+json REST-correct — a version is a representation format, exactly what Accept is for. Least common due to client complexity
// URI versioning
@RestController @RequestMapping("/api/v1/users")
public class UserControllerV1 { ... }

@RestController @RequestMapping("/api/v2/users")
public class UserControllerV2 { ... }

// Header versioning
@GetMapping(value = "/api/users/{id}", headers = "X-API-Version=2")
public UserResponseV2 getUserV2(@PathVariable Long id) { ... }

// Content negotiation
@GetMapping(value = "/api/users/{id}", produces = "application/vnd.company.user.v2+json")
public UserResponseV2 getUserV2(@PathVariable Long id) { ... }
The real goal: never need v3 — be additive, not breaking

Adding a new optional field or endpoint never requires a version bump — existing clients ignore unknown fields (Jackson defaults to FAIL_ON_UNKNOWN_PROPERTIES = false in Boot). A true version bump is for removing a field, changing a type, or changing status code semantics — changes that break existing clients regardless of care.

Pagination

@GetMapping
public Page<UserResponse> listUsers(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") @Max(100) int size,   // @Max protects the DB
        @RequestParam(defaultValue = "id") String sort) {
    return userService.findAll(PageRequest.of(page, size, Sort.by(sort)))
        .map(UserResponse::from);
}

// Page<T> serialises to:
// { "content":[...], "totalElements":150, "totalPages":8,
//   "number":0, "size":20, "first":true, "last":false }

The Page vs Slice trade-off (whether to pay the extra COUNT(*) cost) is in Spring Data JPA — Pagination. The REST-layer concern here is the @Max(100) guard — without it, ?size=1000000 loads your entire table in a single request.

Calling Other APIs with RestClient

@Service
public class ExternalApiService {

    private final RestClient restClient;

    // RestClient.Builder is auto-configured by Boot — inject and customise, don't new it
    public ExternalApiService(RestClient.Builder builder) {
        this.restClient = builder
            .baseUrl("https://api.example.com")
            .defaultHeader("Accept", "application/json")
            .requestInterceptor((req, body, execution) -> {
                req.getHeaders().setBearerAuth(tokenProvider.getToken());
                return execution.execute(req, body);
            })
            .build();
    }

    // GET — deserialise body directly
    public UserResponse getUser(Long id) {
        return restClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
                throw new ResourceNotFoundException("User not found: " + id);
            })
            .body(UserResponse.class);
    }

    // GET list — ParameterizedTypeReference for generic types
    public List<UserResponse> getAllUsers() {
        return restClient.get()
            .uri("/users")
            .retrieve()
            .body(new ParameterizedTypeReference<List<UserResponse>>() {});
    }

    // POST with request body
    public UserResponse createUser(CreateUserRequest request) {
        return restClient.post()
            .uri("/users")
            .contentType(MediaType.APPLICATION_JSON)
            .body(request)
            .retrieve()
            .body(UserResponse.class);
    }
}
Three Spring HTTP clients — know which one and why
ClientStatusChoose when
RestTemplateMaintenance mode since Spring 5.0Existing legacy code — don't migrate working code just to modernise
RestClientCurrent default (Spring 6.1+)New code in a Spring MVC (blocking) application — synchronous, fluent, direct RestTemplate replacement
WebClientReactive stackNon-blocking I/O in WebFlux applications — overkill if you're in Spring MVC and just need one HTTP call
RestClient throws on 4xx/5xx — handle it or it becomes your 500

A non-2xx response from a remote API throws a RestClientException subclass by default — it does not silently return null. Without handling this with .onStatus(), an external API's 404 bubbles up as an unhandled exception and returns a 500 to your caller, leaking implementation details through your own API surface.

Testing REST Endpoints with MockMvc

@WebMvcTest(UserController.class)  // loads ONLY the web layer — no DB, no full context
class UserControllerTest {

    @Autowired private MockMvc mockMvc;
    @Autowired private ObjectMapper objectMapper;
    @MockitoBean private UserService userService;  // @MockBean before Boot 3.4

    @Test
    void getUser_whenExists_returns200() throws Exception {
        var user = new UserResponse(1L, "Ana", "ana@example.com", LocalDateTime.now());
        when(userService.findById(1L)).thenReturn(Optional.of(user));

        mockMvc.perform(get("/api/v1/users/1").accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("Ana"))
            .andExpect(jsonPath("$.passwordHash").doesNotExist());  // assert no data leaks
    }

    @Test
    void getUser_whenNotFound_returns404() throws Exception {
        when(userService.findById(99L)).thenReturn(Optional.empty());
        mockMvc.perform(get("/api/v1/users/99"))
            .andExpect(status().isNotFound());
    }

    @Test
    void createUser_whenValid_returns201WithLocation() throws Exception {
        var req = new CreateUserRequest("Ana", "ana@example.com", "securepass", null);
        var res = new UserResponse(1L, "Ana", "ana@example.com", LocalDateTime.now());
        when(userService.create(any())).thenReturn(res);

        mockMvc.perform(post("/api/v1/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(req)).with(csrf()))
            .andExpect(status().isCreated())
            .andExpect(header().exists("Location"))
            .andExpect(jsonPath("$.id").value(1));
    }

    @Test
    void createUser_whenInvalidEmail_returns400() throws Exception {
        var req = new CreateUserRequest("Ana", "not-an-email", "securepass", null);

        mockMvc.perform(post("/api/v1/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(req)).with(csrf()))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.title").value("Validation Error"))
            .andExpect(jsonPath("$.errors[0].field").value("email"));
    }
}
Assert what's NOT in the response — not just what is

jsonPath("$.passwordHash").doesNotExist() is the test that catches "someone returned the entity directly." A test asserting only that correct fields are present passes even if sensitive fields are also present. Asserting the absence of fields that must never be exposed is the guard that survives code review.

Interview Questions

🎓 Junior level

Q: Why use DTOs instead of exposing JPA entities directly?
Entities are database mappings — they may contain sensitive fields, internal metadata, or lazy associations that trigger N+1 queries during serialisation. DTOs are the API contract: they contain exactly what the client needs for a specific operation, nothing more.

Q: What's the difference between PUT and PATCH?
PUT replaces the entire resource — every field the client omits is reset to null or default. PATCH applies a partial update — only the provided fields change. Using PUT for partial updates silently destroys data.

Q: Why does a successful POST return 201 instead of 200?
201 Created communicates that a new resource now exists. The Location header gives its URI — a 200 provides no standard way for the client to know where the created resource lives.

Q: What is the difference between @NotNull, @NotEmpty, and @NotBlank?
@NotNull rejects null only. @NotEmpty rejects null and empty strings. @NotBlank rejects null, empty, and whitespace-only strings. For String fields, @NotBlank is almost always the right choice.

🔥 Senior level

Q: Why is idempotency important for HTTP client retry logic?
Idempotent operations (GET, PUT, DELETE) can be safely retried automatically after a timeout — repeating them produces the same end state. POST cannot be retried blindly because the original may have succeeded before the connection dropped, and a retry creates a duplicate. Payment APIs require a client-supplied idempotency key precisely for this reason.

Q: What is ProblemDetail (RFC 9457) and why was it introduced in Spring 6?
It's a standardised JSON error shape (type, title, status, detail, plus custom extensions). Before Spring 6, every team invented its own error class — inconsistent shape across APIs, clients needed custom parsing per API. RFC 9457 standardises the contract once.

Q: RestClient vs RestTemplate vs WebClient — when would you choose each?
RestTemplate is in maintenance mode — don't migrate working code, don't choose it for new code. RestClient (Spring 6.1+) is the synchronous blocking replacement — better ergonomics, same use case. WebClient is for non-blocking reactive code in WebFlux — adding it to a Spring MVC application for one HTTP call pulls in the full reactive stack for no benefit.

Q: How does Jackson handle unknown fields by default in Boot, and what does that enable for versioning?
Boot configures Jackson with FAIL_ON_UNKNOWN_PROPERTIES = false — unknown fields are silently ignored. This makes additive versioning safe: a v2 API can add new response fields and v1 clients continue working without change. Removing a field or changing a type still breaks existing clients — those require a new API version.