How to Build Your First REST API with Spring MVC - A Complete Step-by-Step Guide

How to Build Your First REST API with Spring MVC - A Complete Step-by-Step Guide

Most Spring MVC tutorials show you fragments - a controller here, an annotation there. By the end, you have a collection of disconnected pieces but no real understanding of how a complete REST API comes together. This tutorial is different. You'll build a working Product API from scratch, step by step, understanding every decision along the way.

By the end of this guide, you'll have a fully functional Spring MVC REST API with proper routing, request data handling, JSON responses, input validation, and global error handling - the same structure used in production Spring Boot applications. Every section builds on the previous one, so the final result is a complete, coherent codebase rather than a set of isolated examples.

Before diving in, if you're new to Spring's core concepts - IoC, beans, and dependency injection - Board Infinity's guide on core Java concepts and syntax and OOP concepts in Java will give you the right foundation. This tutorial assumes basic Java and Spring knowledge and focuses entirely on building the API.

What You'll Build

A Product REST API with full CRUD operations: GET /api/products, GET /api/products/{id}, POST /api/products, PUT /api/products/{id}, DELETE /api/products/{id}. Every endpoint will have proper HTTP status codes, input validation, and structured error responses.

Prerequisites

You'll need Java 17 or later, Maven or an IDE with Maven support (IntelliJ IDEA recommended), basic understanding of Java classes and OOP, and basic knowledge of what HTTP methods (GET, POST, PUT, DELETE) do. Board Infinity's classes and objects in Java guide covers the Java class model that every Spring @RestController, @Service, and model class is built on - if any of the class and constructor patterns in this tutorial feel unfamiliar, that guide is the right starting point.

Step 1 - Setting Up a Spring Project with Maven

The fastest way to set up a Spring Boot project is via start.spring.io. Select Maven, Java, Spring Boot 3.x, and the dependencies: Spring Web, Spring Boot DevTools, Validation. This generates a project with everything needed. The most important file is pom.xml - it declares your dependencies and build configuration.

spring-boot-starter-web bundles Spring MVC, Jackson (for JSON serialisation), and an embedded Tomcat server. You don't need to configure a separate web server - when you run your Spring Boot app, Tomcat starts automatically on port 8080. Understanding the Java servlet architecture that embedded Tomcat runs on is covered in Board Infinity's understanding servlets in Java guide - Spring MVC's DispatcherServlet is a Java servlet, and understanding the servlet model explains why Spring Boot's embedded Tomcat approach is so powerful.

XML - pom.xml Core Dependencies
<dependencies><!-- Spring Web - includes Spring MVC + embedded Tomcat -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency><!-- Bean Validation - enables @Valid, @NotBlank, @Email etc. -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency><!-- DevTools - auto-restarts app on file save -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <scope>runtime</scope>
    <optional>true</optional>
</dependency>
๐Ÿ’ก
spring-boot-starter-web Includes Everything You Need

The spring-boot-starter-web dependency bundles Spring MVC, Jackson (for JSON serialisation), and an embedded Tomcat server. You don't need to configure a separate web server - when you run your Spring Boot app, Tomcat starts automatically on port 8080. This is one of Spring Boot's biggest advantages over raw Spring MVC.

Step 2 - Creating Your Model and Service Layer

Before writing the controller, define the data model and a simple in-memory service. In a real application this would connect to a database - but for this tutorial an in-memory Map keeps the focus on the API layer.

The Product class uses private fields with getters and setters - the encapsulation principle applied directly. Board Infinity's abstraction vs encapsulation in Java guide explains why this matters in Spring: Jackson's JSON deserialiser uses these getter and setter methods to map JSON fields to Java object properties. Without getters, Jackson can't serialise the Product to JSON. Without setters (or a matching constructor), Jackson can't deserialise an incoming JSON body to a Product object. The Map<Long, Product> in-memory store and Optional<Product> return types in ProductService use Java generics throughout. Board Infinity's generics in Java guide explains the <Long, Product> and <Product> type parameters - they tell the compiler exactly what types the collection holds, preventing type errors at compile time rather than runtime.

