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.

How do I handle exceptions globally in Spring MVC?

In Spring MVC, handle exceptions globally by creating a class annotated with @ControllerAdvice or @RestControllerAdvice and adding methods annotated with @ExceptionHandler.

For REST APIs, prefer @RestControllerAdvice, because it combines @ControllerAdvice and @ResponseBody, so returned objects are serialized as JSON automatically.

package com.example.demo.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex
    ) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage()
        );

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(
            Exception ex
    ) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "An unexpected error occurred"
        );

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

Example error response DTO:

package com.example.demo.exception;

import java.time.Instant;

public record ErrorResponse(
        int status,
        String message,
        Instant timestamp
) {
    public ErrorResponse(int status, String message) {
        this(status, message, Instant.now());
    }
}

Example custom exception:

package com.example.demo.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

Then you can throw exceptions from controllers or services:

throw new ResourceNotFoundException("User not found");

Spring will automatically route that exception to the matching @ExceptionHandler.

Common handlers you may want to add:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
        MethodArgumentNotValidException ex
) {
    ErrorResponse error = new ErrorResponse(
            HttpStatus.BAD_REQUEST.value(),
            "Validation failed"
    );

    return ResponseEntity.badRequest().body(error);
}

Use:

  • @RestControllerAdvice for REST APIs returning JSON.
  • @ControllerAdvice for MVC apps returning views or when you manually use ResponseEntity.
  • Specific exception handlers before generic ones.
  • A final @ExceptionHandler(Exception.class) as a fallback.

How do I validate form data in Spring?

In Spring MVC, the standard way to validate form data is to use Jakarta Bean Validation annotations on a form/DTO object, then check validation results in your controller with BindingResult.

Since your project uses Jakarta EE, use jakarta.validation.* imports.

1. Add validation annotations to your form object

Example form/DTO:

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

public class UserForm {

    @NotBlank(message = "Name is required")
    @Size(max = 100, message = "Name must be at most 100 characters")
    private String name;

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

    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    private String password;

    // getters and setters
}

With Lombok:

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class UserForm {

    @NotBlank(message = "Name is required")
    @Size(max = 100, message = "Name must be at most 100 characters")
    private String name;

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

    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    private String password;
}

2. Use @Valid in your controller

import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class UserController {

    @GetMapping("/register")
    public String showRegisterForm(Model model) {
        model.addAttribute("userForm", new UserForm());
        return "register";
    }

    @PostMapping("/register")
    public String register(
            @Valid UserForm userForm,
            BindingResult bindingResult
    ) {
        if (bindingResult.hasErrors()) {
            return "register";
        }

        // Save user or call service layer here
        return "redirect:/register/success";
    }
}

Important: BindingResult must come immediately after the validated object.

Correct:

public String register(@Valid UserForm userForm, BindingResult bindingResult)

Incorrect:

public String register(@Valid UserForm userForm, Model model, BindingResult bindingResult)

3. Display errors in Thymeleaf

If you use Thymeleaf:

<form th:action="@{/register}" th:object="${userForm}" method="post">
    <div>
        <label>Name</label>
        <input type="text" th:field="*{name}">
        <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span>
    </div>

    <div>
        <label>Email</label>
        <input type="email" th:field="*{email}">
        <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></span>
    </div>

    <div>
        <label>Password</label>
        <input type="password" th:field="*{password}">
        <span th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></span>
    </div>

    <button type="submit">Register</button>
</form>

4. Common validation annotations

@NotNull
@NotBlank
@NotEmpty
@Size(min = 2, max = 100)
@Min(18)
@Max(120)
@Email
@Pattern(regexp = "...")
@Past
@Future
@Positive
@PositiveOrZero

Use:

  • @NotNull for any value that must not be null
  • @NotBlank for strings that must contain non-whitespace text
  • @NotEmpty for strings, collections, arrays, or maps that must not be empty
  • @Size for string length or collection size
  • @Email for email format validation

5. Maven dependency

If you use Spring Boot, add:

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

For Gradle:

implementation("org.springframework.boot:spring-boot-starter-validation")

6. Service-layer validation

You can also validate method parameters in Spring services:

import jakarta.validation.Valid;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;

@Service
@Validated
public class UserService {

    public void createUser(@Valid UserForm userForm) {
        // business logic
    }
}

7. REST API validation example

