Перейти к основному содержимому

Практикум Spring Boot — Simple CRM

Разработчику Начальный уровень

О практикуме

Соберём Simple CRM — учебное веб-приложение на Spring Boot 3 с SQLite, Thymeleaf и REST API. Пользователь сможет просматривать клиентов в браузере, добавлять и редактировать записи, удалять строки; параллельно тот же набор операций доступен через JSON-эндпоинты для интеграций.

CRM (Customer Relationship Management) в нашем случае — не корпоративный монолит, а компактный справочник клиентов с полями имя, email, телефон, компания и заметки. Такой сценарий встречается в реальных проектах как первый модуль внутренней админки или как учебный прототип перед подключением PostgreSQL и авторизации.

Стек проекта:

Эталонный проект

Готовый код для сверки — F:\Projects\JVM\Java\SpringBootSimple. Статья повторяет его структуру и содержимое файлов по этапам; после каждого шага можно сравнить diff с эталоном или запустить mvn compile.

Что такое Spring Boot в контексте этого проекта

Spring Boot — надстройка над Spring Framework. В классическом Spring вы вручную собираете контекст, XML или Java-конфигурацию, отдельно ставите Tomcat. Boot делает обратное: вы описываете классы с аннотациями, а автоконфигурация подключает DataSource, JPA, MVC, Jackson, если в classpath есть нужные стартеры.

Одна команда mvn spring-boot:run:

  • компилирует проект;
  • поднимает встроенный Tomcat на порту 8080;
  • сканирует пакет com.example.crm и создаёт beans;
  • связывает зависимости через DI — внедрение в конструктор (ООП и инкапсуляция).

Точка входа — обычный public static void main (точка входа JVM). Это принципиально иной старт, чем у JSF WAR (практикум "Список задач"), где приложение разворачивают в уже запущенном servlet-контейнере.

Как запрос проходит через приложение

Один и тот же сервисный слой обслуживает и браузер, и API. Контроллеры — тонкие: они принимают HTTP, вызывают CustomerService и возвращают view или JSON.

В приложении четыре слоя:

СлойРоль в проектеФайлыНе должен знать о
ModelJPA-сущность, валидация полейCustomer.javaHTTP, URL, HTML
RepositoryЧтение и запись в БДCustomerRepository.javaбизнес-правила, JSON
ServiceCRUD, транзакции, исключенияCustomerService.javaThymeleaf, статусы HTTP
ControllerМаршруты HTML и JSONCustomerWebController, CustomerRestControllerSQL, детали Hibernate

Веб-контроллер возвращает имя шаблона ("customers/list") — Spring MVC передаёт его в Thymeleaf. REST-контроллер возвращает объекты JavaJackson сериализует их в JSON в теле ответа.

Ключевые термины

  • CRUD — Create, Read, Update, Delete; четыре базовые операции над сущностью "клиент".
  • Bean — объект, жизненным циклом которого управляет Spring-контейнер (@Service, @Controller, @RestController, @Configuration). Обычно один экземпляр на тип в рамках приложения (аннотации Spring Boot).
  • Стартер (spring-boot-starter-web) — Maven-зависимость, которая тянет согласованный набор библиотек; версии задаёт spring-boot-starter-parent.
  • JPA Entity — класс, строка которого хранится в таблице БД; аннотации @Entity, @Table, @Column описывают маппинг объект ↔ SQL.
  • Repository — интерфейс Spring Data; реализацию и SQL для findById, save генерирует фреймворк.
  • Query method — метод репозитория с именем вроде findAllByOrderByNameAsc; Spring Data строит JPQL по конвенции имени.
  • @Transactional — граница транзакции БД; либо все изменения коммитятся, либо откатываются при исключении.
  • DTO vs Entity — здесь REST отдаёт ту же сущность Customer; в проде часто отделяют модель API от JPA (паттерны проектирования).
  • Thymeleaf — шаблонизатор "natural templates": HTML валиден и без сервера; атрибуты th:text, th:each, th:field подставляют данные из Model.
  • Model — контейнер атрибутов для view; контроллер кладёт customers, customer, formTitle.
  • Post-Redirect-Get (PRG) — после успешного POST браузер перенаправляют GET-ом, чтобы F5 не повторял отправку формы.
  • Flash attribute — одноразовое сообщение в сессии после redirect (например, "Клиент добавлен").
  • @RestControllerAdvice — перехват исключений REST-слоя и ответ JSON вместо HTML-страницы ошибки.
  • Whitelabel Error Page — стандартная страница Spring Boot при необработанном исключении; наш RestExceptionHandler заменяет её для API.

Почему именно этот проект

  • показывает типичный корпоративный каркас controller → service → repository — тот же, что в лабораторном кейсе Spring Boot, но с веб-UI;
  • два фронта на одном сервисе — браузер (HTML) и интеграции (JSON) без дублирования логики;
  • SQLite — нулевая настройка инфраструктуры; файл crm.db рядом с JAR;
  • демо-данные при первом запуске — сразу видно таблицу, не пустой экран;
  • валидация и обработка ошибок — и в форме, и в REST;
  • укладывается в один–два вечера, но архитектурно близок к реальным внутренним CRUD-сервисам.

Предварительные знания:

Что получится

Полный набор CRUD для сущности "клиент" — в браузере и через API.

ДействиеВеб (браузер)REST APIHTTP-метод REST
Список/customers/api/customersGET
Один клиент/customers/{id}/edit (форма)/api/customers/{id}GET
Создать/customers/newPOST /customersPOST /api/customersPOST
ОбновитьPOST /customers/{id}PUT /api/customers/{id}PUT
УдалитьPOST /customers/{id}/deleteDELETE /api/customers/{id}DELETE

Обратите внимание: в HTML удаление идёт через POST (ограничение форм в браузере), а в REST — через DELETE, как принято в API (HTTP-методы).

Карта этапов