Java - Product Model
public class Product {private Long   id;
private String name;
private String description;
private double price;
private int    stock;// Default constructor (required by Jackson for JSON deserialisation)
public Product() {}public Product(Long id, String name, String description,
               double price, int stock) {
    this.id          = id;
    this.name        = name;
    this.description = description;
    this.price       = price;
    this.stock       = stock;
}// Getters and setters for all fields
public Long   getId()          { return id; }
public String getName()        { return name; }
public String getDescription() { return description; }
public double getPrice()       { return price; }
public int    getStock()       { return stock; }
public void   setId(Long id)    { this.id = id; }
public void   setName(String n) { this.name = n; }
public void   setPrice(double p){ this.price = p; }
public void   setStock(int s)   { this.stock = s; }
}
Java - ProductService (In-Memory)
@Service
public class ProductService {// In-memory store - replace with JPA repository in a real app
private final Map<Long, Product> store     = new ConcurrentHashMap<>();
private final AtomicLong          idCounter = new AtomicLong(1);public List<Product> findAll() {
    return new ArrayList<>(store.values());
}public Optional<Product> findById(Long id) {
    return Optional.ofNullable(store.get(id));
}public Product create(Product product) {
    Long id = idCounter.getAndIncrement();
    product.setId(id);
    store.put(id, product);
    return product;
}public Optional<Product> update(Long id, Product updated) {
    if (!store.containsKey(id)) return Optional.empty();
    updated.setId(id);
    store.put(id, updated);
    return Optional.of(updated);
}public boolean delete(Long id) {
    return store.remove(id) != null;
}
}

Step 3 - Creating Your First @RestController

The controller is where HTTP requests meet your business logic. @RestController tells Spring this class handles REST API requests and that every method's return value should be written directly to the HTTP response as JSON.

Constructor injection - public ProductController(ProductService productService) - is the recommended approach for wiring dependencies. This is Java's regular constructor, called by Spring's IoC container at startup. The understanding polymorphism in Java guide covers the principle behind why ProductController receives a ProductService reference rather than a concrete implementation - constructor injection through the interface type is polymorphism applied to dependency injection, making the controller testable and swappable without changing the controller code itself.

Java - ProductController Skeleton
@RestController               // = @Controller + @ResponseBody on every method
@RequestMapping("/api/products") // base URL for all endpoints in this controller
public class ProductController {
private final ProductService productService;

// Constructor injection - the recommended approach
public ProductController(ProductService productService) {
    this.productService = productService;
}

// GET /api/products
@GetMapping
public List<Product> getAllProducts() {
    return productService.findAll();
}
// More endpoints coming in steps 4 and 5...
}
๐Ÿ”
@RequestMapping on the Class Sets the Base URL

Putting @RequestMapping("/api/products") on the class means every method inside automatically inherits that base path. @GetMapping with no value maps to /api/products. @GetMapping("/{id}") maps to /api/products/{id}. This keeps your URLs consistent and avoids repeating the base path in every method.

Step 4 - Mapping Routes with @GetMapping, @PostMapping, @PutMapping, @DeleteMapping

Spring provides dedicated annotations for each HTTP method. Each maps a URL pattern to a controller method. The Optional<Product> returned by findById() and update() - and the .map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()) pattern - is the Optional chaining approach that eliminates null checks throughout the controller layer. Board Infinity's learn about Java list guide and Java list collections overview provide useful context on the Java collections that the findAll() method returns - the List<Product> from the service layer is what Spring's Jackson serialiser converts directly into a JSON array response. The what is composition in Java guide also applies here: ProductController owns a ProductService as a composed dependency, and this composition - rather than inheritance - is the pattern that keeps Spring's three-layer architecture clean and testable.

Annotation HTTP Method URL Pattern Use For Success Status
@GetMapping GET /api/products Retrieve all resources 200 OK
@GetMapping("/{id}") GET /api/products/1 Retrieve one resource by ID 200 OK
@PostMapping POST /api/products Create a new resource 201 Created
@PutMapping("/{id}") PUT /api/products/1 Replace an existing resource 200 OK
@DeleteMapping("/{id}") DELETE /api/products/1 Remove a resource 204 No Content
Java - Complete CRUD Route Mapping
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;