For JSON request bodies:

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserRestController {

    @PostMapping("/api/users")
    public String createUser(@Valid @RequestBody UserForm userForm) {
        return "User created";
    }
}

For REST APIs, invalid input usually results in a 400 Bad Request.

Summary

Use this pattern:

@PostMapping("/submit")
public String submit(@Valid MyForm form, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        return "form-page";
    }

    return "redirect:/success";
}

That is the typical Spring MVC form validation flow.

How do I handle HTTP requests with Spring controllers?

In Spring MVC or Spring Boot, you handle HTTP requests by creating controller classes. A controller receives a request, runs application logic, and returns either:

  • a view name for server-rendered pages, or
  • data such as JSON for REST APIs.

1. Basic Spring MVC Controller

Use @Controller when you want to return views such as JSP, Thymeleaf, or other templates.

package com.example.web;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {

    @GetMapping("/")
    public String home(Model model) {
        model.addAttribute("message", "Welcome to Spring MVC!");
        return "home";
    }
}

In this example:

  • @Controller marks the class as a Spring MVC controller.
  • @GetMapping("/") handles HTTP GET /.
  • Model passes data to the view.
  • "home" is the logical view name.

If you use Thymeleaf, Spring would typically look for:

src/main/resources/templates/home.html

2. REST Controller Returning JSON

Use @RestController when you want to build REST APIs.

package com.example.web;

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

@RestController
public class GreetingRestController {

    @GetMapping("/api/greeting")
    public Greeting greeting() {
        return new Greeting("Hello from Spring!");
    }

    public record Greeting(String message) {
    }
}

Calling:

GET /api/greeting

returns JSON like:

{
  "message": "Hello from Spring!"
}

@RestController is a shortcut for:

@Controller
@ResponseBody

So every method returns the response body directly instead of a view name.


3. Handling Different HTTP Methods

Spring provides convenient annotations for common HTTP methods.

package com.example.web;

import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {

    @GetMapping("/users")
    public String getUsers() {
        return "Get all users";
    }

    @PostMapping("/users")
    public String createUser() {
        return "Create a new user";
    }

    @PutMapping("/users/1")
    public String replaceUser() {
        return "Replace user";
    }

    @PatchMapping("/users/1")
    public String updateUser() {
        return "Update part of user";
    }

    @DeleteMapping("/users/1")
    public String deleteUser() {
        return "Delete user";
    }
}

Common mappings include:

Annotation HTTP Method
@GetMapping GET
@PostMapping POST
@PutMapping PUT
@PatchMapping PATCH
@DeleteMapping DELETE

4. Reading Path Variables

Use @PathVariable to read values from the URL path.

package com.example.web;

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

@RestController
public class ProductController {

    @GetMapping("/products/{id}")
    public String getProduct(@PathVariable Long id) {
        return "Product ID: " + id;
    }
}

Request:

GET /products/10

Response:

Product ID: 10

5. Reading Query Parameters

Use @RequestParam to read query string parameters.

package com.example.web;

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

@RestController
public class SearchController {

    @GetMapping("/search")
    public String search(
            @RequestParam String keyword,
            @RequestParam(defaultValue = "1") int page
    ) {
        return "Searching for: " + keyword + ", page: " + page;
    }
}

Request:

GET /search?keyword=spring&page=2

Response:

Searching for: spring, page: 2

6. Reading Request Body JSON

Use @RequestBody to bind JSON request data to a Java object.

package com.example.web;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

public record CreateUserRequest(String name, String email) {
}

@RestController
public class UserApiController {

    @PostMapping("/api/users")
    public String createUser(@RequestBody CreateUserRequest request) {
        return "Created user: " + request.name() + " with email: " + request.email();
    }
}

Request:

POST /api/users
Content-Type: application/json
{
  "name": "Alice",
  "email": "[email protected]"
}

7. Returning Proper HTTP Status Codes

Use ResponseEntity when you need control over the response status, headers, or body.

package com.example.web;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OrderController {

    @PostMapping("/orders")
    public ResponseEntity<String> createOrder() {
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body("Order created");
    }
}

This returns:

HTTP/1.1 201 Created

8. Handling Form Submissions

For traditional web applications, a controller can handle form submissions with @PostMapping.

