Как делать11 октября 2026 г.6 мин чтения
JavaШаг 5 из 6

Spring Boot: REST API с нуля

Коротко

REST API на Spring Boot собирается из трёх слоёв: @RestController принимает HTTP-запросы и отдаёт JSON, @Service хранит правила и транзакции, а интерфейс-репозиторий Spring Data JPA работает с базой без ручного SQL. Входные данные описывают DTO с аннотациями Jakarta Bean Validation и @Valid, ошибки 400 и 404 возвращаются в формате RFC 9457 через @RestControllerAdvice. Проект генерируют на start.spring.io, проверяют тестами MockMvc на H2 и запускают с PostgreSQL через профиль.

Пошаговый урокУровень: СреднийSpring BootПрактика: ~3–6 ч

После материала вы сможете

  • сгенерировать проект на start.spring.io и объяснить, что делают стартеры и автоконфигурация
  • написать контроллер, сервис и репозиторий Spring Data JPA для CRUD с кодами 200, 201, 204 и 404
  • валидировать DTO через @Valid и аннотации Jakarta Bean Validation и отдавать ошибки в формате RFC 9457
  • проверить API тестами MockMvc и запустить его с профилем PostgreSQL в Docker

Проверено на

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

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Что даёт Spring Boot: стартеры, автоконфигурация, start.spring.io
  2. 02Сущность и репозиторий Spring Data JPA
  3. 03DTO и валидация: Jakarta Bean Validation
  4. 04Сервисный слой и внедрение зависимостей
  5. 05Контроллер: маршруты и коды ответов
  6. 06Ошибки: 404 и 400 в формате Problem Details
  7. 07Конфигурация, профили и PostgreSQL в Docker
  8. 08Тесты MockMvc, сборка и упражнения

Что даёт Spring Boot: стартеры, автоконфигурация, start.spring.io

Spring Boot — надстройка над Spring Framework, которая убирает ручную настройку. Две идеи делают её удобной. Стартеры — готовые наборы зависимостей: один spring-boot-starter-webmvc приносит Spring MVC, встроенный Tomcat и Jackson для JSON. Автоконфигурация смотрит, какие библиотеки лежат в проекте, и сама создаёт нужные объекты: нашла драйвер H2 и Spring Data JPA — поднимет базу в памяти и репозитории. Любую из этих настроек можно переопределить своей.

Урок собран на Spring Boot 4.1.1 — актуальной стабильной версии на start.spring.io. По документации ей нужна Java 17 или новее (совместимость заявлена до Java 26) и Spring Framework 7. Мы берём Java 25 LTS из прошлого урока. Если вы видели примеры для Spring Boot 3, учтите переименования четвёртой версии: веб-стартер называется spring-boot-starter-webmvc, а для тестов появились отдельные стартеры по технологиям, например spring-boot-starter-webmvc-test. Проект генерируем одной командой — так же, как кнопкой Generate на сайте:

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
Зависимости: Spring Web, Spring Data JPA, Validation, H2 и драйвер PostgreSQL. mvnw — Maven Wrapper: он скачает нужную версию Maven сам.
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>
Фрагмент сгенерированного pom.xml. Версии библиотек не указаны: их задаёт родительский spring-boot-starter-parent. Модуль spring-boot-h2console Initializr добавил сам вместе с 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 объединяет три аннотации: @SpringBootConfiguration, @EnableAutoConfiguration (включает автоконфигурацию) и @ComponentScan (ищет ваши классы с аннотациями @Service, @RestController и другими в этом пакете и вложенных). Поэтому весь код заявок кладём в подпакет com.example.leads.lead — иначе Spring его не найдёт.

  1. 01LeadControllerHTTP: маршруты, коды ответов, валидация входа
  2. 02LeadServiceправила и транзакции
  3. 03LeadRepositorySpring Data JPA: запросы к базе
  4. 04H2 / PostgreSQLтаблица leads
Слои API заявок: каждый знает только о соседе снизу.

Сущность и репозиторий Spring Data JPA