public ProductController(ProductService productService) {
    this.productService = productService;
}

// GET /api/products - list all
@GetMapping
public List<Product> getAll() {
    return productService.findAll();
}

// GET /api/products/1 - get by ID
@GetMapping("/{id}")
public ResponseEntity<Product> getById(@PathVariable Long id) {
    return productService.findById(id)
        .map(ResponseEntity::ok)
        .orElse(ResponseEntity.notFound().build());
}

// POST /api/products - create new
@PostMapping
public ResponseEntity<Product> create(@RequestBody Product product) {
    Product created = productService.create(product);
    return ResponseEntity.status(201).body(created);
}

// PUT /api/products/1 - full update
@PutMapping("/{id}")
public ResponseEntity<Product> update(
        @PathVariable Long id,
        @RequestBody Product product) {
    return productService.update(id, product)
        .map(ResponseEntity::ok)
        .orElse(ResponseEntity.notFound().build());
}

// DELETE /api/products/1 - remove
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    return productService.delete(id)
        ? ResponseEntity.noContent().build()   // 204 - deleted
        : ResponseEntity.notFound().build();   // 404 - not found
}
}

Step 5 - Reading Request Data: @PathVariable, @RequestParam, @RequestBody

Every REST API needs to read data from incoming requests. Spring provides three annotations for the three places data can come from: the URL path, the query string, and the request body.

The stream operations in the search() method - .stream().filter(...).filter(...).collect(Collectors.toList()) - apply Java Streams directly in the controller. Board Infinity's map stream in Java guide covers these chained stream operations: each .filter() is a predicate applied to the product stream, and .collect(Collectors.toList()) gathers the results. The @PathVariable Long id parameter uses the Long wrapper type - Board Infinity's understanding wrapper class in Java guide explains why Long (not long) is used: Spring's web layer converts the String URL segment to Long via autoboxing, and the nullable wrapper type handles cases where the path variable is missing more gracefully than a primitive long would.

Annotation Where Data Comes From Example URL or Request Use For
@PathVariable URL path segment /api/products/42 Resource identifiers
@RequestParam URL query string /api/products?category=tech&sort=price Filters, sorting, pagination
@RequestBody HTTP request body (JSON) POST body: {"name":"Laptop","price":999} Creating or updating resources
Java - @PathVariable, @RequestParam and @RequestBody in Action
@RestController
@RequestMapping("/api/products")
public class ProductController {
// @PathVariable - from URL: GET /api/products/42
@GetMapping("/{id}")
public ResponseEntity<Product> getById(@PathVariable Long id) {
    return productService.findById(id)
        .map(ResponseEntity::ok)
        .orElse(ResponseEntity.notFound().build());
}

// @RequestParam - from query string: GET /api/products?category=tech&minPrice=100
@GetMapping("/search")
public List<Product> search(
        @RequestParam String  category,
        @RequestParam(defaultValue = "0") double minPrice,
        @RequestParam(defaultValue = "name") String sort) {
    return productService.findAll().stream()
        .filter(p -> p.getDescription().contains(category))
        .filter(p -> p.getPrice() >= minPrice)
        .collect(Collectors.toList());
}

// @RequestBody - from JSON body: POST /api/products
// Body: {"name": "Laptop", "price": 999.0, "stock": 50}
@PostMapping
public ResponseEntity<Product> create(@RequestBody Product product) {
    // Jackson automatically deserialises the JSON body into a Product object
    Product saved = productService.create(product);
    return ResponseEntity.status(201).body(saved);
}
}
๐Ÿ“Œ
Use @RequestParam for Filters - @PathVariable for Identifiers

A common beginner confusion: when to use @PathVariable vs @RequestParam. The rule is simple - if the value identifies a specific resource, put it in the path (/products/42). If the value filters, sorts, or paginates a collection, put it in the query string (/products?category=tech&page=1). This follows REST conventions and makes your API URLs intuitive for consumers.

