How to buildOctober 11, 20268 min read
JavaStep 5 of 6

Spring Boot: a REST API from scratch

In short

A Spring Boot REST API has three layers: a @RestController accepts HTTP requests and returns JSON, a @Service holds rules and transactions, and a Spring Data JPA repository interface talks to the database without hand-written SQL. Input is described by DTOs with Jakarta Bean Validation annotations and @Valid, and 400 and 404 errors are returned in RFC 9457 format via @RestControllerAdvice. You generate the project on start.spring.io, verify it with MockMvc tests on H2, and run it against PostgreSQL through a profile.

TutorialLevel: IntermediateSpring BootPractice: ~3–6 h

After this you will be able to

  • Generate a project on start.spring.io and explain what starters and auto-configuration do
  • Write a controller, a service and a Spring Data JPA repository for CRUD with 200, 201, 204 and 404 responses
  • Validate DTOs with @Valid and Jakarta Bean Validation annotations and return errors in RFC 9457 format
  • Verify the API with MockMvc tests and run it with a PostgreSQL profile in Docker

Verified with

Spring Boot 4.1.1Spring Framework 7.0.9Java 25 LTS (Eclipse Temurin 25.0.4)Hibernate ORM 7.4.5Hibernate Validator 9.1.3H2 2.4.240PostgreSQL 18.6Maven 3.10.0

Checked against docs: October 11, 2026

Contents
  1. 01What Spring Boot gives you: starters, auto-configuration, start.spring.io
  2. 02The entity and a Spring Data JPA repository
  3. 03DTOs and validation with Jakarta Bean Validation
  4. 04The service layer and dependency injection
  5. 05The controller: routes and status codes
  6. 06Errors: 404 and 400 as Problem Details
  7. 07Configuration, profiles and PostgreSQL in Docker
  8. 08MockMvc tests, the build and exercises

What Spring Boot gives you: starters, auto-configuration, start.spring.io

Spring Boot sits on top of the Spring Framework and removes manual setup. Two ideas make it convenient. Starters are curated dependency sets: a single spring-boot-starter-webmvc brings Spring MVC, embedded Tomcat and Jackson for JSON. Auto-configuration looks at the libraries on the classpath and creates the beans you need: it finds the H2 driver plus Spring Data JPA and starts an in-memory database with repositories. Any of these defaults can be replaced with your own.

The lesson uses Spring Boot 4.1.1, the current stable release on start.spring.io. According to the docs it needs Java 17 or later (compatible up to Java 26) and Spring Framework 7. We use Java 25 LTS from the previous lesson. If you have seen Spring Boot 3 examples, note the renames in version 4: the web starter is spring-boot-starter-webmvc, and tests get technology-specific starters such as spring-boot-starter-webmvc-test. We generate the project with one command — the same as the Generate button on the site:

terminal
$ curl -o leads.zip "https://start.spring.io/starter.zip?type=maven-project&language=java&bootVersion=4.1.1&baseDir=leads&groupId=com.example&artifactId=leads&name=leads&packageName=com.example.leads&javaVersion=25&dependencies=web,data-jpa,validation,h2,postgresql"
$ unzip leads.zip && cd leads
$ find . -type f -not -path './.mvn/*' | sort
./.gitattributes
./.gitignore
./HELP.md
./mvnw
./mvnw.cmd
./pom.xml
./src/main/java/com/example/leads/LeadsApplication.java
./src/main/resources/application.properties
./src/test/java/com/example/leads/LeadsApplicationTests.java
Dependencies: Spring Web, Spring Data JPA, Validation, H2 and the PostgreSQL driver. mvnw is the Maven Wrapper: it downloads the right Maven version by itself.
pom.xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
<!-- … -->
<properties>
    <java.version>25</java.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-h2console</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>

    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
A fragment of the generated pom.xml. Library versions are not listed: the spring-boot-starter-parent parent manages them. Initializr added the spring-boot-h2console module automatically along with H2.
src/main/java/com/example/leads/LeadsApplication.java
package com.example.leads;

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