Практика урока — API заявок с сайта: имя, телефон, сообщение и статус обработки. JPA (Jakarta Persistence) связывает Java-класс с таблицей: класс с @Entity — строка таблицы, поля — колонки. Реализация JPA в Spring Boot — Hibernate.

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) // в базе — 'NEW', а не порядковый номер
    @Column(nullable = false, length = 20)
    private LeadStatus status = LeadStatus.NEW;

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

    protected Lead() {
        // конструктор без аргументов нужен JPA
    }

    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) — идентификатор выдаёт база: в PostgreSQL это колонка generated by default as identity.
  • @Enumerated(EnumType.STRING) хранит статус текстом. Порядковый номер по умолчанию сломает данные, если кто-то вставит новый статус в середину enum.
  • Защищённый конструктор без аргументов нужен JPA, чтобы создавать объекты при чтении из базы; код приложения пользуется публичным.
  • Сеттеров нет: состояние меняют методы update и changeStatus, как в уроке об ООП.
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> {

    // запрос выводится из имени метода: WHERE status = ? ORDER BY id DESC
    List<Lead> findByStatusOrderByIdDesc(LeadStatus status);
}

Репозиторий — только интерфейс. Наследуя JpaRepository<Lead, Long>, он получает save, findById, findAll, delete и другие методы, а реализацию Spring Data создаёт при запуске. Метод findByStatusOrderByIdDesc вообще не содержит SQL: по документации Spring Data имя разбирается на «субъект» (find…By) и «предикат» (Status, OrderByIdDesc), и из них строится запрос.

DTO и валидация: Jakarta Bean Validation

Наружу API работает не с сущностью, а с DTO — простыми объектами для входа и выхода. Если принимать Lead прямо из JSON, клиент сможет прислать "status": "DONE" или чужой id. Отдельный LeadRequest перечисляет ровно те поля, которые клиент вправе передать, и описывает правила для них аннотациями Jakarta Bean Validation.

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 и PUT: то, что клиент вправе прислать (без id, статуса и даты)
public record LeadRequest(
        @NotBlank(message = "Укажите имя")
        @Size(max = 100, message = "Имя — не длиннее 100 символов")
        String name,

        @NotBlank(message = "Укажите телефон")
        @Pattern(regexp = "\\+?[0-9 ()-]{10,20}", message = "Телефон — от 10 до 20 цифр, можно с +, пробелами и скобками")
        String phone,

        @Size(max = 1000, message = "Сообщение — не длиннее 1000 символов")
        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 = "Укажите статус") LeadStatus status) {
}
src/main/java/com/example/leads/lead/LeadResponse.java
package com.example.leads.lead;

import java.time.Instant;

// ответ API: наружу отдаём DTO, а не JPA-сущность
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

Проверяет
строка не null и содержит хотя бы один непробельный символ

@NotNull

Проверяет
значение передано (для любых типов, в том числе enum)

@Size(max = 100)

Проверяет
длина строки или размер коллекции в границах

@Pattern(regexp = …)

Проверяет
строка целиком совпадает с регулярным выражением

@Email, @Min, @Max, @Positive

Проверяет
другие частые ограничения из того же пакета jakarta.validation.constraints

Сами аннотации ничего не делают, пока параметр контроллера не помечен @Valid. Тогда Spring MVC проверяет тело запроса до вызова метода и при нарушении бросает MethodArgumentNotValidException — по умолчанию это ответ 400. Документация Spring советует обрабатывать и родственное HandlerMethodValidationException: его бросает проверка ограничений, повешенных прямо на параметры метода, например @Min на @PathVariable.

Сервисный слой и внедрение зависимостей

Сервис хранит правила работы с заявками и границы транзакций; контроллер не обращается к репозиторию напрямую. Spring создаёт LeadService один раз (bean) и передаёт ему репозиторий через конструктор. Документация Spring Boot рекомендует именно внедрение через конструктор: зависимости видны в сигнатуре, поле можно сделать final, а в тесте легко подставить свою реализацию.

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;

    // единственный конструктор: Spring передаст репозиторий сам, @Autowired не нужен
    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); // save не нужен: изменения сохранятся при коммите транзакции
    }

    @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("Заявка " + id + " не найдена");
    }
}
  1. @Transactional(readOnly = true) на классе

    все методы по умолчанию выполняются в транзакции только для чтения

  2. @Transactional на изменяющих методах

    переопределяет режим: create, update, changeStatus и delete пишут в базу

  3. Отслеживание изменений

    в update нет вызова save: Hibernate сам заметит изменённые поля загруженной сущности и выполнит UPDATE при коммите

  4. orElseThrow

    findById возвращает Optional; если заявки нет, сервис бросает LeadNotFoundException, а превращать её в 404 будет обработчик ошибок

Контроллер: маршруты и коды ответов

@RestController — это @Controller, у которого результат каждого метода сразу сериализуется в тело ответа (в JSON). @RequestMapping("/api/leads") задаёт общий префикс, а @GetMapping, @PostMapping и соседние аннотации — HTTP-метод и остаток пути.

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 и 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
    }

    @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