ЭтапФокусРезультат
0Каркас Maven JARПустая структура каталогов
1pom.xmlSpring Boot, JPA, Thymeleaf, SQLite
2application.propertiesПодключение SQLite, порт 8080
3CrmApplicationТочка входа, @SpringBootApplication
4CustomerJPA-сущность и валидация
5CustomerRepositorySpring Data репозиторий
6CustomerServiceCRUD и транзакции
7CustomerRestControllerJSON API
8RestExceptionHandler404 и ошибки валидации
9DataInitializerДемо-клиенты при первом запуске
10CustomerWebControllerМаршруты HTML
11list.htmlТаблица клиентов
12form.htmlФорма создания и редактирования
13style.cssОформление страниц
14Запускmvn spring-boot:run, браузер и curl

После каждого этапа имеет смысл mvn compile или полный запуск — привычка из разработки и отладки. Не обязательно проходить все 15 этапов за один сеанс: логичные точки остановки — после этапа 3 (пустой Boot), 7 (REST без UI), 10 (контроллеры без CSS), 14 (полный продукт).

Два интерфейса — один сервис

Веб и REST делят CustomerService. Если исправите логику обновления в сервисе — изменится и форма, и API. Так и задумано в слоистой архитектуре: HTTP-адаптеры тонкие, правила — в одном месте.


Этап 0 — каркас проекта

Цель этапа — создать стандартную структуру Spring Boot JAR-проекта Maven.

В структуре Java-проектов Maven разделяет исходники, ресурсы и тесты. Spring Boot по умолчанию собирает executable JAR (java -jar), а не WAR для внешнего Tomcat.

Отличие от практикума JSF:

  • WAR — вы кладёте архив в уже установленный servlet-контейнер; точка входа — web.xml и сервлеты.
  • Boot JAR — внутри лежит встроенный Tomcat; точка входа — main в CrmApplication.

Пакет com.example.crm — учебный префикс. В реальных проектах это обычно домен компании в обратном порядке (ru.company.product). Spring Boot сканирует только пакет главного класса и вложенные — поэтому все наши классы живут под com.example.crm.*.

Создайте корневую папку:

mkdir simple-crm && cd simple-crm

Каталоги (Windows PowerShell):