@SpringBootApplication
public class LeadsApplication {

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

}

@SpringBootApplication combines three annotations: @SpringBootConfiguration, @EnableAutoConfiguration (turns auto-configuration on) and @ComponentScan (finds your @Service, @RestController and other annotated classes in this package and below). That is why all lead code goes into the com.example.leads.lead subpackage — otherwise Spring would not find it.

  1. 01LeadControllerHTTP: routes, status codes, input validation
  2. 02LeadServicebusiness rules and transactions
  3. 03LeadRepositorySpring Data JPA: database queries
  4. 04H2 / PostgreSQLthe leads table
Layers of the leads API: each one only knows about the layer below.

The entity and a Spring Data JPA repository

The practice project is an API for website leads: name, phone, message and processing status. JPA (Jakarta Persistence) maps a Java class to a table: an @Entity class is a row, its fields are columns. Spring Boot uses Hibernate as the JPA implementation.

src/main/java/com/example/leads/lead/LeadStatus.java
package com.example.leads.lead;

public enum LeadStatus {
    NEW,
    IN_PROGRESS,
    DONE
}
src/main/java/com/example/leads/lead/Lead.java
package com.example.leads.lead;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;

@Entity
@Table(name = "leads")
public class Lead {

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

    @Column(nullable = false, length = 100)
    private String name;

    @Column(nullable = false, length = 20)
    private String phone;

    @Column(length = 1000)
    private String message;

    @Enumerated(EnumType.STRING) // stored as 'NEW', not as an ordinal number
    @Column(nullable = false, length = 20)
    private LeadStatus status = LeadStatus.NEW;

    @Column(nullable = false, updatable = false)
    private Instant createdAt = Instant.now();

    protected Lead() {
        // JPA requires a no-arg constructor
    }

    public Lead(String name, String phone, String message) {
        this.name = name;
        this.phone = phone;
        this.message = message;
    }

    public void update(String name, String phone, String message) {
        this.name = name;
        this.phone = phone;
        this.message = message;
    }

    public void changeStatus(LeadStatus status) {
        this.status = status;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getPhone() {
        return phone;
    }

    public String getMessage() {
        return message;
    }

    public LeadStatus getStatus() {
        return status;
    }

    public Instant getCreatedAt() {
        return createdAt;
    }
}
  • @GeneratedValue(strategy = GenerationType.IDENTITY) lets the database assign IDs: in PostgreSQL it is a generated by default as identity column.
  • @Enumerated(EnumType.STRING) stores the status as text. The default ordinal would corrupt data once someone inserts a new status in the middle of the enum.
  • The protected no-arg constructor is for JPA, which instantiates objects when reading from the database; application code uses the public one.
  • There are no setters: state changes through update and changeStatus, as in the OOP lesson.
src/main/java/com/example/leads/lead/LeadRepository.java
package com.example.leads.lead;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;

public interface LeadRepository extends JpaRepository<Lead, Long> {

    // the query is derived from the method name: WHERE status = ? ORDER BY id DESC
    List<Lead> findByStatusOrderByIdDesc(LeadStatus status);
}

The repository is just an interface. By extending JpaRepository<Lead, Long> it gets save, findById, findAll, delete and more, and Spring Data generates the implementation at startup. findByStatusOrderByIdDesc contains no SQL at all: per the Spring Data docs, the name is split into a subject (find…By) and a predicate (Status, OrderByIdDesc), and the query is derived from them.

DTOs and validation with Jakarta Bean Validation

Externally the API works with DTOs — simple input and output objects — rather than the entity. If Lead were bound straight from JSON, a client could send "status": "DONE" or someone else’s id. A dedicated LeadRequest lists exactly the fields the client may send and declares the rules for them with Jakarta Bean Validation annotations.

src/main/java/com/example/leads/lead/LeadRequest.java
package com.example.leads.lead;

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

// POST and PUT body: what the client may send (no id, status or date)
public record LeadRequest(
        @NotBlank(message = "Name is required")
        @Size(max = 100, message = "Name must be at most 100 characters")
        String name,

        @NotBlank(message = "Phone is required")
        @Pattern(regexp = "\\+?[0-9 ()-]{10,20}", message = "Phone must be 10 to 20 digits; +, spaces and brackets are allowed")
        String phone,

        @Size(max = 1000, message = "Message must be at most 1000 characters")
        String message) {
}
src/main/java/com/example/leads/lead/LeadStatusRequest.java
package com.example.leads.lead;

import jakarta.validation.constraints.NotNull;

public record LeadStatusRequest(@NotNull(message = "Status is required") LeadStatus status) {
}
src/main/java/com/example/leads/lead/LeadResponse.java
package com.example.leads.lead;

import java.time.Instant;

// API response: expose a DTO, not the JPA entity
public record LeadResponse(Long id, String name, String phone, String message, LeadStatus status, Instant createdAt) {