package com.example.web;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class ContactController {

    @PostMapping("/contact")
    public String submitContactForm(
            @RequestParam String name,
            @RequestParam String message
    ) {
        System.out.println("Name: " + name);
        System.out.println("Message: " + message);

        return "redirect:/contact-success";
    }
}

The redirect prevents duplicate form submissions if the user refreshes the page.


9. Using a Service from a Controller

Controllers should usually be thin. Put business logic in a service class.

package com.example.web;

import com.example.service.UserService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {

    private final UserService userService;

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

    @GetMapping("/api/users/count")
    public int countUsers() {
        return userService.countUsers();
    }
}
package com.example.service;

import org.springframework.stereotype.Service;

@Service
public class UserService {

    public int countUsers() {
        return 5;
    }
}

This keeps the controller focused on HTTP request/response handling.


10. Simple Exception Handling

You can handle exceptions globally using @ControllerAdvice.

package com.example.web;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleIllegalArgumentException(IllegalArgumentException ex) {
        return new ErrorResponse(ex.getMessage());
    }

    public record ErrorResponse(String message) {
    }
}

Now if a controller throws:

throw new IllegalArgumentException("Invalid request");

Spring returns a 400 Bad Request response.


Quick Summary

Use these annotations to handle HTTP requests in Spring:

Annotation Purpose
@Controller Web controller returning views
@RestController REST controller returning response bodies
@GetMapping Handle GET requests
@PostMapping Handle POST requests
@PutMapping Handle PUT requests
@PatchMapping Handle PATCH requests
@DeleteMapping Handle DELETE requests
@PathVariable Read values from the URL path
@RequestParam Read query parameters or form fields
@RequestBody Read JSON/XML request body
ResponseEntity Customize status, headers, and body
@ControllerAdvice Centralized exception handling

In short:

@RestController
public class HelloController {

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

That is the simplest form of handling an HTTP request with a Spring controller.

How do I build a web application using Spring MVC?

Building a Web Application Using Spring MVC

A Spring MVC web application is typically built around these pieces:

  1. DispatcherServlet — the front controller that receives HTTP requests.
  2. Spring MVC configuration — enables MVC and configures controllers, view resolution, static resources, etc.
  3. Controllers — handle web requests.
  4. Services — contain business logic.
  5. Repositories — handle persistence, often with Spring Data JPA.
  6. Views or REST responses — return HTML pages, JSON, text, etc.
  7. Deployment setup — either Spring Boot embedded server or traditional WAR deployment.

1. Choose an Application Style

There are two common ways to build Spring MVC applications.

Option A: Spring Boot MVC Application

This is the most common modern approach.

You create an executable application with an embedded server such as Tomcat.

Option B: Traditional Spring MVC WAR Application

You deploy a WAR file to an external servlet container such as Tomcat.

Both use Spring MVC, but Spring Boot reduces configuration significantly.


Option A: Spring Boot + Spring MVC

2. Add Dependencies

If using Maven, a basic Spring MVC web application can start with:

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

    <!-- Optional: for validation -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- Optional: for JPA/database access -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
</dependencies>

spring-boot-starter-web includes Spring MVC and an embedded servlet container.


3. Create the Main Application Class

package com.example.app;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class WebApplication {

    public static void main(String[] args) {
        SpringApplication.run(WebApplication.class, args);
    }
}

@SpringBootApplication enables component scanning, auto-configuration, and Spring configuration support.

Recommended package structure:

com.example.app
├── WebApplication.java
├── controller
│   └── HomeController.java
├── service
│   └── GreetingService.java
├── repository
│   └── UserRepository.java
└── model
    └── User.java

Keep the main class in the root package so Spring can scan subpackages.


4. Create a REST Controller

For JSON/text responses, use @RestController.

package com.example.app.controller;

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

@RestController
public class HelloRestController {

    @GetMapping("/api/hello")
    public String hello() {
        return "Hello from Spring MVC";
    }
}

Run the application and visit:

http://localhost:8080/api/hello

5. Create an MVC Controller That Returns a View

If you want server-rendered HTML pages, use @Controller.

package com.example.app.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {

    @GetMapping("/")
    public String home(Model model) {
        model.addAttribute("message", "Welcome to Spring MVC");
        return "home";
    }
}

With Thymeleaf, add:

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

Then create:

src/main/resources/templates/home.html
<!DOCTYPE html>
<html>
<head>
    <title>Spring MVC</title>
</head>
<body>
    <h1 th:text="${message}">Default message</h1>
</body>
</html>

Spring Boot automatically configures Thymeleaf templates from src/main/resources/templates.


6. Add a Service Layer

Controllers should usually delegate business logic to services.

package com.example.app.service;

import org.springframework.stereotype.Service;

@Service
public class GreetingService {

