🏗 Архитектура слоёв + Spring Core
Цель урока
Понять, почему логика НЕ в Controller, что такое Bean, IoC, DI, и разложить TaskFlow по слоям: controller → service → repository.
Теория · для собеса
1 Слои Spring Boot-приложения ▸
HTTP request
↓
[Filter chain] ← логирование, безопасность, JWT
↓
[DispatcherServlet] ← «маршрутизатор» Spring MVC
↓
[Controller] ← @RestController, принимает запрос
↓
[Service] ← @Service, бизнес-логика
↓
[Repository] ← @Repository, доступ к БД
↓
[Database]
Почему нельзя писать всю логику в Controller:
- Controller = «входная дверь». Должен быть тонким.
- Если вся логика в нём — его невозможно переиспользовать (другой endpoint, scheduler, Kafka consumer, тест).
- Service-слой не знает про HTTP → его можно вызвать откуда угодно.
- Service легко покрыть unit-тестами (Controller требует MockMvc / WebMvcTest).
2 Что такое Bean, IoC, DI ▸
Bean — это объект, который создаёт и хранит IoC-контейнер Spring.
IoC (Inversion of Control) — принцип: не ты создаёшь объекты через new, а контейнер Spring создаёт их за тебя и сам подсовывает куда нужно.
DI (Dependency Injection) — способ, которым Spring «подсовывает» зависимости:
@Service
public class TaskService {
private final TaskRepository repo;
// Constructor injection — Spring ВЫЗЫВАЕТ этот конструктор
public TaskService(TaskRepository repo) {
this.repo = repo;
}
}
🧠 Аналогия: обычный код — ты сам идёшь на склад, ищешь инструмент, несёшь в цех. Spring IoC — склад сам привозит инструменты к тебе в цех, когда ты начал работу.
3 3 способа инъекции (constructor — лучший) ▸
Правило: всегда constructor injection. Поля помечай final.
4 Аннотации Spring (минимум) ▸
обобщённый класс, создаётся контейнером
то же + семантика «бизнес-логика»
то же + семантика «доступ к БД» (+ перехват исключений JPA)
@Controller + @ResponseBody на каждом методе (возвращает данные)
источник бинов (через @Bean-методы)
= @Configuration + @EnableAutoConfiguration + @ComponentScan
5 Что делает @ComponentScan? ▸
Сканирует пакет, где лежит @SpringBootApplication, и все его подпакеты. Все классы с @Component / @Service / @Repository / @RestController / @Configuration внутри становятся бинами.
TaskService лежит в com.taskflow.service, а @SpringBootApplication в com.taskflow — всё ОК (подпакет). Если класс лежит в com.example.service (параллельная ветка) — Spring его не увидит.🗺 Аналогия: @ComponentScan — это «смотри в папкуcom.taskflowи всё, что внутри». Всё, что лежит в соседней папкеcom.example— не его территория.
Практика: разносим урок 1 по слоям
-
1Структура после рефакторинга
src/main/java/com/taskflow/ ├── TaskFlowApplication.java ├── controller/ │ └── TaskController.java # ТОЛЬКО HTTP ├── service/ │ └── TaskService.java # бизнес-логика ├── repository/ │ └── TaskRepository.java # хранение (пока List) ├── model/ │ └── Task.java # record с полями └── dto/ └── TaskRequest.java # record для входящих -
2model/Task.java · record
package com.taskflow.model; public record Task( Long id, String title, String description, boolean done ) {} -
3repository/TaskRepository.java · in-memory
package com.taskflow.repository; import com.taskflow.model.Task; import org.springframework.stereotype.Repository; import java.util.*; import java.util.concurrent.atomic.AtomicLong; @Repository public class TaskRepository { private final List<Task> tasks = new ArrayList<>(); private final AtomicLong idGen = new AtomicLong(1); public List<Task> findAll() { return List.copyOf(tasks); } public Optional<Task> findById(Long id) { return tasks.stream().filter(t -> t.id().equals(id)).findFirst(); } public Task save(Task task) { if (task.id() == null) { Task t = new Task(idGen.getAndIncrement(), task.title(), task.description(), task.done()); tasks.add(t); return t; } tasks.removeIf(t -> t.id().equals(task.id())); tasks.add(task); return task; } public void deleteById(Long id) { tasks.removeIf(t -> t.id().equals(id)); } } -
4service/TaskService.java · бизнес-логика
package com.taskflow.service; import com.taskflow.model.Task; import com.taskflow.repository.TaskRepository; import org.springframework.stereotype.Service; import java.util.*; @Service public class TaskService { private final TaskRepository repo; public TaskService(TaskRepository repo) { // constructor injection this.repo = repo; } public List<Task> findAll() { return repo.findAll(); } public Optional<Task> findById(Long id) { return repo.findById(id); } public Task create(Task t) { return repo.save(t); } public Task update(Long id, Task t) { return repo.save(new Task(id, t.title(), t.description(), t.done())); } public void delete(Long id) { repo.deleteById(id); } } -
5dto/TaskRequest.java · что приходит снаружи
package com.taskflow.dto; public record TaskRequest(String title, String description, boolean done) {} -
6controller/TaskController.java · только HTTP
package com.taskflow.controller; import com.taskflow.dto.TaskRequest; import com.taskflow.model.Task; import com.taskflow.service.TaskService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/tasks") public class TaskController { private final TaskService service; public TaskController(TaskService service) { this.service = service; } @GetMapping public List<Task> findAll() { return service.findAll(); } @GetMapping("/{id}") public ResponseEntity<Task> findById(@PathVariable Long id) { return service.findById(id).map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } @PostMapping public Task create(@RequestBody TaskRequest req) { return service.create(new Task(null, req.title(), req.description(), req.done())); } @PutMapping("/{id}") public Task update(@PathVariable Long id, @RequestBody TaskRequest req) { return service.update(id, new Task(id, req.title(), req.description(), req.done())); } @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { service.delete(id); return ResponseEntity.noContent().build(); } } -
7Запуск и проверка
./gradlew bootRun # Создать $ curl -X POST http://localhost:8080/api/tasks \ -H "Content-Type: application/json" \ -d '{"title":"Buy milk","description":"2.5%","done":false}' {"id":1,"title":"Buy milk","description":"2.5%","done":false} # Получить все $ curl http://localhost:8080/api/tasks [{"id":1,"title":"Buy milk","description":"2.5%","done":false}]Логика разделена по слоям. 🎉
Зачем это на собесе
После урока ты должен уметь ответить на:
5 вопросов на углубление
0 / 5 выбраноРаскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.
TaskService есть зависимость от TaskRepository, но в конструкторе не указан @Autowired?С Spring 4.3+ аннотация @Autowired на единственном конструкторе необязательна. Что будет, если конструкторов несколько? Когда @Autowired обязателен?
singleton (default) от prototype? Какой scope у Spring-бинов по умолчанию?Singleton — один экземпляр на весь контекст. Prototype — новый при каждом запросе. Что с lifecycle-методами (@PreDestroy) у prototype?
record подходит для Task, но НЕ подходит для @Entity в JPA?Record иммутабельный. JPA требует no-arg constructor + сеттеры. Что ещё нужно JPA, чего record дать не может?
A → B → A. Что бросает Spring? Когда @Lazy — лекарство, а когда — пластырь?
TaskRepository используется AtomicLong, а не long?Race condition в многопоточном коде. Что такое CAS на уровне CPU?
Готов идти дальше?
Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.
📖 Изучить выбранные вопросыСначала выбери хотя бы 1 вопрос на углубление
🚀 Перейти к Уроку 3Сначала пройди все секции и выбери хотя бы 1 вопрос
⬅️ Назад к Уроку 1