Успех
200 и массив
Ошибки
400 — неизвестный статус

GET /api/leads/{id}

Успех
200 и заявка
Ошибки
404 — нет заявки

POST /api/leads

Успех
201, заголовок Location и заявка
Ошибки
400 — не прошла валидация или битый JSON

PUT /api/leads/{id}

Успех
200 и обновлённая заявка
Ошибки
400, 404

PATCH /api/leads/{id}/status

Успех
200 и заявка с новым статусом
Ошибки
400, 404

DELETE /api/leads/{id}

Успех
204 без тела
Ошибки
404

Имена {id} и status Spring берёт из имён параметров метода: spring-boot-starter-parent включает для компилятора флаг -parameters, поэтому писать @PathVariable("id") не нужно. Строка запроса ?status=NEW сама превращается в enum. Ответ на POST возвращает 201 Created и адрес новой записи в Location — так клиент узнаёт, куда обращаться дальше.

terminal
$ curl -i -X POST localhost:8080/api/leads -H "Content-Type: application/json" -d '{"name": "Айгерим", "phone": "+7 700 000 00 01", "message": "Нужен сайт для салона"}'
HTTP/1.1 201 
Location: http://localhost:8080/api/leads/1
Content-Type: application/json
…

{"id":1,"name":"Айгерим","phone":"+7 700 000 00 01","message":"Нужен сайт для салона","status":"NEW","createdAt":"2026-10-11T23:41:53.116756544Z"}

$ curl -X PATCH localhost:8080/api/leads/1/status -H "Content-Type: application/json" -d '{"status": "IN_PROGRESS"}'
{"id":1,"name":"Айгерим","phone":"+7 700 000 00 01","message":"Нужен сайт для салона","status":"IN_PROGRESS","createdAt":"2026-10-11T23:41:53.116757Z"}

$ curl "localhost:8080/api/leads?status=IN_PROGRESS"
[{"id":1,"name":"Айгерим","phone":"+7 700 000 00 01","message":"Нужен сайт для салона","status":"IN_PROGRESS","createdAt":"2026-10-11T23:41:53.116757Z"}]
Реальный вывод: приложение запущено через java -jar, база H2 в памяти. Заголовки ответа сокращены.

Ошибки: 404 и 400 в формате Problem Details

Без обработчика исключение LeadNotFoundException дошло бы до клиента как ответ 500 — будто сломался сервер, хотя заявки просто нет. Spring Framework поддерживает стандарт RFC 9457 «Problem Details for HTTP APIs»: класс ProblemDetail с полями type, title, status, detail и instance и тип ответа application/problem+json. По рекомендации документации Spring собираем обработчик на базе ResponseEntityExceptionHandler — он уже переводит в этот формат встроенные ошибки Spring MVC.

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 в формате RFC 9457 (application/problem+json).
// Базовый класс уже превращает стандартные ошибки Spring MVC в ProblemDetail:
// битый JSON — 400, неверный метод — 405 и так далее
@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("Заявка не найдена");
        return problem;
    }

    // 400 с перечнем полей: {"errors": {"phone": "Укажите телефон"}}
    @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("Проверьте поля запроса");
        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":"Проверьте поля запроса","instance":"/api/leads","status":400,"title":"Bad Request","errors":{"name":"Укажите имя","phone":"Телефон — от 10 до 20 цифр, можно с +, пробелами и скобками"}}

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

{"detail":"Заявка 42 не найдена","instance":"/api/leads/42","status":404,"title":"Заявка не найдена"}
Порядок полей внутри errors может меняться от запуска к запуску: валидатор не гарантирует порядок нарушений.

Конфигурация, профили и PostgreSQL в Docker

Сгенерированный application.properties заменяем на application.yaml — Spring Boot читает оба формата. Файл разделён строкой --- на два документа: первый действует всегда, второй — только при активном профиле postgres (spring.config.activate.on-profile). Без профиля H2 в памяти поднимается автоматически, и по документации Spring Boot для встроенной базы таблицы создаются и удаляются сами (ddl-auto: create-drop).

src/main/resources/application.yaml
spring:
  application:
    name: leads
  jpa:
    open-in-view: false # транзакции и ленивые связи — только в сервисном слое

---
# профиль postgres: 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 # для учебного проекта; в production — миграции Flyway/Liquibase

Запись ${DB_PASSWORD} берёт значение из переменной окружения, а ${DB_URL:jdbc:…} — из переменной или из значения после двоеточия. Так пароль не попадает в репозиторий. Проверим профиль на настоящем PostgreSQL в Docker; приложение тоже запускаем в контейнере, в одной сети с базой:

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 | Айгерим | NEW
(1 row)
Между запуском и запросом к psql создана одна заявка тем же curl, что выше. После перезапуска приложения данные остаются: ddl-auto: update не удаляет таблицу. Остановка: docker stop leads-db.