    static LeadResponse from(Lead lead) {
        return new LeadResponse(lead.getId(), lead.getName(), lead.getPhone(), lead.getMessage(),
                lead.getStatus(), lead.getCreatedAt());
    }
}

@NotBlank

Checks
the string is not null and has at least one non-whitespace character

@NotNull

Checks
a value is present (any type, including an enum)

@Size(max = 100)

Checks
string length or collection size is within bounds

@Pattern(regexp = …)

Checks
the whole string matches the regular expression

@Email, @Min, @Max, @Positive

Checks
other common constraints from the same jakarta.validation.constraints package

The annotations do nothing until the controller parameter is marked @Valid. Then Spring MVC validates the request body before calling the method and, on failure, throws MethodArgumentNotValidException, which becomes a 400 response by default. The Spring docs recommend handling the related HandlerMethodValidationException too: it is raised when constraints sit directly on method parameters, such as @Min on a @PathVariable.

The service layer and dependency injection

The service holds the lead rules and the transaction boundaries; the controller never talks to the repository directly. Spring creates LeadService once (as a bean) and passes the repository through the constructor. The Spring Boot docs recommend constructor injection: dependencies are visible in the signature, the field can be final, and a test can easily pass its own implementation.

src/main/java/com/example/leads/lead/LeadService.java
package com.example.leads.lead;

import java.util.List;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
public class LeadService {

    private final LeadRepository repository;

    // a single constructor: Spring passes the repository in, no @Autowired needed
    public LeadService(LeadRepository repository) {
        this.repository = repository;
    }

    public List<LeadResponse> findAll(LeadStatus status) {
        List<Lead> leads = status == null
                ? repository.findAll(Sort.by(Sort.Direction.DESC, "id"))
                : repository.findByStatusOrderByIdDesc(status);
        return leads.stream().map(LeadResponse::from).toList();
    }

    public LeadResponse get(Long id) {
        return LeadResponse.from(find(id));
    }

    @Transactional
    public LeadResponse create(LeadRequest request) {
        Lead lead = new Lead(request.name().strip(), request.phone().strip(), request.message());
        return LeadResponse.from(repository.save(lead));
    }

    @Transactional
    public LeadResponse update(Long id, LeadRequest request) {
        Lead lead = find(id);
        lead.update(request.name().strip(), request.phone().strip(), request.message());
        return LeadResponse.from(lead); // no save needed: changes are flushed when the transaction commits
    }

    @Transactional
    public LeadResponse changeStatus(Long id, LeadStatus status) {
        Lead lead = find(id);
        lead.changeStatus(status);
        return LeadResponse.from(lead);
    }

    @Transactional
    public void delete(Long id) {
        repository.delete(find(id));
    }

    private Lead find(Long id) {
        return repository.findById(id).orElseThrow(() -> new LeadNotFoundException(id));
    }
}
src/main/java/com/example/leads/lead/LeadNotFoundException.java
package com.example.leads.lead;

public class LeadNotFoundException extends RuntimeException {

    public LeadNotFoundException(Long id) {
        super("Lead " + id + " not found");
    }
}
  1. @Transactional(readOnly = true) on the class

    every method runs in a read-only transaction by default

  2. @Transactional on modifying methods

