Практикум Spring Boot — Simple CRM
О практикуме
Соберём Simple CRM — учебное веб-приложение на Spring Boot 3 с SQLite, Thymeleaf и REST API. Пользователь сможет просматривать клиентов в браузере, добавлять и редактировать записи, удалять строки; параллельно тот же набор операций доступен через JSON-эндпоинты для интеграций.
CRM (Customer Relationship Management) в нашем случае — не корпоративный монолит, а компактный справочник клиентов с полями имя, email, телефон, компания и заметки. Такой сценарий встречается в реальных проектах как первый модуль внутренней админки или как учебный прототип перед подключением PostgreSQL и авторизации.
Стек проекта:
- Spring Boot 3.4 — встроенный Tomcat, автонастройка, один JAR (первая программа на Spring, обзор Spring);
- Spring Data JPA + Hibernate — ORM, сущности, репозитории без ручного SQL (JPA, работа с БД);
- SQLite — файловая БД
crm.db, без отдельного сервера СУБД (SQLite в разделе SQL); - Thymeleaf — серверный HTML, формы и таблица рендерятся на JVM;
- Bean Validation — декларативные правила
@NotBlank,@Emailна полях модели; - REST — CRUD по
/api/customers, JSON через Jackson, единый формат ошибок (ошибки REST, HTTP и интеграции).
Готовый код для сверки — 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.
В приложении четыре слоя:
| Слой | Роль в проекте | Файлы | Не должен знать о |
|---|---|---|---|
| Model | JPA-сущность, валидация полей | Customer.java | HTTP, URL, HTML |
| Repository | Чтение и запись в БД | CustomerRepository.java | бизнес-правила, JSON |
| Service | CRUD, транзакции, исключения | CustomerService.java | Thymeleaf, статусы HTTP |
| Controller | Маршруты HTML и JSON | CustomerWebController, CustomerRestController | SQL, детали Hibernate |
Веб-контроллер возвращает имя шаблона ("customers/list") — Spring MVC передаёт его в Thymeleaf. REST-контроллер возвращает объекты Java — Jackson сериализует их в 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-сервисам.
Предварительные знания:
- JDK 17+, Maven 3.9+ (первая программа на Java, структура и сборки);
- классы, коллекции, исключения (ООП, коллекции, исключения);
- желательно — первую программу на Spring Boot с простым REST без БД;
- для дат в сущности —
java.time(LocalDateTime).
Что получится
Полный набор CRUD для сущности "клиент" — в браузере и через API.
| Действие | Веб (браузер) | REST API | HTTP-метод REST |
|---|---|---|---|
| Список | /customers | /api/customers | GET |
| Один клиент | /customers/{id}/edit (форма) | /api/customers/{id} | GET |
| Создать | /customers/new → POST /customers | POST /api/customers | POST |
| Обновить | POST /customers/{id} | PUT /api/customers/{id} | PUT |
| Удалить | POST /customers/{id}/delete | DELETE /api/customers/{id} | DELETE |
Обратите внимание: в HTML удаление идёт через POST (ограничение форм в браузере), а в REST — через DELETE, как принято в API (HTTP-методы).
Карта этапов
| Этап | Фокус | Результат |
|---|---|---|
| 0 | Каркас Maven JAR | Пустая структура каталогов |
| 1 | pom.xml | Spring Boot, JPA, Thymeleaf, SQLite |
| 2 | application.properties | Подключение SQLite, порт 8080 |
| 3 | CrmApplication | Точка входа, @SpringBootApplication |
| 4 | Customer | JPA-сущность и валидация |
| 5 | CustomerRepository | Spring Data репозиторий |
| 6 | CustomerService | CRUD и транзакции |
| 7 | CustomerRestController | JSON API |
| 8 | RestExceptionHandler | 404 и ошибки валидации |
| 9 | DataInitializer | Демо-клиенты при первом запуске |
| 10 | CustomerWebController | Маршруты HTML |
| 11 | list.html | Таблица клиентов |
| 12 | form.html | Форма создания и редактирования |
| 13 | style.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.css→classpath:/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-web | Tomcat, Spring MVC, Jackson | REST + встроенный HTTP-сервер |
spring-boot-starter-data-jpa | Hibernate, JDBC, транзакции | Customer ↔ таблица customers |
spring-boot-starter-thymeleaf | Thymeleaf + интеграция с MVC | HTML-страницы списка и формы |
spring-boot-starter-validation | Hibernate Validator | @NotBlank на имени, @Email |
sqlite-jdbc | драйвер JDBC | файл crm.db без сервера БД |
hibernate-community-dialects | SQLiteDialect | Hibernate 6 не включает SQLite в core |
spring-boot-starter-test | JUnit 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.name | simple-crm | имя в логах и Actuator (если подключите позже) |
server.port | 8080 | HTTP-порт встроенного Tomcat; занят — смените на 8081 |
spring.datasource.url | jdbc:sqlite:crm.db | относительный путь: файл рядом с процессом JVM |
spring.datasource.driver-class-name | org.sqlite.JDBC | класс драйвера из sqlite-jdbc |
spring.jpa.database-platform | SQLiteDialect | как Hibernate генерирует DDL и SQL |
spring.jpa.hibernate.ddl-auto | update | создать/дополнить таблицы по @Entity; не для prod без миграций |
spring.jpa.show-sql | false | при true SQL виден в консоли — удобно на этапе 4 |
spring.thymeleaf.cache | false | в dev шаблоны не кешируются; в prod обычно true |
Значения ddl-auto (справочно)
create-drop— пересоздаёт схему при каждом старте, удаляет при остановке (только тесты).update— добавляет таблицы/колонки, не удаляет лишнее (учебный режим).validate— только проверка схемы; для prod с Flyway/Liquibase.
Для 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
- Создаёт ApplicationContext — контейнер beans.
- Запускает автоконфигурацию (Tomcat, JPA, Thymeleaf).
- Поднимает встроенный Tomcat на
server.port. - Вызывает
CommandLineRunnerbeans после готовности контекста.
На этом этапе приложение запустится, но ответит 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 |
LocalDateTime | java.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, тип idLong.- Унаследованные методы:
save,findById,findAll,deleteById,existsById,count— готовы без объявления. findAllByOrderByNameAsc— query 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) |
Конструктор с CustomerRepository | constructor injection — зависимости явные, поля final |
@Transactional на классе | запись по умолчанию в транзакции |
@Transactional(readOnly = true) на чтении | hint Hibernate: без flush, оптимизация |
orElseThrow в findById | Optional → исключение вместо null |
customer.setId(null) в create | защита от подстановки id из JSON/формы |
update копирует поля в managed entity | createdAt не затирается с формы |
Поток update
findByIdзагружает строку из БД в persistence context.- Сеттеры меняют поля managed-объекта.
@PreUpdateобновляетupdatedAt.- При коммите транзакции 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 | Тело ответа |
|---|---|---|---|
handleNotFound | CustomerNotFoundException | 404 | {"error":"Customer not found: 99"} |
handleValidation | MethodArgumentNotValidException | 400 | error + 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:
- Model —
Customer, списокcustomersвModel; - View — Thymeleaf
list.html/form.html; - Controller —
CustomerWebController, маппинг 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 | Метод | Метод контроллера | Результат |
|---|---|---|---|
/ | GET | home() | redirect на /customers |
/customers | GET | list() | таблица |
/customers/new | GET | createForm() | пустая форма |
/customers | POST | create() | сохранение или форма с ошибками |
/customers/{id}/edit | GET | editForm() | форма с данными |
/customers/{id} | POST | update() | обновление |
/customers/{id}/delete | POST | delete() | удаление |
Разбор ключевых приёмов
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-Get —
redirect:/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')— условие показа блока ошибки.
Сценарий валидации в браузере
- Пользователь отправляет форму с пустым именем.
@Valid+@NotBlank→BindingResult.hasErrors() == true.- Контроллер снова возвращает
customers/formбез redirect. - 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— зелёный блок для flashmessage.@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.
Чек-лист веб-интерфейса
/customers— таблица с Алисой и Борисом (демо-данные изDataInitializer).- Кнопка "+ Добавить" → форма; пустое имя → ошибка валидации у поля, остаётесь на форме.
- Новый клиент → зелёное сообщение "Клиент добавлен", строка появляется в таблице.
- "Изменить" → правка полей → "Клиент обновлён".
- "Удалить" →
confirmв браузере → "Клиент удалён", строка исчезает. - F5 на списке после redirect — запись не дублируется (Post-Redirect-Get).
- Пустая БД (удалите
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, тело пустое.
Импортируйте Maven-проект в IntelliJ IDEA, запустите CrmApplication и поставьте breakpoint в CustomerService.create(). Общие приёмы — Отладка Java-кода в IDE.
Частые ошибки
| Симптом | Вероятная причина | Что проверить |
|---|---|---|
Failed to configure a DataSource | нет драйвера или неверный JDBC URL | sqlite-jdbc, application.properties |
SQLiteDialect not found | нет hibernate-community-dialects | pom.xml, этап 1 |
Port 8080 was already in use | порт занят другим процессом | остановите старый spring-boot:run, смените server.port |
404 на /customers | нет шаблона или опечатка в return | templates/customers/list.html, строка customers/list |
| Whitelabel Error Page на REST | контроллер вне component scan | пакет com.example.crm.*, главный класс в корне |
| Пустой список без демо-данных | crm.db уже существовал пустым | удалить crm.db, перезапустить |
| Демо-клиенты дублируются | убрали проверку count() > 0 | DataInitializer, этап 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 и веб
- Первая программа на Spring Framework — DI, минимальный REST,
mvn spring-boot:run - Spring Framework — обзор — модули экосистемы
- Аннотации Spring Boot —
@SpringBootApplication, стереотипы слоёв
Данные и ошибки
- Hibernate и JPA — практический старт — сущности, репозитории, транзакции
- Работа с БД в Java — JDBC и связка с JPA
- Ошибки REST — @Valid и @ControllerAdvice — единый формат ошибок API
База Java и инструменты
- Структура и сборки Java-проектов — Maven, JAR, каталоги
- Первая программа на Java — JDK, Maven, IDE
- ООП в Java — классы, инкапсуляция, JavaBean-свойства
- Исключения в Java — checked vs unchecked,
RuntimeException java.time—LocalDateTimeв сущности- Отладка Java-кода в IDE — breakpoint в
CustomerService
Смежные практикумы и HTTP
- Практикум JSF — "Список задач" — серверный UI другим стеком (legacy)
- HTTP как основа веб-интеграций — методы, статусы, тело запроса
Spring Boot и веб на Java — Первая программа на Spring, Практикум Simple CRM, Spring Security, Hibernate и JPA, Ошибки REST.