    public String getGreeting() {
        return "Hello from the service layer";
    }
}

Inject the service into a controller using constructor injection:

package com.example.app.controller;

import com.example.app.service.GreetingService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GreetingController {

    private final GreetingService greetingService;

    public GreetingController(GreetingService greetingService) {
        this.greetingService = greetingService;
    }

    @GetMapping("/api/greeting")
    public String greeting() {
        return greetingService.getGreeting();
    }
}

7. Handle Form Data

For a traditional web form:

package com.example.app.controller;

import com.example.app.form.ContactForm;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class ContactController {

    @GetMapping("/contact")
    public String showForm(Model model) {
        model.addAttribute("contactForm", new ContactForm());
        return "contact";
    }

    @PostMapping("/contact")
    public String submitForm(ContactForm contactForm, Model model) {
        model.addAttribute("message", "Thanks, " + contactForm.getName());
        return "contact-success";
    }
}

Form object:

package com.example.app.form;

public class ContactForm {

    private String name;
    private String email;
    private String message;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}

With Lombok, this can be simplified:

package com.example.app.form;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class ContactForm {

    private String name;
    private String email;
    private String message;
}

8. Add Validation

Use Jakarta Bean Validation annotations:

package com.example.app.form;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class ContactForm {

    @NotBlank
    private String name;

    @Email
    @NotBlank
    private String email;

    @NotBlank
    private String message;
}

Controller:

package com.example.app.controller;

import com.example.app.form.ContactForm;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class ContactController {

    @PostMapping("/contact")
    public String submitForm(
            @Valid ContactForm contactForm,
            BindingResult bindingResult
    ) {
        if (bindingResult.hasErrors()) {
            return "contact";
        }

        return "contact-success";
    }
}

In Spring MVC, BindingResult must immediately follow the validated argument.


9. Add Persistence with Spring Data JPA

Entity:

package com.example.app.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;
}

Repository:

package com.example.app.user;

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

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

Service:

package com.example.app.user;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional(readOnly = true)
    public List<User> findAll() {
        return userRepository.findAll();
    }

    @Transactional
    public User save(User user) {
        return userRepository.save(user);
    }
}

Controller:

package com.example.app.user;

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

import java.util.List;

@RestController
public class UserRestController {

    private final UserService userService;

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

    @GetMapping("/api/users")
    public List<User> users() {
        return userService.findAll();
    }
}

Option B: Traditional Spring MVC Without Spring Boot

If you are building a classic Spring MVC application deployed as a WAR, you usually configure the application with Java configuration classes.

10. Add MVC Configuration

package com.example.app.config;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@EnableWebMvc
@ComponentScan(basePackages = "com.example.app")
public class WebConfig implements WebMvcConfigurer {
}

@EnableWebMvc enables Spring MVC features such as request mapping, message conversion, validation support, and more.


11. Configure the DispatcherServlet

For a Servlet 3+ container, you can initialize Spring MVC without web.xml:

package com.example.app.config;

import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;

public class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {

    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class<?>[] { RootConfig.class };
    }

    @Override
    protected Class<?>[] getServletConfigClasses() {
        return new Class<?>[] { WebConfig.class };
    }

    @Override
    protected String[] getServletMappings() {
        return new String[] { "/" };
    }
}

Typical separation:

RootConfig     -> services, repositories, data sources, transactions
WebConfig      -> controllers, view resolvers, Spring MVC configuration
DispatcherServlet -> receives web requests

12. Add a Root Configuration

package com.example.app.config;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan(basePackages = {
        "com.example.app.service",
        "com.example.app.repository"
})
public class RootConfig {
}

13. Configure Views

For JSP views in a traditional Spring MVC app:

package com.example.app.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.view.InternalResourceViewResolver;

@Configuration
public class ViewConfig {

    @Bean
    public ViewResolver viewResolver() {
        InternalResourceViewResolver resolver = new InternalResourceViewResolver();
        resolver.setPrefix("/WEB-INF/views/");
        resolver.setSuffix(".jsp");
        return resolver;
    }
}