Step 6 - Input Validation with @Valid and Constraint Annotations

Never trust data coming into your API. A client could send an empty product name, a negative price, or no data at all. Without validation, that invalid data reaches your service layer - or worse, your database. Spring's Bean Validation integration stops invalid data at the controller layer before it goes anywhere.

The validation annotations on the Product class - @NotBlank, @Size, @Positive, @Min - are applied to private fields. The encapsulation that makes these fields private is also what makes the validation meaningful: external code can't bypass the constraint by directly setting product.price = -1 - it must go through setPrice(), and the validator checks the value before the object is accepted. Board Infinity's access modifiers in Java guide covers why private fields matter here - access control at the Java level and validation at the framework level work together to protect data integrity from two different angles. The throw and throws in Java guide is also relevant: when @Valid fails, Spring throws MethodArgumentNotValidException - an unchecked exception that propagates up to the @RestControllerAdvice handler in Step 7.

Java - Product with Validation Constraints
public class Product {
private Long id; // no constraint - assigned by server

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

@NotBlank(message = "Description is required")
private String description;

@Positive(message = "Price must be a positive number")
private double price;

@Min(value = 0, message = "Stock cannot be negative")
private int stock;

// constructors, getters, setters...
}
// Controller - activate validation with @Valid
@PostMapping
public ResponseEntity<Product> create(
@Valid @RequestBody Product product) {
// @Valid runs all constraints before this method body executes
// If name is blank or price is negative - exception thrown automatically
return ResponseEntity.status(201).body(productService.create(product));
}
@PutMapping("/{id}")
public ResponseEntity<Product> update(
@PathVariable Long id,
@Valid @RequestBody Product product) {
// @Valid applies to updates too - can't update with invalid data
return productService.update(id, product)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
โš ๏ธ
@Valid Without @RestControllerAdvice Returns Ugly 400 Errors

When @Valid fails and you don't have a global exception handler, Spring returns a default 400 error with a large, unformatted JSON dump that includes Spring's internal stack information. This is not suitable for a production API. Always pair @Valid with a @RestControllerAdvice handler (Step 7) that catches MethodArgumentNotValidException and returns clean, structured field-level error messages.

Step 7 - Global Error Handling with @RestControllerAdvice

The final piece of a production-quality REST API is consistent error handling. Without it, different exceptions produce different response shapes - validation errors look different from not-found errors, which look different from server errors. Clients can't reliably parse your error responses.

@RestControllerAdvice provides a single class that intercepts every exception thrown from every controller and returns a consistent ApiError structure. The Map<String, String> fieldErrors in the ApiError class - populated by Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage, ...) - uses Java's HashMap through the Streams API. Board Infinity's HashSet and Java collections guides cover Java's collection framework, and the Map<String, String> here maps each failing field name to its validation message - providing structured, field-level error information that API clients can use to show targeted error messages to users. The singleton catch-all @ExceptionHandler(Exception.class) - which never exposes stack traces to clients - applies the same exception hierarchy principle covered in Board Infinity's throw and throws in Java guide: Exception.class catches every unchecked exception in the hierarchy, making it the safety net for any unexpected error that reaches the controller layer.