New-Item -ItemType Directory -Force -Path `
src/main/java/com/example/crm/config, `
src/main/java/com/example/crm/controller, `
src/main/java/com/example/crm/model, `
src/main/java/com/example/crm/repository, `
src/main/java/com/example/crm/service, `
src/main/resources/static/css, `
src/main/resources/templates/customers

Итоговая схема:

simple-crm/
├── pom.xml
└── src/main/
├── java/com/example/crm/
│ ├── CrmApplication.java
│ ├── config/ ← DataInitializer, RestExceptionHandler
│ ├── controller/ ← Web и REST
│ ├── model/ ← Customer
│ ├── repository/
│ └── service/
└── resources/
├── application.properties
├── static/css/
│ └── style.css
└── templates/customers/
├── list.html
└── form.html

Разбор структуры

  • src/main/java — компилируемый код; путь com/example/crm/controller = пакет com.example.crm.controller (ООП, пакеты).
  • src/main/resources — всё, что попадает в JAR "как есть": properties, шаблоны, статика; не компилируется javac.
  • config/@Configuration, обработчики ошибок, инициализация данных; не HTTP-слой.
  • controller/ — только маппинг URL → вызов сервиса; без SQL и без разметки HTML в Java.
  • static/ — публичные файлы; URL /css/style.cssclasspath:/static/css/style.css.
  • templates/ — Thymeleaf; возврат "customers/list" из контроллера → templates/customers/list.html.

На этом этапе pom.xml ещё нет — каталоги можно создать вручную или сгенерировать через start.spring.io (Dependencies: Web, JPA, Thymeleaf, Validation), затем сверить с этапом 1.


Этап 1 — pom.xml

Цель этапа — подключить Spring Boot parent, стартеры Web, Data JPA, Thymeleaf, Validation и драйвер SQLite.

pom.xml (Project Object Model) — сердце Maven-проекта (структура и сборки). Блок <parent> с spring-boot-starter-parent даёт:

  • BOM (Bill of Materials) — согласованные версии Hibernate, Jackson, Tomcat;
  • настройки компилятора под <java.version>17</java.version>;
  • defaults для spring-boot-maven-plugin.

Без parent пришлось бы вручную подбирать совместимые версии десятка библиотек.

Создайте pom.xml в корне проекта:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/>
</parent>

<groupId>com.example</groupId>
<artifactId>simple-crm</artifactId>
<version>1.0.0</version>
<name>Simple CRM</name>
<description>Simple CRM with Spring Boot, SQLite, REST API and web UI</description>

<properties>
<java.version>17</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.49.1.0</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-community-dialects</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

Разбор зависимостей

ЗависимостьЧто подтягивает транзитивноЗачем в CRM
spring-boot-starter-webTomcat, Spring MVC, JacksonREST + встроенный HTTP-сервер
spring-boot-starter-data-jpaHibernate, JDBC, транзакцииCustomer ↔ таблица customers
spring-boot-starter-thymeleafThymeleaf + интеграция с MVCHTML-страницы списка и формы
spring-boot-starter-validationHibernate Validator@NotBlank на имени, @Email
sqlite-jdbcдрайвер JDBCфайл crm.db без сервера БД
hibernate-community-dialectsSQLiteDialectHibernate 6 не включает SQLite в core
spring-boot-starter-testJUnit 5, AssertJ, MockMvcтесты API (следующий шаг после практикума)

Разбор ключевых элементов POM

  • <parent>3.4.5 — фиксирует линейку Spring Boot; при обновлении меняют одну версию.
  • <artifactId>simple-crm</artifactId> — имя JAR: target/simple-crm-1.0.0.jar.
  • spring-boot-maven-plugin — собирает fat JAR (все зависимости внутри) и цель spring-boot:run.
  • <java.version>17</java.version> — LTS; Boot 3.x требует минимум Java 17 (основы Java).

Проверка — Maven должен разрешить дерево зависимостей без ошибок:

mvn -q validate
mvn -q dependency:resolve

Этап 2 — application.properties

Цель этапа — указать порт, URL SQLite и параметры JPA/Thymeleaf.

Spring Boot читает application.properties из classpath при старте. Свойства вида spring.datasource.* и spring.jpa.* участвуют в автоконфигурации DataSource и EntityManagerFactory — отдельный @Bean для подключения к БД писать не нужно (JPA).

Файл src/main/resources/application.properties:

spring.application.name=simple-crm

server.port=8080

spring.datasource.url=jdbc:sqlite:crm.db
spring.datasource.driver-class-name=org.sqlite.JDBC

spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false

spring.thymeleaf.cache=false

Разбор настроек

СвойствоЗначениеСмысл
spring.application.namesimple-crmимя в логах и Actuator (если подключите позже)
server.port8080HTTP-порт встроенного Tomcat; занят — смените на 8081
spring.datasource.urljdbc:sqlite:crm.dbотносительный путь: файл рядом с процессом JVM
spring.datasource.driver-class-nameorg.sqlite.JDBCкласс драйвера из sqlite-jdbc
spring.jpa.database-platformSQLiteDialectкак Hibernate генерирует DDL и SQL
spring.jpa.hibernate.ddl-autoupdateсоздать/дополнить таблицы по @Entity; не для prod без миграций
spring.jpa.show-sqlfalseпри true SQL виден в консоли — удобно на этапе 4
spring.thymeleaf.cachefalseв dev шаблоны не кешируются; в prod обычно true

Значения ddl-auto (справочно)

  • create-drop — пересоздаёт схему при каждом старте, удаляет при остановке (только тесты).
  • update — добавляет таблицы/колонки, не удаляет лишнее (учебный режим).
  • validate — только проверка схемы; для prod с Flyway/Liquibase.
SQLite в учебном проекте

Для production чаще PostgreSQL или MySQL — см. Testcontainers и работу с БД. SQLite здесь — нулевая настройка: один файл, без сервера. Ограничение — один писатель в момент времени; для CRM на десятках пользователей это не модель prod.


Этап 3 — CrmApplication.java

Цель этапа — точка входа и включение автонастройки Spring Boot.

Класс в корневом пакете com.example.crm — важно для component scan. Если бы CrmApplication лежал в com.example, а контроллеры в com.other.app, Spring их не увидел бы без @ComponentScan.

src/main/java/com/example/crm/CrmApplication.java:

package com.example.crm;

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

@SpringBootApplication
public class CrmApplication {

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

Разбор аннотации @SpringBootApplication

  • @Configuration — класс может объявлять @Bean (как DataInitializer на этапе 9).
  • @EnableAutoConfiguration — включает автонастройку по classpath (видит JPA → настраивает DataSource).
  • @ComponentScan — ищет @Service, @Controller, @Repository в текущем пакете и ниже.

Что делает SpringApplication.run

  1. Создаёт ApplicationContext — контейнер beans.
  2. Запускает автоконфигурацию (Tomcat, JPA, Thymeleaf).
  3. Поднимает встроенный Tomcat на server.port.
  4. Вызывает CommandLineRunner beans после готовности контекста.

На этом этапе приложение запустится, но ответит 404 на все URL — контроллеров ещё нет. В логе должны быть строки про Tomcat started on port 8080 и Started CrmApplication.

mvn spring-boot:run

Остановка — Ctrl+C. Первый запуск скачает зависимости Maven — это нормально (первая программа).


Этап 4 — сущность Customer

Цель этапа — описать таблицу customers и правила валидации полей.

JPA (Java Persistence API) — стандарт ORM на JVM. Hibernate — реализация, которую использует Spring Data JPA. Класс с @Entity — не просто POJO (ООП), а сущность, строка которой синхронизируется с таблицей.

Один класс Customer выполняет три роли:

  • модель БД — колонки через @Column;
  • модель формы — Thymeleaf th:field биндит поля;
  • модель JSON — Jackson сериализует в REST.

В production часто разделяют Entity и DTO; для учебного CRM допустимо объединение.

src/main/java/com/example/crm/model/Customer.java:

package com.example.crm.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

import java.time.LocalDateTime;

@Entity
@Table(name = "customers")
public class Customer {

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

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

@Email
@Size(max = 150)
@Column(length = 150)
private String email;

@Size(max = 30)
@Column(length = 30)
private String phone;

@Size(max = 100)
@Column(length = 100)
private String company;

@Size(max = 500)
@Column(length = 500)
private String notes;

@Column(nullable = false)
private LocalDateTime createdAt;

@Column(nullable = false)
private LocalDateTime updatedAt;

@PrePersist
void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
}

@PreUpdate
void onUpdate() {
updatedAt = LocalDateTime.now();
}

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public String getName() {
return name;
}

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

public String getEmail() {
return email;
}

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

public String getPhone() {
return phone;
}

public void setPhone(String phone) {
this.phone = phone;
}

public String getCompany() {
return company;
}

public void setCompany(String company) {
this.company = company;
}

public String getNotes() {
return notes;
}

public void setNotes(String notes) {
this.notes = notes;
}

public LocalDateTime getCreatedAt() {
return createdAt;
}

public LocalDateTime getUpdatedAt() {
return updatedAt;
}
}

Разбор полей и аннотаций

ЭлементНазначение
@Entityкласс участвует в persistence context Hibernate
@Table(name = "customers")имя таблицы; без аннотации было бы customer
@Id + @GeneratedValue(IDENTITY)первичный ключ; SQLite autoincrement
@NotBlank на nameпустая строка и пробелы — ошибка валидации
@Emailформат email; пустое значение допустимо (не @NotBlank)
@Size(max = …)ограничение длины; согласовано с @Column(length)
@Column(nullable = false)NOT NULL в DDL для name, timestamps
LocalDateTimejava.time; Hibernate 6 маппит в SQLite TEXT/NUMERIC
@PrePersist / @PreUpdateколбэки жизненного цикла entity перед INSERT/UPDATE

Почему геттеры и сеттеры обязательны

  • JPA/Hibernate — доступ к полям через свойства JavaBean (JavaBeans);
  • Thymeleaf th:field="*{name}" — вызывает getName / setName;
  • Jackson при десериализации JSON — setName, при сериализации — getName.

Поведение при старте

После перезапуска в рабочей директории появится crm.db. Hibernate выполнит CREATE TABLE IF NOT EXISTS customers (...). Таблица пока пуста — данные добавит DataInitializer на этапе 9.


Этап 5 — CustomerRepository

Цель этапа — слой доступа к данным без SQL вручную.

Spring Data JPA генерирует реализацию интерфейса в runtime — вы пишете только сигнатуру. Это сокращает шаблонный JDBC-код из работы с БД: Connection, PreparedStatement, маппинг ResultSet в объект.

src/main/java/com/example/crm/repository/CustomerRepository.java:

package com.example.crm.repository;

import com.example.crm.model.Customer;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface CustomerRepository extends JpaRepository<Customer, Long> {

List<Customer> findAllByOrderByNameAsc();
}

Разбор интерфейса

  • JpaRepository<Customer, Long> — generic: сущность Customer, тип id Long.
  • Унаследованные методы: save, findById, findAll, deleteById, existsById, count — готовы без объявления.
  • findAllByOrderByNameAscquery method; Spring Data парсит имя → ORDER BY name ASC (JPA).
  • Spring создаёт proxy-реализацию и регистрирует bean; @Service получит его через конструктор.

Конвенция имен query methods (фрагменты)

  • find…By… — выборка;
  • …OrderBy…Asc/Desc — сортировка;
  • countBy… — агрегация (используем count() из базового интерфейса).

Если имя метода некорректно — ошибка при старте контекста, не при первом запросе.


Этап 6 — CustomerService и исключение

Цель этапа — бизнес-логика CRUD и единая точка для веб- и REST-контроллеров.

Сервисный слой — место для правил предметной области: "нельзя создать клиента с чужим id", "обновление только существующей записи", единый CustomerNotFoundException. Контроллеры остаются тонкими — только HTTP и маппинг DTO/view (паттерн слоёв).

Сначала доменное исключение src/main/java/com/example/crm/service/CustomerNotFoundException.java:

package com.example.crm.service;

public class CustomerNotFoundException extends RuntimeException {

public CustomerNotFoundException(Long id) {
super("Customer not found: " + id);
}
}

Затем сервис src/main/java/com/example/crm/service/CustomerService.java:

package com.example.crm.service;

import com.example.crm.model.Customer;
import com.example.crm.repository.CustomerRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional
public class CustomerService {

private final CustomerRepository customerRepository;

public CustomerService(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}

@Transactional(readOnly = true)
public List<Customer> findAll() {
return customerRepository.findAllByOrderByNameAsc();
}

@Transactional(readOnly = true)
public Customer findById(Long id) {
return customerRepository.findById(id)
.orElseThrow(() -> new CustomerNotFoundException(id));
}

public Customer create(Customer customer) {
customer.setId(null);
return customerRepository.save(customer);
}

public Customer update(Long id, Customer updated) {
Customer existing = findById(id);
existing.setName(updated.getName());
existing.setEmail(updated.getEmail());
existing.setPhone(updated.getPhone());
existing.setCompany(updated.getCompany());
existing.setNotes(updated.getNotes());
return customerRepository.save(existing);
}

public void delete(Long id) {
if (!customerRepository.existsById(id)) {
throw new CustomerNotFoundException(id);
}
customerRepository.deleteById(id);
}
}

Разбор CustomerNotFoundException

  • Наследник RuntimeException — не обязан объявляться в throws (исключения).
  • Сообщение "Customer not found: " + id попадёт в JSON ответа на этапе 8.
  • Отдельный тип исключения позволяет @ExceptionHandler отличить 404 от других ошибок.

Разбор CustomerService

ЭлементЗачем
@Serviceстереотип @Component для бизнес-логики (аннотации Boot)
Конструктор с CustomerRepositoryconstructor injection — зависимости явные, поля final
@Transactional на классезапись по умолчанию в транзакции
@Transactional(readOnly = true) на чтенииhint Hibernate: без flush, оптимизация
orElseThrow в findByIdOptional → исключение вместо null
customer.setId(null) в createзащита от подстановки id из JSON/формы
update копирует поля в managed entitycreatedAt не затирается с формы

Поток update

  1. findById загружает строку из БД в persistence context.
  2. Сеттеры меняют поля managed-объекта.
  3. @PreUpdate обновляет updatedAt.
  4. При коммите транзакции Hibernate выполняет UPDATE.

Этап 7 — CustomerRestController

Цель этапа — REST API с JSON-телом и корректными HTTP-статусами.

REST (Representational State Transfer) здесь — ресурс /api/customers, операции через HTTP-методы, тело JSON. Клиент — curl, Postman, фронт на React или другой сервис (HTTP).

Контроллер не содержит SQL и не решает, что такое "клиент не найден" — только делегирует в CustomerService.

src/main/java/com/example/crm/controller/CustomerRestController.java:

package com.example.crm.controller;

import com.example.crm.model.Customer;
import com.example.crm.service.CustomerService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/customers")
public class CustomerRestController {

private final CustomerService customerService;

public CustomerRestController(CustomerService customerService) {
this.customerService = customerService;
}

@GetMapping
public List<Customer> getAll() {
return customerService.findAll();
}

@GetMapping("/{id}")
public Customer getById(@PathVariable Long id) {
return customerService.findById(id);
}

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Customer create(@Valid @RequestBody Customer customer) {
return customerService.create(customer);
}

@PutMapping("/{id}")
public Customer update(@PathVariable Long id, @Valid @RequestBody Customer customer) {
return customerService.update(id, customer);
}

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

Разбор маппингов

Метод HTTPПутьДействиеСтатус ответа
GET/api/customersсписок200 OK
GET/api/customers/{id}один клиент200 или 404 через advice
POST/api/customersсоздание201 Created
PUT/api/customers/{id}обновление200 OK
DELETE/api/customers/{id}удаление204 No Content

Разбор аннотаций

  • @RestController@Controller + @ResponseBody на каждом методе; возврат Customer → JSON в теле.
  • @RequestMapping("/api/customers") — общий префикс; версионирование API часто дают /api/v1/....
  • @PathVariable Long id — сегмент URL {id} → параметр метода.
  • @RequestBody — Jackson десериализует JSON в Customer.
  • @Valid — запускает Bean Validation до входа в метод; при ошибке — MethodArgumentNotValidException.
  • @ResponseStatus(HttpStatus.CREATED) — POST возвращает 201, не 200.

Пример тела JSON для POST/PUT

{
"name": "Иван Сидоров",
"email": "ivan@example.com",
"phone": "+7 900 000-00-00",
"company": "ООО Бета",
"notes": "Новый лид"
}

Поля id, createdAt, updatedAt можно не передавать при создании — сервис и JPA заполнят их сами.

Проверка после этапа 9 (когда есть демо-данные):

curl -s http://localhost:8080/api/customers

Этап 8 — RestExceptionHandler

Цель этапа — единый JSON при 404 и ошибках валидации вместо HTML Whitelabel (ошибки REST).

Без обработчика необработанное CustomerNotFoundException дало бы 500 Internal Server Error и HTML-страницу — неприемлемо для API-клиентов. @RestControllerAdvice перехватывает исключения из @RestController и формирует ResponseEntity с нужным статусом.

src/main/java/com/example/crm/config/RestExceptionHandler.java:

package com.example.crm.config;

import com.example.crm.service.CustomerNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;

@RestControllerAdvice
public class RestExceptionHandler {

@ExceptionHandler(CustomerNotFoundException.class)
public ResponseEntity<Map<String, String>> handleNotFound(CustomerNotFoundException ex) {
Map<String, String> body = Map.of("error", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
error -> error.getField(),
error -> error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid value",
(first, second) -> first,
LinkedHashMap::new
));

Map<String, Object> body = new LinkedHashMap<>();
body.put("error", "Validation failed");
body.put("fields", errors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
}

Разбор методов обработки

ОбработчикИсключениеHTTPТело ответа
handleNotFoundCustomerNotFoundException404{"error":"Customer not found: 99"}
handleValidationMethodArgumentNotValidException400error + fields с ошибками по полям

Детали реализации

  • Collectors.toMap собирает ошибки полей; merge-функция (first, second) -> first — на случай дубликатов ключей.
  • LinkedHashMap сохраняет порядок полей в JSON — удобно читать в логах.
  • Веб-контроллер не использует этот advice — там ошибки показываются в форме через BindingResult и th:errors.

Проверка 404

curl -s -w "\nHTTP %{http_code}\n" http://localhost:8080/api/customers/9999

Проверка 400 — POST с пустым именем:

curl -s -X POST http://localhost:8080/api/customers -H "Content-Type: application/json" -d "{\"name\":\"\"}"

Этап 9 — DataInitializer

Цель этапа — два демо-клиента при первом запуске, если таблица пуста.

Пустая таблица после этапа 4 неудобна для проверки UI. CommandLineRunner — функциональный интерфейс с методом run(String... args), вызываемым Spring после готовности контекста, но до приёма HTTP-запросов.

src/main/java/com/example/crm/config/DataInitializer.java:

package com.example.crm.config;

import com.example.crm.model.Customer;
import com.example.crm.repository.CustomerRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DataInitializer {

@Bean
CommandLineRunner seedCustomers(CustomerRepository customerRepository) {
return args -> {
if (customerRepository.count() > 0) {
return;
}

Customer alice = new Customer();
alice.setName("Алиса Иванова");
alice.setEmail("alice@example.com");
alice.setPhone("+7 900 111-22-33");
alice.setCompany("ООО Альфа");
alice.setNotes("Интересуется корпоративным тарифом");

Customer bob = new Customer();
bob.setName("Борис Петров");
bob.setEmail("boris@example.com");
bob.setPhone("+7 900 444-55-66");
bob.setCompany("ИП Петров");
bob.setNotes("Постоянный клиент");

customerRepository.save(alice);
customerRepository.save(bob);
};
}
}

Разбор DataInitializer

  • @Configuration — класс источник bean-определений.
  • @Bean CommandLineRunner — Spring регистрирует лямбду как bean; можно несколько runner'ов с @Order.
  • customerRepository.count() > 0 — идемпотентность: второй запуск не дублирует Алису и Бориса.
  • save вместо customerService.create — seed напрямую в репозиторий; для демо допустимо, в prod — через сервис.
  • @PrePersist на Customer проставит createdAt/updatedAt при insert.

Альтернативы seed-данных

  • data.sql в resources + spring.sql.init.mode=always — чистый SQL;
  • Flyway/Liquibase — миграции с INSERT для prod;
  • фикстуры только в @SpringBootTest (JUnit).

Перезапустите приложение и проверьте API:

curl -s http://localhost:8080/api/customers

В JSON должны быть два объекта — Алиса Иванова и Борис Петров.


Этап 10 — CustomerWebController

Цель этапа — HTML-маршруты, список, формы, redirect после POST.

Spring MVC для браузера — классический Model-View-Controller:

  • ModelCustomer, список customers в Model;
  • View — Thymeleaf list.html / form.html;
  • ControllerCustomerWebController, маппинг URL.

Отличие от REST в том же проекте: здесь @Controller, методы возвращают строку — имя view или redirect:..., а не JSON.

src/main/java/com/example/crm/controller/CustomerWebController.java:

package com.example.crm.controller;

import com.example.crm.model.Customer;
import com.example.crm.service.CustomerService;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Controller
public class CustomerWebController {

private final CustomerService customerService;

public CustomerWebController(CustomerService customerService) {
this.customerService = customerService;
}

@GetMapping("/")
public String home() {
return "redirect:/customers";
}

@GetMapping("/customers")
public String list(Model model) {
model.addAttribute("customers", customerService.findAll());
return "customers/list";
}

@GetMapping("/customers/new")
public String createForm(Model model) {
model.addAttribute("customer", new Customer());
model.addAttribute("formTitle", "Новый клиент");
model.addAttribute("formAction", "/customers");
return "customers/form";
}

@PostMapping("/customers")
public String create(@Valid @ModelAttribute("customer") Customer customer,
BindingResult bindingResult,
Model model,
RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
model.addAttribute("formTitle", "Новый клиент");
model.addAttribute("formAction", "/customers");
return "customers/form";
}
customerService.create(customer);
redirectAttributes.addFlashAttribute("message", "Клиент добавлен");
return "redirect:/customers";
}

@GetMapping("/customers/{id}/edit")
public String editForm(@PathVariable Long id, Model model) {
model.addAttribute("customer", customerService.findById(id));
model.addAttribute("formTitle", "Редактирование клиента");
model.addAttribute("formAction", "/customers/" + id);
return "customers/form";
}

@PostMapping("/customers/{id}")
public String update(@PathVariable Long id,
@Valid @ModelAttribute("customer") Customer customer,
BindingResult bindingResult,
Model model,
RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
model.addAttribute("formTitle", "Редактирование клиента");
model.addAttribute("formAction", "/customers/" + id);
return "customers/form";
}
customerService.update(id, customer);
redirectAttributes.addFlashAttribute("message", "Клиент обновлён");
return "redirect:/customers";
}

@PostMapping("/customers/{id}/delete")
public String delete(@PathVariable Long id, RedirectAttributes redirectAttributes) {
customerService.delete(id);
redirectAttributes.addFlashAttribute("message", "Клиент удалён");
return "redirect:/customers";
}
}

Разбор маршрутов

URLМетодМетод контроллераРезультат
/GEThome()redirect на /customers
/customersGETlist()таблица
/customers/newGETcreateForm()пустая форма
/customersPOSTcreate()сохранение или форма с ошибками
/customers/{id}/editGETeditForm()форма с данными
/customers/{id}POSTupdate()обновление
/customers/{id}/deletePOSTdelete()удаление

Разбор ключевых приёмов

  • Model model — контейнер атрибутов для Thymeleaf; addAttribute("customers", …)${customers} в HTML.
  • @ModelAttribute("customer") — биндинг полей формы к объекту Customer из request parameters.
  • BindingResult — сразу после @Valid модели; hasErrors() → вернуть форму без сохранения.
  • return "customers/form" — ViewResolver → templates/customers/form.html.
  • Post-Redirect-Getredirect:/customers после успеха; браузер делает GET, F5 не дублирует POST.
  • RedirectAttributes.addFlashAttribute("message", …) — flash в HTTP-сессии на один следующий запрос.
  • Удаление через POST — HTML <form method="post"> не умеет DELETE; REST-слой использует DELETE для API.

Почему два POST-маппинга не конфликтуют

  • POST /customers — создание (нет {id} в пути).
  • POST /customers/{id} — обновление существующего.
  • POST /customers/{id}/delete — отдельный суффикс /delete для удаления.

Этап 11 — шаблон list.html

Цель этапа — таблица клиентов, пустое состояние, кнопки "Изменить" и "Удалить".

Thymeleaf — серверный рендеринг: JVM собирает HTML и отдаёт готовую страницу. Браузер не вызывает API за JSON — данные уже в разметке. Для SPA позже останется только REST-слой (первая программа Spring — только JSON).

src/main/resources/templates/customers/list.html:

<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Клиенты — Simple CRM</title>
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
<header class="header">
<div class="container header-inner">
<a class="logo" th:href="@{/customers}">Simple CRM</a>
<nav class="nav">
<a th:href="@{/customers}" class="active">Клиенты</a>
<a th:href="@{/customers/new}" class="btn btn-primary btn-sm">+ Добавить</a>
</nav>
</div>
</header>

<main class="container main">
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>

<div class="page-header">
<h1>Клиенты</h1>
<p class="subtitle">Управление базой клиентов</p>
</div>

<div th:if="${#lists.isEmpty(customers)}" class="empty-state">
<p>Пока нет клиентов.</p>
<a th:href="@{/customers/new}" class="btn btn-primary">Добавить первого клиента</a>
</div>

<div th:unless="${#lists.isEmpty(customers)}" class="card">
<table class="table">
<thead>
<tr>
<th>Имя</th>
<th>Email</th>
<th>Телефон</th>
<th>Компания</th>
<th class="actions-col">Действия</th>
</tr>
</thead>
<tbody>
<tr th:each="customer : ${customers}">
<td>
<strong th:text="${customer.name}">Name</strong>
<div class="muted small" th:if="${customer.notes}" th:text="${customer.notes}"></div>
</td>
<td th:text="${customer.email ?: '—'}">email</td>
<td th:text="${customer.phone ?: '—'}">phone</td>
<td th:text="${customer.company ?: '—'}">company</td>
<td class="actions-col">
<a th:href="@{/customers/{id}/edit(id=${customer.id})}" class="btn btn-secondary btn-sm">Изменить</a>
<form th:action="@{/customers/{id}/delete(id=${customer.id})}" method="post" class="inline-form"
onsubmit="return confirm('Удалить клиента?');">
<button type="submit" class="btn btn-danger btn-sm">Удалить</button>
</form>
</td>
</tr>
</tbody>
</table>
</div>
</main>

<footer class="footer">
<div class="container">
REST API: <code>GET /api/customers</code>
</div>
</footer>
</body>
</html>

Разбор Thymeleaf по блокам

КонструкцияЧто делает
xmlns:th="http://www.thymeleaf.org"включает атрибуты th:* в HTML
th:href="@{/css/style.css}"URL статики с учётом context path
th:if="${message}"блок виден только при flash-сообщении
th:if="${#lists.isEmpty(customers)}"пустое состояние без записей
th:each="customer : ${customers}"итерация по списку из Model
th:text="${customer.name}"экранированный текст в ячейке
th:text="${customer.email ?: '—'}"Elvis — null/пусто → прочерк
@{/customers/{id}/edit(id=${customer.id})}URL с подстановкой id
<form method="post"> на удалениеотдельная форма на строку; onsubmit confirm

Natural templates

Внутри тегов оставлен fallback-текст (Name, email) — файл можно открыть в браузере без сервера и увидеть вёрстку; при рендере Thymeleaf подставит данные.

Откройте http://localhost:8080/customers — таблица с демо-клиентами (стили подключатся на этапе 13).


Этап 12 — шаблон form.html

Цель этапа — одна форма для создания и редактирования с привязкой полей и ошибками валидации.

Контроллер передаёт formTitle и formAction — шаблон универсален. При создании action = /customers, при редактировании = /customers/3. Это уменьшает дублирование разметки по сравнению с двумя отдельными .html.

src/main/resources/templates/customers/form.html:

<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="${formTitle + ' — Simple CRM'}">Форма — Simple CRM</title>
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
<header class="header">
<div class="container header-inner">
<a class="logo" th:href="@{/customers}">Simple CRM</a>
<nav class="nav">
<a th:href="@{/customers}">Клиенты</a>
<a th:href="@{/customers/new}" class="btn btn-primary btn-sm">+ Добавить</a>
</nav>
</div>
</header>

<main class="container main">
<div class="page-header">
<h1 th:text="${formTitle}">Форма</h1>
<a th:href="@{/customers}" class="link-back">← Назад к списку</a>
</div>

<div class="card form-card">
<form th:action="${formAction}" th:object="${customer}" method="post">
<input type="hidden" th:field="*{id}">

<div class="form-group">
<label for="name">Имя *</label>
<input id="name" type="text" th:field="*{name}" class="form-control" autofocus>
<span class="error" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span>
</div>

<div class="form-row">
<div class="form-group">
<label for="email">Email</label>
<input id="email" type="email" th:field="*{email}" class="form-control">
<span class="error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></span>
</div>
<div class="form-group">
<label for="phone">Телефон</label>
<input id="phone" type="text" th:field="*{phone}" class="form-control">
</div>
</div>

<div class="form-group">
<label for="company">Компания</label>
<input id="company" type="text" th:field="*{company}" class="form-control">
</div>

<div class="form-group">
<label for="notes">Заметки</label>
<textarea id="notes" th:field="*{notes}" class="form-control" rows="4"></textarea>
</div>

<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<a th:href="@{/customers}" class="btn btn-secondary">Отмена</a>
</div>
</form>
</div>
</main>

<footer class="footer">
<div class="container">
REST API: <code>/api/customers</code>
</div>
</footer>
</body>
</html>

Разбор формы

  • th:object="${customer}" — выбранный объект для *{…} селекторов.
  • th:field="*{name}" — генерирует name="name", id, value из свойства; двусторонний биндинг при POST.
  • <input type="hidden" th:field="*{id}"> — при edit передаёт id; при create поле пустое.
  • th:errors="*{name}" — вывод сообщения Bean Validation рядом с полем.
  • #fields.hasErrors('name') — условие показа блока ошибки.

Сценарий валидации в браузере

  1. Пользователь отправляет форму с пустым именем.
  2. @Valid + @NotBlankBindingResult.hasErrors() == true.
  3. Контроллер снова возвращает customers/form без redirect.
  4. Thymeleaf показывает красный текст ошибки у поля "Имя".

Проверьте http://localhost:8080/customers/new — пустое имя должно оставить вас на форме с сообщением об ошибке.


Этап 13 — style.css

Цель этапа — оформление списка, формы и адаптивная таблица на узких экранах.

Spring Boot по соглашению раздаёт src/main/resources/static как корень веб-статики. Ссылка th:href="@{/css/style.css}" в шаблоне → файл static/css/style.css. Отдельный WebMvcConfigurer не нужен.

src/main/resources/static/css/style.css:

:root {
--bg: #f4f6fb;
--surface: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--danger: #dc2626;
--danger-hover: #b91c1c;
--border: #e5e7eb;
--success-bg: #ecfdf5;
--success-text: #047857;
--shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}

* {
box-sizing: border-box;
}

body {
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}

.container {
width: min(1100px, calc(100% - 2rem));
margin: 0 auto;
}

.header {
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}

.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 64px;
}

.logo {
font-size: 1.25rem;
font-weight: 700;
color: var(--text);
text-decoration: none;
}

.nav {
display: flex;
align-items: center;
gap: 1rem;
}

.nav a {
color: var(--muted);
text-decoration: none;
}

.nav a.active,
.nav a:hover {
color: var(--primary);
}

.main {
padding: 2rem 0 3rem;
}

.footer {
padding: 1.5rem 0 2rem;
color: var(--muted);
font-size: 0.9rem;
}

.page-header {
margin-bottom: 1.5rem;
}

.page-header h1 {
margin: 0 0 0.25rem;
font-size: 2rem;
}

.subtitle {
margin: 0;
color: var(--muted);
}

.link-back {
color: var(--primary);
text-decoration: none;
}

.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow);
overflow: hidden;
}

.form-card {
padding: 1.5rem;
max-width: 720px;
}

.table {
width: 100%;
border-collapse: collapse;
}

.table th,
.table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: top;
}

.table th {
background: #f9fafb;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}

.actions-col {
width: 220px;
white-space: nowrap;
}

.inline-form {
display: inline;
}

.btn {
display: inline-block;
border: none;
border-radius: 10px;
padding: 0.65rem 1rem;
font-size: 0.95rem;
cursor: pointer;
text-decoration: none;
transition: background 0.15s ease;
}

.btn-sm {
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
}

.btn-primary {
background: var(--primary);
color: white;
}

.btn-primary:hover {
background: var(--primary-hover);
}

.btn-secondary {
background: #eef2ff;
color: #3730a3;
}

.btn-danger {
background: #fee2e2;
color: var(--danger);
}

.btn-danger:hover {
background: #fecaca;
}

.alert {
padding: 0.875rem 1rem;
border-radius: 12px;
margin-bottom: 1rem;
}

.alert-success {
background: var(--success-bg);
color: var(--success-text);
border: 1px solid #a7f3d0;
}

.empty-state {
text-align: center;
padding: 3rem 1rem;
background: var(--surface);
border: 1px dashed var(--border);
border-radius: 16px;
}

.form-group {
margin-bottom: 1rem;
}

.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}

label {
display: block;
margin-bottom: 0.35rem;
font-weight: 600;
}

.form-control {
width: 100%;
padding: 0.75rem 0.875rem;
border: 1px solid var(--border);
border-radius: 10px;
font: inherit;
background: white;
}

.form-control:focus {
outline: 2px solid rgba(37, 99, 235, 0.25);
border-color: var(--primary);
}

.form-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
}

.error {
display: block;
margin-top: 0.35rem;
color: var(--danger);
font-size: 0.875rem;
}

.muted {
color: var(--muted);
}

.small {
font-size: 0.85rem;
}

code {
background: #eef2ff;
padding: 0.15rem 0.4rem;
border-radius: 6px;
}

@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}

.table,
.table thead,
.table tbody,
.table th,
.table td,
.table tr {
display: block;
}

.table thead {
display: none;
}

.table tr {
border-bottom: 1px solid var(--border);
padding: 1rem;
}

.table td {
border: none;
padding: 0.35rem 0;
}

.actions-col {
width: auto;
margin-top: 0.75rem;
}
}

Разбор CSS (основные идеи)

  • CSS-переменные в :root — единая палитра; смена --primary перекрашивает кнопки.
  • .card + .table — карточка-контейнер для таблицы клиентов.
  • .btn-primary / .btn-danger — визуальная иерархия действий.
  • .alert-success — зелёный блок для flash message.
  • @media (max-width: 768px) — таблица превращается в карточки строк на мобильном.

Проверьте прямую ссылку http://localhost:8080/css/style.css — должен вернуться текст CSS, не 404.


Этап 14 — запуск и проверка

Цель этапа — убедиться, что JAR собирается, веб-интерфейс и REST работают вместе, а сценарии CRUD проходят с обеих сторон.

Полный цикл сборки и старта:

mvn clean package
mvn spring-boot:run

Приложение: http://localhost:8080/ → редирект на /customers.

Чек-лист веб-интерфейса

  1. /customers — таблица с Алисой и Борисом (демо-данные из DataInitializer).
  2. Кнопка "+ Добавить" → форма; пустое имя → ошибка валидации у поля, остаётесь на форме.
  3. Новый клиент → зелёное сообщение "Клиент добавлен", строка появляется в таблице.
  4. "Изменить" → правка полей → "Клиент обновлён".
  5. "Удалить" → confirm в браузере → "Клиент удалён", строка исчезает.
  6. F5 на списке после redirect — запись не дублируется (Post-Redirect-Get).
  7. Пустая БД (удалите crm.db и перезапустите) → блок "Пока нет клиентов" с кнопкой добавления.

Чек-лист REST API

curl -s http://localhost:8080/api/customers
curl -s http://localhost:8080/api/customers/1

Создание клиента:

curl -X POST http://localhost:8080/api/customers ^
-H "Content-Type: application/json" ^
-d "{\"name\":\"Иван Сидоров\",\"email\":\"ivan@example.com\",\"phone\":\"+7 900 000-00-00\",\"company\":\"ООО Бета\",\"notes\":\"Новый лид\"}"

На Linux/macOS замените ^ на \ для переноса строк.

Обновление (подставьте свой id):

curl -X PUT http://localhost:8080/api/customers/3 ^
-H "Content-Type: application/json" ^
-d "{\"name\":\"Иван Сидоров\",\"email\":\"ivan@example.com\",\"phone\":\"+7 900 000-00-01\",\"company\":\"ООО Бета\",\"notes\":\"Обновлено\"}"

Удаление:

curl -X DELETE http://localhost:8080/api/customers/3

Ошибка 404:

curl -s http://localhost:8080/api/customers/9999

Запуск JAR без Maven

java -jar target/simple-crm-1.0.0.jar

Файл crm.db создаётся в каталоге, откуда запущен процесс — не внутри JAR. Для переноса на другой компьютер копируют и JAR, и crm.db (или настраивают внешнюю БД).

Ожидаемые фрагменты ответов

GET /api/customers — массив JSON, у каждого объекта есть id, name, email, createdAt (ISO-8601).

POST с пустым name — HTTP 400, тело вида:

{
"error": "Validation failed",
"fields": {
"name": "must not be blank"
}
}

DELETE успешный — HTTP 204, тело пустое.

Отладка в IDE

Импортируйте Maven-проект в IntelliJ IDEA, запустите CrmApplication и поставьте breakpoint в CustomerService.create(). Общие приёмы — Отладка Java-кода в IDE.


Частые ошибки

СимптомВероятная причинаЧто проверить
Failed to configure a DataSourceнет драйвера или неверный JDBC URLsqlite-jdbc, application.properties
SQLiteDialect not foundнет hibernate-community-dialectspom.xml, этап 1
Port 8080 was already in useпорт занят другим процессомостановите старый spring-boot:run, смените server.port
404 на /customersнет шаблона или опечатка в returntemplates/customers/list.html, строка customers/list
Whitelabel Error Page на RESTконтроллер вне component scanпакет com.example.crm.*, главный класс в корне
Пустой список без демо-данныхcrm.db уже существовал пустымудалить crm.db, перезапустить
Демо-клиенты дублируютсяубрали проверку count() > 0DataInitializer, этап 9
Стили не грузятсяфайл не в static/src/main/resources/static/css/style.css
Validation failed в JSONпустое name или неверный emailтело запроса, аннотации на Customer
Форма без ошибок при пустом именинет @Valid или BindingResult не рядом с модельюCustomerWebController.create
crm.db не там, где ждётеотносительный путь SQLiteкаталог запуска java -jar = место файла БД
Кракозябры в curl Windowsкодировка консолиUTF-8 в терминале или Postman

Куда развивать дальше

Собранный каркас — база для типичного backend на Spring:

  • PostgreSQL + Testcontainers — реальная СУБД и интеграционные тесты без ручного Docker (273, SQLite → PostgreSQL).
  • Spring Security — Basic Auth, роли, закрытие /api (272, безопасность в prod).
  • JWT — stateless API для SPA и мобильных клиентов (274).
  • DTO и MapStruct — не отдавать JPA-сущность напрямую; скрыть createdAt или внутренние поля.
  • Пагинация и поискPageable, findByNameContainingIgnoreCase в репозитории.
  • OpenAPI / springdoc — интерактивная документация Swagger UI для /api/customers.
  • MockMvc-тесты@WebMvcTest для контроллеров, @DataJpaTest для репозитория (JUnit).
  • Фронт на React/Vue — оставить REST, UI вынести в отдельный проект; CORS и JWT по мере необходимости.

Связанные статьи

Spring и веб

Данные и ошибки

База Java и инструменты

Смежные практикумы и HTTP


Spring Boot и веб на JavaПервая программа на Spring, Практикум Simple CRM, Spring Security, Hibernate и JPA, Ошибки REST.