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.
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
@Validand 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
Know beforehand
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
- What Spring Boot gives you: starters, auto-configuration, start.spring.io
- The entity and a Spring Data JPA repository
- DTOs and validation with Jakarta Bean Validation
- The service layer and dependency injection
- The controller: routes and status codes
- Errors: 404 and 400 as Problem Details
- Configuration, profiles and PostgreSQL in Docker
- MockMvc 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:
$ 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.javamvnw is the Maven Wrapper: it downloads the right Maven version by itself.<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>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.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.
- 01LeadControllerHTTP: routes, status codes, input validation
- 02LeadServicebusiness rules and transactions
- 03LeadRepositorySpring Data JPA: database queries
- 04H2 / PostgreSQLthe leads table
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.
package com.example.leads.lead;
public enum LeadStatus {
NEW,
IN_PROGRESS,
DONE
}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 agenerated by default as identitycolumn.@Enumerated(EnumType.STRING)stores the status as text. The default ordinal would corrupt data once someone inserts a new status in the middle of theenum.- 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
updateandchangeStatus, as in the OOP lesson.
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.
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) {
}package com.example.leads.lead;
import jakarta.validation.constraints.NotNull;
public record LeadStatusRequest(@NotNull(message = "Status is required") LeadStatus status) {
}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
nulland 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.constraintspackage
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.
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));
}
}package com.example.leads.lead;
public class LeadNotFoundException extends RuntimeException {
public LeadNotFoundException(Long id) {
super("Lead " + id + " not found");
}
}@Transactional(readOnly = true) on the class
every method runs in a read-only transaction by default
@Transactional on modifying methods
overrides the mode:
create,update,changeStatusanddeletewrite to the databaseDirty checking
updatenever callssave: Hibernate detects changed fields of the loaded entity and issues anUPDATEon commitorElseThrow
findByIdreturns anOptional; when the lead is missing the service throwsLeadNotFoundException, 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.
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
Locationheader 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.
$ 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"}]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.
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);
}
}$ 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"}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).
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:
$ 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)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.
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());
}
}$ ./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)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.