How Do I Build REST APIs with Spring MVC?

Building REST APIs with Spring MVC

In Spring MVC, you build REST APIs by defining controller classes that map HTTP requests to Java methods. In modern Spring Boot applications, this is usually done with @RestController.

A typical REST API is organized like this:

HTTP Request
    ↓
Controller
    ↓
Service
    ↓
Repository
    ↓
Database

Each layer has a clear responsibility:

Layer Responsibility
Controller Handles HTTP requests and responses
Service Contains business logic
Repository Handles database access
Entity Represents database tables
DTO Represents API request/response data

1. Add the Spring Web Dependency

For Maven:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

If you need validation:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

If you use JPA:

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

2. Create a REST Controller

Use @RestController for REST APIs. It combines @Controller and @ResponseBody, meaning returned objects are written directly to the HTTP response, usually as JSON.

package com.example.demo.user;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @GetMapping("/api/hello")
    public String hello() {
        return "Hello, REST API!";
    }
}

Calling:

GET /api/hello

returns:

Hello, REST API!

3. Design Resource-Based URLs

REST APIs should use nouns for resources and HTTP methods for actions.

Good:

GET    /api/users
GET    /api/users/1
POST   /api/users
PUT    /api/users/1
DELETE /api/users/1

Avoid action-style URLs like:

/api/getUsers
/api/createUser
/api/deleteUser

The HTTP method already describes the operation.


4. Create DTOs for Request and Response Bodies

Avoid exposing database entities directly from your API. Use DTOs instead.

package com.example.demo.user;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record CreateUserRequest(
        @NotBlank String name,
        @NotBlank @Email String email
) {
}
package com.example.demo.user;

public record UserResponse(
        Long id,
        String name,
        String email
) {
}

DTOs keep your API contract separate from your database model.


5. Create REST Endpoints

A controller for basic CRUD operations might look like this:

package com.example.demo.user;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.util.List;

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

    private final UserService userService;

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

    @GetMapping
    public List<UserResponse> findAll() {
        return userService.findAll();
    }

    @GetMapping("/{id}")
    public UserResponse findById(@PathVariable Long id) {
        return userService.findById(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
        return userService.create(request);
    }

    @PutMapping("/{id}")
    public UserResponse update(
            @PathVariable Long id,
            @Valid @RequestBody CreateUserRequest request
    ) {
        return userService.update(id, request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        userService.delete(id);
    }
}

Key annotations:

Annotation Purpose
@RestController Marks the class as a REST controller
@RequestMapping Defines a base URL
@GetMapping Handles HTTP GET
@PostMapping Handles HTTP POST
@PutMapping Handles HTTP PUT
@DeleteMapping Handles HTTP DELETE
@PathVariable Reads values from the URL path
@RequestBody Reads JSON from the request body
@Valid Triggers Jakarta Bean Validation
@ResponseStatus Sets the HTTP response status

6. Put Business Logic in a Service

Controllers should stay thin. Put business rules in a service class.

package com.example.demo.user;

import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserService {

    public List<UserResponse> findAll() {
        // Load users from repository
        return List.of();
    }

    public UserResponse findById(Long id) {
        // Find user by id
        return new UserResponse(id, "Alice", "[email protected]");
    }

    public UserResponse create(CreateUserRequest request) {
        // Create user
        return new UserResponse(1L, request.name(), request.email());
    }

    public UserResponse update(Long id, CreateUserRequest request) {
        // Update user
        return new UserResponse(id, request.name(), request.email());
    }

    public void delete(Long id) {
        // Delete user
    }
}

In a real application, the service would call a repository.


7. Use Spring Data JPA for Persistence

If your API stores data in a database, create an entity and repository.

package com.example.demo.user;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class User {

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

    private String name;

    private String email;
}
package com.example.demo.user;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
}

By extending JpaRepository, you automatically get methods like:

findAll();
findById(id);
save(entity);
delete(entity);
existsById(id);

8. Return Proper HTTP Status Codes

Use status codes that match the result:

Situation Status
Successful read 200 OK
Created resource 201 Created
Deleted resource 204 No Content
Invalid request 400 Bad Request
Missing resource 404 Not Found
Conflict 409 Conflict
Server error 500 Internal Server Error

For creation, you can also return a Location header:

package com.example.demo.user;

import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;

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

    private final UserService userService;

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

    @PostMapping
    public ResponseEntity<UserResponse> create(
            @Valid @RequestBody CreateUserRequest request,
            UriComponentsBuilder uriBuilder
    ) {
        UserResponse response = userService.create(request);

        URI location = uriBuilder
                .path("/api/users/{id}")
                .buildAndExpand(response.id())
                .toUri();

        return ResponseEntity.created(location).body(response);
    }
}

9. Handle Errors Globally

Use @RestControllerAdvice to return consistent JSON errors.

package com.example.demo.exception;

import java.time.Instant;
import java.util.List;

public record ApiError(
        int status,
        String error,
        String message,
        String path,
        Instant timestamp,
        List<FieldErrorDetail> fieldErrors
) {
    public record FieldErrorDetail(
            String field,
            String message
    ) {
    }
}
package com.example.demo.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}
package com.example.demo.exception;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.time.Instant;
import java.util.List;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ApiError handleNotFound(
            ResourceNotFoundException ex,
            HttpServletRequest request
    ) {
        return new ApiError(
                404,
                "Not Found",
                ex.getMessage(),
                request.getRequestURI(),
                Instant.now(),
                List.of()
        );
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError handleValidation(
            MethodArgumentNotValidException ex,
            HttpServletRequest request
    ) {
        List<ApiError.FieldErrorDetail> fieldErrors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> new ApiError.FieldErrorDetail(
                        error.getField(),
                        error.getDefaultMessage()
                ))
                .toList();

        return new ApiError(
                400,
                "Bad Request",
                "Validation failed",
                request.getRequestURI(),
                Instant.now(),
                fieldErrors
        );
    }
}

10. Test Your API

Example using curl:

curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"[email protected]"}'

Get all users:

curl http://localhost:8080/api/users

Get one user:

curl http://localhost:8080/api/users/1

Update a user:

curl -X PUT http://localhost:8080/api/users/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice Smith","email":"[email protected]"}'

Delete a user:

curl -X DELETE http://localhost:8080/api/users/1

11. Add Pagination for List Endpoints

For large collections, avoid returning everything at once.

package com.example.demo.user;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.*;

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

    private final UserService userService;

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

    @GetMapping
    public Page<UserResponse> findAll(Pageable pageable) {
        return userService.findAll(pageable);
    }
}

Then clients can call:

GET /api/users?page=0&size=10
GET /api/users?page=0&size=10&sort=name,asc

Recommended Checklist

When building REST APIs with Spring MVC:

  • Use @RestController.
  • Use resource-based URLs like /api/users.
  • Use HTTP methods correctly: GET, POST, PUT, PATCH, DELETE.
  • Keep controllers thin.
  • Put business logic in services.
  • Use repositories for database access.
  • Use DTOs instead of exposing entities.
  • Validate request bodies with jakarta.validation.
  • Handle errors globally with @RestControllerAdvice.
  • Return correct HTTP status codes.
  • Add pagination for collection endpoints.
  • Use jakarta.* imports in modern Spring Boot applications.

A clean REST API usually follows this shape:

Controller → Service → Repository → Database

That structure keeps your Spring MVC API easier to test, maintain, and evolve.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.