    overrides the mode: create, update, changeStatus and delete write to the database

  3. Dirty checking

    update never calls save: Hibernate detects changed fields of the loaded entity and issues an UPDATE on commit

  4. orElseThrow

    findById returns an Optional; when the lead is missing the service throws LeadNotFoundException, and the error handler turns it into a 404

The controller: routes and status codes

@RestController is a @Controller whose method results are serialized straight into the response body (as JSON). @RequestMapping("/api/leads") sets the common prefix, and @GetMapping, @PostMapping and friends set the HTTP method and the rest of the path.

src/main/java/com/example/leads/lead/LeadController.java
package com.example.leads.lead;

import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;

@RestController
@RequestMapping("/api/leads")
public class LeadController {

    private final LeadService service;

    public LeadController(LeadService service) {
        this.service = service;
    }

    @GetMapping // GET /api/leads and GET /api/leads?status=NEW
    public List<LeadResponse> list(@RequestParam(required = false) LeadStatus status) {
        return service.findAll(status);
    }

    @GetMapping("/{id}")
    public LeadResponse get(@PathVariable Long id) {
        return service.get(id);
    }

    @PostMapping
    public ResponseEntity<LeadResponse> create(@Valid @RequestBody LeadRequest request, UriComponentsBuilder uri) {
        LeadResponse created = service.create(request);
        URI location = uri.path("/api/leads/{id}").buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(created); // 201 + Location header
    }

    @PutMapping("/{id}")
    public LeadResponse update(@PathVariable Long id, @Valid @RequestBody LeadRequest request) {
        return service.update(id, request);
    }

    @PatchMapping("/{id}/status")
    public LeadResponse changeStatus(@PathVariable Long id, @Valid @RequestBody LeadStatusRequest request) {
        return service.changeStatus(id, request.status());
    }

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

GET /api/leads?status=NEW

Success
200 and an array
Errors
400 — unknown status

GET /api/leads/{id}

Success
200 and the lead
Errors
404 — no such lead

POST /api/leads

Success
201, a Location header and the lead
Errors
400 — validation failed or malformed JSON

PUT /api/leads/{id}

Success
200 and the updated lead
Errors
400, 404

PATCH /api/leads/{id}/status

Success
200 and the lead with its new status
Errors
400, 404

DELETE /api/leads/{id}

Success
204 with no body
Errors
404

Spring takes the {id} and status names from the method parameter names: spring-boot-starter-parent enables the compiler’s -parameters flag, so @PathVariable("id") is unnecessary. The ?status=NEW query string is converted into the enum automatically. POST answers 201 Created with the new resource’s URL in Location, telling the client where to go next.

terminal
$ curl -i -X POST localhost:8080/api/leads -H "Content-Type: application/json" -d '{"name": "Aigerim", "phone": "+7 700 000 00 01", "message": "We need a website for our salon"}'
HTTP/1.1 201 
Location: http://localhost:8080/api/leads/1
Content-Type: application/json
…

{"id":1,"name":"Aigerim","phone":"+7 700 000 00 01","message":"We need a website for our salon","status":"NEW","createdAt":"2026-10-11T23:42:11.888709768Z"}

$ curl -X PATCH localhost:8080/api/leads/1/status -H "Content-Type: application/json" -d '{"status": "IN_PROGRESS"}'
{"id":1,"name":"Aigerim","phone":"+7 700 000 00 01","message":"We need a website for our salon","status":"IN_PROGRESS","createdAt":"2026-10-11T23:42:11.888710Z"}

$ curl "localhost:8080/api/leads?status=IN_PROGRESS"
[{"id":1,"name":"Aigerim","phone":"+7 700 000 00 01","message":"We need a website for our salon","status":"IN_PROGRESS","createdAt":"2026-10-11T23:42:11.888710Z"}]
Actual output: the app runs via java -jar with in-memory H2. Response headers are shortened.

Errors: 404 and 400 as Problem Details

Without a handler, LeadNotFoundException would reach the client as a 500 — as if the server broke, while the lead simply does not exist. The Spring Framework supports RFC 9457, “Problem Details for HTTP APIs”: the ProblemDetail class with type, title, status, detail and instance fields and the application/problem+json media type. Following the Spring docs, we build the handler on top of ResponseEntityExceptionHandler, which already renders built-in Spring MVC errors in this format.

src/main/java/com/example/leads/lead/ApiExceptionHandler.java
package com.example.leads.lead;

import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

// API errors in RFC 9457 format (application/problem+json).
// The base class already turns standard Spring MVC errors into ProblemDetail:
// malformed JSON is 400, a wrong method is 405, and so on
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(LeadNotFoundException.class)
    public ProblemDetail handleNotFound(LeadNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Lead not found");
        return problem;
    }