Java - ApiError Response Structure
public class ApiError {
private int                      status;
private String                   message;
private LocalDateTime            timestamp;
private Map<String, String>      fieldErrors; // for validation errors

public ApiError(int status, String message) {
    this.status    = status;
    this.message   = message;
    this.timestamp = LocalDateTime.now();
}

public ApiError(int status, String message,
                Map<String, String> fieldErrors) {
    this(status, message);
    this.fieldErrors = fieldErrors;
}

// getters...
}
// Example JSON response for a validation failure:
// {
//   "status": 400,
//   "message": "Validation failed",
//   "timestamp": "2025-05-08T10:30:00",
//   "fieldErrors": {
//     "name": "Product name is required",
//     "price": "Price must be a positive number"
//   }
// }
Java - Complete @RestControllerAdvice Handler
@RestControllerAdvice
public class GlobalExceptionHandler {
// Handle @Valid failures - returns field-level errors
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(
        MethodArgumentNotValidException ex) {

    Map<String, String> errors = ex.getBindingResult()
        .getFieldErrors().stream()
        .collect(Collectors.toMap(
            FieldError::getField,
            FieldError::getDefaultMessage,
            (e1, e2) -> e1 // keep first message if field has multiple errors
        ));

    return ResponseEntity.badRequest()
        .body(new ApiError(400, "Validation failed", errors));
}

// Handle product not found
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(
        ProductNotFoundException ex) {
    return ResponseEntity.status(404)
        .body(new ApiError(404, ex.getMessage()));
}

// Handle type mismatch - e.g. passing "abc" for a Long @PathVariable
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiError> handleTypeMismatch(
        MethodArgumentTypeMismatchException ex) {
    String msg = ex.getName() + " must be a valid "
        + ex.getRequiredType().getSimpleName();
    return ResponseEntity.badRequest()
        .body(new ApiError(400, msg));
}

// Catch-all - never expose stack traces to clients
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleAll(Exception ex) {
    return ResponseEntity.internalServerError()
        .body(new ApiError(500, "An unexpected error occurred"));
}
}
๐Ÿ’ก
Test Your API With Postman or IntelliJ HTTP Client

Once your application is running on port 8080, you can test every endpoint immediately. In Postman: set the method (GET/POST/PUT/DELETE), enter the URL (http://localhost:8080/api/products), add a JSON body for POST/PUT requests, and hit Send. IntelliJ IDEA Ultimate includes a built-in HTTP client - create a .http file and write requests directly. Both tools let you test validation errors, 404 responses, and success cases without writing any test code.

Further Reading

Board Infinity Guides:

External Resources:

๐Ÿš€ Build a Complete Spring MVC API - With Real Projects

Spring Framework: Core & Web Development on Coursera

This free Coursera course by Board Infinity goes beyond this tutorial - covering Spring Core, IoC, dependency injection, complete MVC web application development, bean validation, and professional error handling through hands-on project work. Every module builds toward a working, production-ready Spring application.

Module 1
Spring Core & Dependency Injection IoC container, constructor injection, Spring Beans, bean scopes and lifecycle - the foundation every Spring app is built on
Module 2
Configuring Spring Applications Java-based configuration, component scanning, essential annotations, Spring profiles and environment properties
Module 3
Building Web Apps with Spring MVC DispatcherServlet flow, REST controllers, URL mapping, path variables, request params, request body handling and form submissions
Module 4
Validation & Error Handling Bean Validation API, custom validators, @RestControllerAdvice, global exception management and structured error responses
Start Learning on Coursera โ†’

โœ“ Certificate available  ยท  โœ“ Self-paced  ยท  โœ“ Beginner-friendly

Conclusion

Building a REST API with Spring MVC follows a clear, repeatable pattern: set up your project with the right starters, create a model with validation constraints, write a service layer to handle business logic, build a controller that maps HTTP methods to service calls, and add a global exception handler that catches every failure and returns consistent, structured error responses.

Each of the seven steps in this tutorial solves a specific, real problem that every production REST API faces. Project setup determines your available tools. The controller layer handles routing. Path variables, request params, and request bodies provide three clean ways to accept input. Validation prevents bad data from reaching your database. And global error handling makes your API's failure responses as predictable as its success responses.

This is the foundation. From here, the natural next steps are connecting a real database with Spring Data JPA, adding authentication and authorisation with Spring Security, and implementing pagination for large result sets - all of which build directly on the pattern established here. The full backend architecture picture that this API fits into is covered in Board Infinity's Essentials of Back-End Development: From APIs to Databases guide, and the Java fundamentals that underpin every layer of the API - OOP, encapsulation, generics, collections, and exception handling - are covered comprehensively in Board Infinity's understanding polymorphism in Java guide.

Programming Language Java Spring MVC Spring Web Development