Or add it directly to WebConfig:

package com.example.app.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.view.InternalResourceViewResolver;

@Configuration
@EnableWebMvc
@ComponentScan(basePackages = "com.example.app.controller")
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public ViewResolver viewResolver() {
        InternalResourceViewResolver resolver = new InternalResourceViewResolver();
        resolver.setPrefix("/WEB-INF/views/");
        resolver.setSuffix(".jsp");
        return resolver;
    }
}

Controller:

package com.example.app.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class PageController {

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("message", "Hello Spring MVC");
        return "index";
    }
}

JSP file:

src/main/webapp/WEB-INF/views/index.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
    <title>Spring MVC</title>
</head>
<body>
    <h1>${message}</h1>
</body>
</html>

14. Recommended Layering

A clean Spring MVC application usually follows this flow:

HTTP Request
    ↓
DispatcherServlet
    ↓
Controller
    ↓
Service
    ↓
Repository
    ↓
Database

Example responsibilities:

Layer Annotation Responsibility
Controller @Controller, @RestController Handle HTTP requests/responses
Service @Service Business logic and transactions
Repository @Repository or Spring Data interface Data access
Entity/Model @Entity, DTOs, form objects Data structure

15. Basic REST Endpoint Example

package com.example.app.employee;

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

import java.util.List;

@RestController
public class EmployeeController {

    @GetMapping("/employees")
    public List<String> employees() {
        return List.of("Alice", "Bob", "Charlie");
    }
}

Calling:

GET http://localhost:8080/employees

returns JSON:

["Alice", "Bob", "Charlie"]

16. Common Spring MVC Annotations

Annotation Purpose
@Controller MVC controller that usually returns a view name
@RestController REST controller returning response bodies
@RequestMapping General request mapping
@GetMapping Handles HTTP GET
@PostMapping Handles HTTP POST
@PutMapping Handles HTTP PUT
@DeleteMapping Handles HTTP DELETE
@PathVariable Reads values from URI path
@RequestParam Reads query/form parameters
@RequestBody Reads JSON/XML request body
@ResponseBody Writes method return value directly to response
@ModelAttribute Binds form/model data
@Valid Triggers Jakarta Bean Validation

17. Example REST Controller with Request Body

DTO:

package com.example.app.employee;

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

public record CreateEmployeeRequest(
        @NotBlank String name,
        @Email @NotBlank String email
) {
}

Controller:

package com.example.app.employee;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class EmployeeController {

    @PostMapping("/employees")
    @ResponseStatus(HttpStatus.CREATED)
    public String createEmployee(@Valid @RequestBody CreateEmployeeRequest request) {
        return "Created employee: " + request.name();
    }
}

Example request:

POST /employees HTTP/1.1
Content-Type: application/json

{
  "name": "Alice",
  "email": "[email protected]"
}

18. Handle Errors Globally

Use @ControllerAdvice for centralized exception handling.

package com.example.app.web;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleIllegalArgument(IllegalArgumentException exception) {
        return new ErrorResponse(exception.getMessage());
    }

    public record ErrorResponse(String message) {
    }
}

19. Test a Controller

With Spring Boot, you can test MVC endpoints using MockMvc:

package com.example.app.employee;

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.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(EmployeeController.class)
class EmployeeControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void employeesReturnsList() throws Exception {
        mockMvc.perform(get("/employees"))
                .andExpect(status().isOk())
                .andExpect(content().json("[\"Alice\",\"Bob\",\"Charlie\"]"));
    }
}

20. Practical Checklist

To build a Spring MVC web application:

  1. Add Spring MVC dependencies.
  2. Create an application entry point.
  3. Enable component scanning.
  4. Create controllers with @Controller or @RestController.
  5. Add services with @Service.
  6. Add repositories with Spring Data JPA if needed.
  7. Use constructor injection.
  8. Add validation with Jakarta Bean Validation.
  9. Configure views if returning HTML.
  10. Configure persistence if using a database.
  11. Add global exception handling.
  12. Write tests for controllers and services.
  13. Run the application and test endpoints in a browser, curl, or Postman.

For most new applications, use Spring Boot with spring-boot-starter-web. For traditional servlet-container deployment, use Java config with @EnableWebMvc and a DispatcherServlet initializer.