    // 400 with a list of fields: {"errors": {"phone": "Phone is required"}}
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        Map<String, String> errors = new LinkedHashMap<>();
        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            errors.putIfAbsent(error.getField(), error.getDefaultMessage());
        }
        ProblemDetail problem = ex.getBody();
        problem.setDetail("Check the request fields");
        problem.setProperty("errors", errors);
        return handleExceptionInternal(ex, problem, headers, status, request);
    }
}
terminal
$ curl -i -X POST localhost:8080/api/leads -H "Content-Type: application/json" -d '{"name": "", "phone": "abc"}'
HTTP/1.1 400 
Content-Type: application/problem+json
…

{"detail":"Check the request fields","instance":"/api/leads","status":400,"title":"Bad Request","errors":{"phone":"Phone must be 10 to 20 digits; +, spaces and brackets are allowed","name":"Name is required"}}

$ curl -i localhost:8080/api/leads/42
HTTP/1.1 404 
Content-Type: application/problem+json
…

{"detail":"Lead 42 not found","instance":"/api/leads/42","status":404,"title":"Lead not found"}
The order of fields inside errors may change between runs: the validator does not guarantee the order of violations.

Configuration, profiles and PostgreSQL in Docker

Replace the generated application.properties with application.yaml — Spring Boot reads both formats. A --- line splits the file into two documents: the first always applies, the second only when the postgres profile is active (spring.config.activate.on-profile). Without a profile, in-memory H2 starts automatically, and per the Spring Boot docs an embedded database gets its tables created and dropped for you (ddl-auto: create-drop).

src/main/resources/application.yaml
spring:
  application:
    name: leads
  jpa:
    open-in-view: false # transactions and lazy loading stay in the service layer

