The post has been translated automatically. Original language: Russian
The classical approach and its problems
Most Spring Boot tutorials show the structure by layers:
com.company.project
├── controller
│ ├── UserController.java
│ ├── CourseController.java
│ └── EnrollmentController.java
├── service
│ ├── UserService.java
│ ├── CourseService.java
│ └── EnrollmentService.java
├── repository
│ ├── UserRepository.java
│ ├── CourseRepository.java
│ └── EnrollmentRepository.java
└── model
├── User.java
├── Course.java
└── Enrollment.javaIt works while the project is small. But when 20+ features appear, each of which affects the controller/service/repository, chaos begins.:
- To understand how the `Enrollment` feature works, you need to jump between four folders.
- There are 30 files in `service/` without a hint of how they are connected
- A change in one feature easily breaks another — there are no obvious boundaries
Our approach: by features
We organize the code by domain areas (features), not by technical layers.:
com.company.lms
├── course
│ ├── controller
│ │ ├── api
│ │ │ └── CourseApiV1.java Swagger, @PreAuthorize, URL mapping
│ │ └── CourseController.java implementation, delegation to the service only
│ ├── service
│ │ └── CourseService.java
│ ├── repository
│ │ ├── CourseRepository.java
│ │ └── specification
│ │ └── CourseSpecification.java
│ ├── model
│ │ ├── entity
│ │ │ └── Course.java
│ │ ├── request
│ │ │ └── CreateCourseRequest.java
│ │ ├── response
│ │ │ └── CourseResponse.java
│ │ └── filter
│ │ └── CoursesFilter.java
│ └── converter
│ └── CourseConverter.java
├── enrollment
│ └── ...
└── common
└── ...What it gives
1. Readability. Everything related to `Course` is in `course/'. A new developer opens a folder and immediately sees the full picture: the entity, API, service, and mappings.
2. Explicit boundaries. The change in `enrollment/` does not affect `course/` by accident. The dependencies between the features are explicit — if the `Enrollment Service` needs a `CourseService', this can be seen through import.
3. Easy to remove features. If you need to cut out a feature, delete the folder. With a layered structure, deletions are scattered throughout the project.
4. Parallel development. Two developers are working on `course` and `enrollment` — conflicts in git are minimal.
The API Interface + Controller pattern
One of the key patterns that we apply is that each controller has a separate API interface.
// CourseApiV1.java interface with documentation
@Tag(name = "Courses")
@RequestMapping(PathConstants.COURSES)
public interface CourseApiV1 {
@Operation(summary = "Get course list")
@ApiResponse(responseCode = "200", useReturnTypeSchema = true)
@PreAuthorize("hasAuthority(T(kz.mediators.api.security.model.enums.Permission).COURSE_VIEW)")
@GetMapping
Page<CourseResponse> getList(CoursesFilter filter, Pageable pageable);
@Operation(summary = "Create course")
@ApiResponse(responseCode = "201", useReturnTypeSchema = true)
@PreAuthorize("hasAuthority(T(kz.mediators.api.security.model.enums.Permission).COURSE_CREATE)")
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
CourseResponse create(@RequestBody @Valid CreateCourseRequest request);
}/ CourseController.java implementation, pure delegation
@RestController
@RequiredArgsConstructor
public class CourseController implements CourseApiV1 {
private final CourseService courseService;
@Override
public Page<CourseResponse> getList(CoursesFilter filter, Pageable pageable) {
return courseService.getList(filter, pageable);
}
@Override
public CourseResponse create(CreateCourseRequest request) {
return courseService.create(request);
}
}Why divide it? The interface contains all the "meta information" — Swagger, access rights, URL. The controller remains clean: it knows neither about the documentation nor about the rights, only that it needs to call the service. It's easy to read, easy to test.
Specification for filtering
Another pattern that we are not deviating from is the `Specification` for dynamic queries. Instead of:
// Antipattern logic of the request in
the public List<Course> getList(CoursesFilter filter) service {
if (filter.getTitle() != null) {
return repository.findByTitle(filter.getTitle());
} else if (filter.getCategoryId() != null) {
return repository.findByCategoryId(filter.getCategoryId());
}
// ...
}We use:
// CourseSpecification.java
@Component
public class CourseSpecification extends BaseSpecification<Course> {
public Specification<Course> build(CoursesFilter filter) {
return Specification
.where(titleLike(filter.getTitle()))
.and(categoryEquals(filter.getCategoryId()))
.and(statusIn(filter.getStatuses()));
}
private Specification<Course> titleLike(String title) {
return (root, query, cb) ->
title == null ? null : cb.like(cb.lower(root.get("title")), "%" + title.toLowerCase() + "%");
}
}The service remains clean:
public Page<CourseResponse> getList(CoursesFilter filter, Pageable pageable) {
Specification<Course> spec = courseSpecification.build(filter);
return courseRepository.findAll(spec, pageable)
.map(courseConverter::toResponse);
}Классический подход и его проблемы
Большинство туториалов по Spring Boot показывают структуру по слоям:
com.company.project
├── controller
│ ├── UserController.java
│ ├── CourseController.java
│ └── EnrollmentController.java
├── service
│ ├── UserService.java
│ ├── CourseService.java
│ └── EnrollmentService.java
├── repository
│ ├── UserRepository.java
│ ├── CourseRepository.java
│ └── EnrollmentRepository.java
└── model
├── User.java
├── Course.java
└── Enrollment.javaЭто работает, пока проект маленький. Но когда появляется 20+ фич, каждая из которых затрагивает controller/service/repository — начинается хаос:
- Чтобы понять, как работает фича `Enrollment`, нужно прыгать между четырьмя папками
- В `service/` лежат 30 файлов без намёка на то, как они связаны
- Изменение в одной фиче легко ломает другую — нет явных границ
Наш подход: по фичам
Мы организуем код по доменным областям (фичам), а не по техническим слоям:
com.company.lms
├── course
│ ├── controller
│ │ ├── api
│ │ │ └── CourseApiV1.java ← Swagger, @PreAuthorize, маппинги URL
│ │ └── CourseController.java ← реализация, только делегирование в сервис
│ ├── service
│ │ └── CourseService.java
│ ├── repository
│ │ ├── CourseRepository.java
│ │ └── specification
│ │ └── CourseSpecification.java
│ ├── model
│ │ ├── entity
│ │ │ └── Course.java
│ │ ├── request
│ │ │ └── CreateCourseRequest.java
│ │ ├── response
│ │ │ └── CourseResponse.java
│ │ └── filter
│ │ └── CoursesFilter.java
│ └── converter
│ └── CourseConverter.java
├── enrollment
│ └── ...
└── common
└── ...Что это даёт
1. Читаемость. Всё, что относится к `Course`, лежит в `course/`. Новый разработчик открывает папку — и сразу видит полную картину: сущность, API, сервис, маппинги.
2. Явные границы. Изменение в `enrollment/` не затрагивает `course/` случайно. Зависимости между фичами явные — если `EnrollmentService` нужен `CourseService`, это видно через import.
3. Легко удалять фичи. Если нужно выпилить фичу — удаляем папку. При слоевой структуре удаление раскидано по всему проекту.
4. Параллельная разработка. Два разработчика работают над `course` и `enrollment` — конфликты в git минимальны.
Паттерн API Interface + Controller
Один из ключевых паттернов, который мы применяем: каждый контроллер имеет отдельный интерфейс API.
// CourseApiV1.java — интерфейс с документацией
@Tag(name = "Courses")
@RequestMapping(PathConstants.COURSES)
public interface CourseApiV1 {
@Operation(summary = "Get course list")
@ApiResponse(responseCode = "200", useReturnTypeSchema = true)
@PreAuthorize("hasAuthority(T(kz.mediators.api.security.model.enums.Permission).COURSE_VIEW)")
@GetMapping
Page<CourseResponse> getList(CoursesFilter filter, Pageable pageable);
@Operation(summary = "Create course")
@ApiResponse(responseCode = "201", useReturnTypeSchema = true)
@PreAuthorize("hasAuthority(T(kz.mediators.api.security.model.enums.Permission).COURSE_CREATE)")
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
CourseResponse create(@RequestBody @Valid CreateCourseRequest request);
}/ CourseController.java — реализация, чистая делегация
@RestController
@RequiredArgsConstructor
public class CourseController implements CourseApiV1 {
private final CourseService courseService;
@Override
public Page<CourseResponse> getList(CoursesFilter filter, Pageable pageable) {
return courseService.getList(filter, pageable);
}
@Override
public CourseResponse create(CreateCourseRequest request) {
return courseService.create(request);
}
}Зачем разделять? В интерфейсе сосредоточена вся «метаинформация» — Swagger, права доступа, URL. Контроллер остаётся чистым: он не знает ни о документации, ни о правах — только о том, что нужно вызвать сервис. Читать легко, тестировать легко.
Specification для фильтрации
Ещё один паттерн, от которого мы не отступаем — `Specification` для динамических запросов. Вместо:
// Антипаттерн — логика запроса в сервисе
public List<Course> getList(CoursesFilter filter) {
if (filter.getTitle() != null) {
return repository.findByTitle(filter.getTitle());
} else if (filter.getCategoryId() != null) {
return repository.findByCategoryId(filter.getCategoryId());
}
// ...
}Мы используем:
// CourseSpecification.java
@Component
public class CourseSpecification extends BaseSpecification<Course> {
public Specification<Course> build(CoursesFilter filter) {
return Specification
.where(titleLike(filter.getTitle()))
.and(categoryEquals(filter.getCategoryId()))
.and(statusIn(filter.getStatuses()));
}
private Specification<Course> titleLike(String title) {
return (root, query, cb) ->
title == null ? null : cb.like(cb.lower(root.get("title")), "%" + title.toLowerCase() + "%");
}
}Сервис остаётся чистым:
public Page<CourseResponse> getList(CoursesFilter filter, Pageable pageable) {
Specification<Course> spec = courseSpecification.build(filter);
return courseRepository.findAll(spec, pageable)
.map(courseConverter::toResponse);
}