Тесты MockMvc, сборка и упражнения

@SpringBootTest поднимает весь контекст приложения с базой H2, но без настоящего HTTP-сервера, а @AutoConfigureMockMvc (в Spring Boot 4 — из пакета org.springframework.boot.webmvc.test.autoconfigure) даёт MockMvc: он прогоняет запросы через тот же Spring MVC — маршрутизацию, валидацию, обработчик ошибок, Jackson. @Transactional на тестовом классе откатывает изменения после каждого теста, поэтому тесты не влияют друг на друга.

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 // каждый тест откатывается — база H2 остаётся чистой
class LeadControllerTest {

    private static final String VALID_LEAD = """
            {"name": "Айгерим", "phone": "+7 700 000 00 01", "message": "Нужен сайт для салона"}
            """;

    @Autowired
    MockMvc mvc;

    // создаёт заявку и возвращает её адрес из заголовка Location
    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("Айгерим"))
                .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("Укажите имя"))
                .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("Заявка не найдена"))
                .andExpect(jsonPath("$.detail").value("Заявка 999999 не найдена"));
    }

    @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)
Сборка через Maven Wrapper: тесты идут до упаковки, упавший тест остановит сборку. spring-boot:repackage делает исполняемый jar со всеми зависимостями. В Windows — mvnw.cmd package.

Что дальше: вынести обработку ошибок в общий рецепт для всех ресурсов, разобрать слои backend-приложения подробнее и заменить ddl-auto миграциями. Для контроля качества пригодится и тест репозитория со срезом @DataJpaTest — стартер spring-boot-starter-data-jpa-test уже есть в проекте.

Как проверено5 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    генерация проекта (curl starter.zip) и листинг файлов

    запрос к start.spring.io выполнен 2026-10-11 (HTTP 200), структура `find` совпала с блоком статьи; pom.xml во фрагменте — из сгенерированного файла

  • запущено, работает

    проект leads (LeadsApplication, Lead, LeadStatus, LeadRepository, LeadRequest, LeadStatusRequest, LeadResponse, LeadNotFoundException, LeadService, LeadController, ApiExceptionHandler, application.yaml, LeadControllerTest) — RU и EN-копия с английскими строками

    docker maven:3.10-eclipse-temurin-25: `mvn -B package` — 6/6 тестов (5 MockMvc + contextLoads), BUILD SUCCESS в обеих локалях; `./mvnw package` (Maven Wrapper 3.3.4 → Maven 3.10.0) в eclipse-temurin:25-jdk — 6/6, repackage. Фактические версии из dependency:list: Spring Boot 4.1.1, Spring Framework 7.0.9, Hibernate ORM 7.4.5, Hibernate Validator 9.1.3, Jackson 3.1.5, Tomcat 11.0.24, H2 2.4.240, PostgreSQL JDBC 42.7.13, JUnit 6.0.3

  • запущено, работает

    curl-сессии (POST 201 + Location, PATCH, фильтр ?status, 400 с errors, 404) — RU и EN

    jar в eclipse-temurin:25-jre (H2), ровно те же команды curl выполнены из контейнера curlimages/curl:8.17.0 в сетевом пространстве приложения (localhost:8080); тела ответов в статье сверены с выводом генератором; дополнительно проверены PUT 200, DELETE 204→404, 405, 400 на битый JSON и неизвестный статус

  • запущено, работает

    профиль postgres с PostgreSQL в Docker

    postgres:18-alpine (18.6) + приложение в eclipse-temurin:25-jre в общей docker-сети с DB_URL/DB_PASSWORD и --spring.profiles.active=postgres: старт с PostgreSQLDialect, POST 201, `psql select id, name, status from leads` — вывод совпал; таблица leads с identity и CHECK по статусам; после перезапуска приложения запись сохранилась. Имена контейнеров при проверке — с префиксом kb- (в статье leads-db/leads-net), остальное идентично

  • запущено, работает

    решения упражнений: тест PUT, 409 для закрытой заявки (LeadClosedException + обработчик), GET /api/leads/stats (countByStatus + EnumMap) — RU и EN

    копия проекта leads-ex: `mvn -B package` — 9/9 тестов (5 + 3 LeadExercisesTest + 1) в обеих локалях; код решений в статье — фрагменты из проверенных файлов, извлечены генератором

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.