---
# postgres profile: java -jar app.jar --spring.profiles.active=postgres
spring:
  config:
    activate:
      on-profile: postgres
  datasource:
    url: ${DB_URL:jdbc:postgresql://localhost:5432/leads}
    username: ${DB_USER:leads}
    password: ${DB_PASSWORD}
  jpa:
    hibernate:
      ddl-auto: update # fine for a tutorial; use Flyway/Liquibase migrations in production

${DB_PASSWORD} reads an environment variable, and ${DB_URL:jdbc:…} reads a variable or falls back to the value after the colon. That keeps the password out of the repository. Let us check the profile against real PostgreSQL in Docker; the app also runs in a container on the same network as the database:

terminal
$ docker network create leads-net
$ docker run -d --name leads-db --network leads-net -e POSTGRES_DB=leads -e POSTGRES_USER=leads -e POSTGRES_PASSWORD=change-me postgres:18-alpine
$ docker run --rm --network leads-net -p 8080:8080 -v "$PWD/target:/app" -w /app \
    -e DB_URL=jdbc:postgresql://leads-db:5432/leads -e DB_PASSWORD=change-me \
    eclipse-temurin:25-jre java -jar leads-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres
… The following 1 profile is active: "postgres"
…
    Database driver: PostgreSQL JDBC Driver
    Database dialect: PostgreSQLDialect
    Database version: 18.6
…

$ docker exec leads-db psql -U leads -d leads -c "select id, name, status from leads"
 id |  name   | status 
----+---------+--------
  1 | Aigerim | NEW
(1 row)
One lead was created with the same curl as above before querying psql. Data survives an app restart: ddl-auto: update does not drop the table. To stop: docker stop leads-db.

MockMvc tests, the build and exercises

@SpringBootTest starts the whole application context with H2 but without a real HTTP server, and @AutoConfigureMockMvc (in Spring Boot 4 it lives in org.springframework.boot.webmvc.test.autoconfigure) provides MockMvc: it sends requests through the same Spring MVC pipeline — routing, validation, the error handler, Jackson. @Transactional on the test class rolls back changes after each test, so tests do not affect each other.

src/test/java/com/example/leads/lead/LeadControllerTest.java
package com.example.leads.lead;

import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.matchesPattern;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
@AutoConfigureMockMvc
@Transactional // every test is rolled back, so the H2 database stays clean
class LeadControllerTest {

    private static final String VALID_LEAD = """
            {"name": "Aigerim", "phone": "+7 700 000 00 01", "message": "We need a website for our salon"}
            """;

    @Autowired
    MockMvc mvc;

    // creates a lead and returns its URL from the Location header
    private String createLead() throws Exception {
        return mvc.perform(post("/api/leads").contentType(MediaType.APPLICATION_JSON).content(VALID_LEAD))
                .andExpect(status().isCreated())
                .andReturn().getResponse().getHeader("Location");
    }

    @Test
    void createsAndReadsLead() throws Exception {
        mvc.perform(post("/api/leads").contentType(MediaType.APPLICATION_JSON).content(VALID_LEAD))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", matchesPattern(".*/api/leads/\\d+")))
                .andExpect(jsonPath("$.status").value("NEW"));

        mvc.perform(get(createLead()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name").value("Aigerim"))
                .andExpect(jsonPath("$.phone").value("+7 700 000 00 01"));
    }

    @Test
    void rejectsInvalidLead() throws Exception {
        mvc.perform(post("/api/leads").contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"name": " ", "phone": "abc"}
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.errors.name").value("Name is required"))
                .andExpect(jsonPath("$.errors.phone").exists());
    }

    @Test
    void returns404ForUnknownLead() throws Exception {
        mvc.perform(get("/api/leads/999999"))
                .andExpect(status().isNotFound())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.title").value("Lead not found"))
                .andExpect(jsonPath("$.detail").value("Lead 999999 not found"));
    }

    @Test
    void changesStatusAndFiltersList() throws Exception {
        String location = createLead();
        mvc.perform(patch(location + "/status").contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"status": "IN_PROGRESS"}
                                """))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.status").value("IN_PROGRESS"));

        mvc.perform(get("/api/leads").param("status", "IN_PROGRESS"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$", hasSize(1)));
    }

    @Test
    void deletesLead() throws Exception {
        String location = createLead();
        mvc.perform(delete(location)).andExpect(status().isNoContent());
        mvc.perform(get(location)).andExpect(status().isNotFound());
    }
}
terminal
$ ./mvnw package
…
[INFO] Running com.example.leads.lead.LeadControllerTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 48.42 s -- in com.example.leads.lead.LeadControllerTest
[INFO] Running com.example.leads.LeadsApplicationTests
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.432 s -- in com.example.leads.LeadsApplicationTests
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] Building jar: /app/target/leads-0.0.1-SNAPSHOT.jar
[INFO] --- spring-boot:4.1.1:repackage (repackage) @ leads ---
[INFO] BUILD SUCCESS

$ java -jar target/leads-0.0.1-SNAPSHOT.jar
…
… No active profile set, falling back to 1 default profile: "default"
…
… Tomcat started on port 8080 (http) with context path '/'
… Started LeadsApplication in 38.098 seconds (process running for 39.347)
Building with the Maven Wrapper: tests run before packaging, and a failing test stops the build. spring-boot:repackage produces an executable jar with all dependencies. On Windows use mvnw.cmd package.

Next steps: move error handling into a shared recipe for all resources, study backend application layers in more depth, and replace ddl-auto with migrations. A repository test with the @DataJpaTest slice is also useful — the spring-boot-starter-data-jpa-test starter is already in the project.

How this was verified5 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.