Ещё несколько месяцев назад я начал делать новый проект, который в теории должен быть совсем не сложным: обычный To-Do (или трекер активности) с не совсем обычной логикой. Этому приложению и будет посвящён этот цикл статей.
Я уже почти сделал всю логику и даже перешёл на создание фронта, как понял, что мне не нравится хаотичное размещение классов в бэке. Как любитель всяких старых технологий, я хотел попробовать доставать данные из xml-файлов несколькими способами (следующие статьи будут посвящены этим), а потом с лёгкостью переключиться на вариант с базой данных.
Но в моём проекте каким-то образом получилось, что сервисы не только обрабатывают данные, чтобы те отвечали бизнес-логике, но и почему-то знают о том, откуда мы берём данные. Если я захотел бы сменить способ хранения данных, пришлось бы переписывать и сервисы, и модели. А это неправильно.
Поэтому я решил подробнее разобраться с архитектурой и поделиться своими открытиями.
Классика на сервлетах: Jsp + Servlet + DAO (Hibernate)
Так как проект на работе и некоторые мои петы написаны на сервлетах, решил начать именно с этого варианта.
Здесь имеет смысл разбить проект на несколько уровней:
Домен (Domain). Содержит модели, которые описывают нашу предметную область (чистые POJO без аннотаций Hibernate/Jackson).
Данные (Persistence).
- Entity. Отражают структуру хранения (как в таблицах).
- DAO. Доступ к данным (Hibernate/JPA, транзакции).
- Маппинг: Entity ↔ Model (внутри persistence-слоя).
Сервисы. Уровень сервисов работает уже с моделями и реализует бизнес-логику и не зависит от способа хранения данных.
Веб. Здесь размещаются сервлеты, которые собирают DTO (data transfer object), если такие есть, проводят валидацию и отправляют редиректы на jsp-страницы. Они же обрабатывают request и работают с сессией (объект HttpSession).
Фронт. В моём проекте jsp-страницы вынесен в отдельный пакет, но по логике не будет страшно, если они окажутся в вебе недалеко от сервлетов.
Таким образом, данные из базы преобразуются несколько раз. Сначала они достаются в виде сущности (Entity), потом маппятся в модель (уходим в домен), а потом по необходимости превращаются в DTO для передачи во фронт. В обратную сторону это тоже работает в том же порядке.
Важно помнить, что сервлеты и сервисы не знают ничего о сущностях в базе.
Пример структуры java-проекта с пакетами:
com.app
├─ domain/ // модели
├─ persistence/
│ ├─ entity/ // сущности
│ └─ dao/ // hibernate
├─ service/ // бизнес-логика
├─ dto/ // DTO и мапперы
└─ web/
├─ servlet/ // сервлеты
└─ jsp/ // /WEB-INF/views/*.jsp
REST-архитектура: Spring (Boot) + фронт на JS
Теперь рассмотрим более современный вариант со спрингом и REST-архитектурой. Слои будут почти такими же:
Домен (Domain). Здесь размещаются чистые модели и интерфейсы репозиториев (порты), которые задают контракт – что мы можем делать с данными (например, взять все экземпляры или найти один по какому-нибудь из его свойств).
Сервисы (Application/Use-cases). Бизнес-логика, которая опять же работает только с моделями и не подозревает об инфраструктуре и как хранятся данные в хранилище.
Инфраструктура (Infrastructure/Adapters).
- JPA-сущности (Entity) — как хранится в БД.
- Реализации портов (адаптеры): Spring Data JPA/JDBC/файлы и т.п.
- Маппинг Entity ↔ Domain внутри адаптеров.
Веб (REST). Описываем рест-контроллеры, которые общаются с сервисами, проводят валидацию данных и высылает JSON-данные или статусы.
Frontend (JS). Общается с бэеком только по REST/GraphQL, рисует данные в нужном виде.
Пример структуры java-проекта с пакетами:
com.app
├─ domain/
│ ├─ model/
│ └─ ports/ // интерфейсы репозиториев
├─ application/
│ ├─ service/ // бизнес-логика
│ ├─ dto/ // Request/Response DTO
│ └─ mapper/ // MapStruct/ручные мапперы
├─ infrastructure/
│ ├─ jpa/
│ │ ├─ entity/ // сущности, как в базе
│ │ └─ repo/ // Spring Data + адаптеры под domain.ports
│ └─ external/ // REST/Kafka/S3 клиенты
└─ web/
├─ rest/ // контроллеры
└─ advice/ // ExceptionHandler — обработка исключений
(frontend отдельно: /frontend)
Пример кода для REST-архитектуры
Чтобы ситуация стала немного понятнее и менее теоретической, предлагаю рассмотреть пример кода. Допустим, у нас есть объект, у которого ничего кроме имени нету (ну и id, естественно).
Начнём с домена.
// domain/model/User.java
package com.example.demo.domain.model;
public record User(Long id, String name) {}
// domain/ports/UserRepository.java
package com.example.demo.domain.ports;
import com.example.demo.domain.model.User;
import java.util.*;
public interface UserRepository {
List<User> findAll();
Optional<User> findById(Long id);
User save(User user);
}
Здесь мы описали поля модели и операции, которые можно с ними проводить: найти всех юзеров, найти одного по Id (Optional на случай, если ничего не найдётся) и сохранить нового юзера.
Далее уровень сервиса.
package com.example.demo.application.service;
import com.example.demo.domain.model.User;
import com.example.demo.domain.ports.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class UserService {
private final UserRepository repo;
public UserService(UserRepository repo) { this.repo = repo; }
@Transactional(readOnly = true)
public List<User> list() { return repo.findAll(); }
@Transactional(readOnly = true)
public User get(Long id) { return repo.findById(id).orElseThrow(); }
@Transactional
public User create(String name) { return repo.save(new User(null, name)); }
}
Здесь обращаемся к методам репозитория, не зная, какая именно у него будет реализация.
Далее уровень инфраструктуры.
// infrastructure/jpa/entity/UserEntity.java
package com.example.demo.infrastructure.jpa.entity;
import jakarta.persistence.*;
@Entity @Table(name = "users")
public class UserEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
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; }
}
// infrastructure/jpa/springrepo/UserJpaRepo.java
package com.example.demo.infrastructure.jpa.springrepo;
import com.example.demo.infrastructure.jpa.entity.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserJpaRepo extends JpaRepository<UserEntity, Long> {}
// infrastructure/jpa/adapter/UserRepositoryJpaAdapter.java
package com.example.demo.infrastructure.jpa.adapter;
import com.example.demo.domain.model.User;
import com.example.demo.domain.ports.UserRepository;
import com.example.demo.infrastructure.jpa.entity.UserEntity;
import com.example.demo.infrastructure.jpa.springrepo.UserJpaRepo;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class UserRepositoryJpaAdapter implements UserRepository {
private final UserJpaRepo jpa;
public UserRepositoryJpaAdapter(UserJpaRepo jpa) { this.jpa = jpa; }
private static User toDomain(UserEntity e) { return new User(e.getId(), e.getName()); }
private static UserEntity toEntity(User d) {
var e = new UserEntity();
e.setId(d.id());
e.setName(d.name());
return e;
}
@Override public Optional<User> findById(Long id) {
// ТУТ фактически идёт запрос в БД:
// jpa.findById(...) -> SimpleJpaRepository -> EntityManager.find(...)
return jpa.findById(id).map(UserRepositoryJpaAdapter::toDomain);
}
@Override public List<User> findAll() {
// ТУТ тоже — jpa.findAll() -> SELECT * FROM users ...
return jpa.findAll().stream().map(UserRepositoryJpaAdapter::toDomain).toList();
}
@Override public User save(User user) {
// jpa.save(...) -> persist/merge через EntityManager
var saved = jpa.save(toEntity(user));
return toDomain(saved);
}
}
Spring Data генерирует реализацию UserJpaRepo (прокси), которая делегирует в SimpleJpaRepository, а тот – в EntityManager. DataSource/EntityManager берутся из application.yml и @EnableJpaRepositories/автоконфигурации.
С этим моментом я ещё не совсем сам разобрался, и пока принимаю это всё как аксиому.
И, наконец, уровень Веб с контроллером и DTO.
// web/dto/CreateUserRequest.java
package com.example.demo.web.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateUserRequest(@NotBlank String name) {}
// web/dto/UserResponse.java
package com.example.demo.web.dto;
public record UserResponse(Long id, String name) {}
// web/rest/UserController.java
package com.example.demo.web.rest;
import com.example.demo.application.service.UserService;
import com.example.demo.web.dto.CreateUserRequest;
import com.example.demo.web.dto.UserResponse;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService service;
public UserController(UserService service) { this.service = service; }
@GetMapping
public List<UserResponse> list() {
return service.list().stream().map(u -> new UserResponse(u.id(), u.name())).toList();
}
@GetMapping("/{id}")
public UserResponse get(@PathVariable Long id) {
var u = service.get(id);
return new UserResponse(u.id(), u.name());
}
@PostMapping
public UserResponse create(@RequestBody @Valid CreateUserRequest req) {
var u = service.create(req.name());
return new UserResponse(u.id(), u.name());
}
}
Как это всё связывается?
- Контроллер вызывает сервис.
- Сервис работает с портом UserRepository.
- Spring по активному профилю подставляет реализацию порта – здесь UserRepositoryJpaAdapter, который внутри использует JpaRepository.
- Адаптер маппит Entity ↔ Domain.
Ключевые рекомендации
Что самое главное? Разделение обязанностей, которое позволит легко масштабировать приложение или менять технологии в отдельных слоях.
Контроллеры должны быть тонкими, сервисы – жирными (вся логика там), репозитории – узкими (только доступ к данным).
Нужно разделять DTO/Entity/Domain – это упрощает эволюцию API и БД.
Нужно выносить маппинг в отдельные классы (ручной или MapStruct).
Нужно централизовать ошибки (@ControllerAdvice) и валидацию (@Valid + доменные проверки).
В сервлетной архитектуре обязателен слой сервисов: там и транзакции, и бизнес; а JSP занимается только отображением.
В Spring – обязательны профили/конфиги, чтобы легко менять инфраструктуру (БД, файловое хранилище и т.д.).
Архитектура моего приложения ToDo
И напоследок ещё один пример, в котором я разберусь с архитектурой своего приложения. Как правильно разместить классы, чтобы можно было легко заменить чтение из xml-файлов на работу с базой без каких-либо перемен в логике и контроллерах?
com.yourorg.galochki
├─ domain
│ ├─ model/ // ЧИСТЫЕ доменные классы
│ │ ├─ GalochkiPage.java
│ │ ├─ GalochkiDay.java
│ │ ├─ Section.java
│ │ └─ ActivityType.java
│ ├─ ports/ // ПОРТЫ (интерфейсы доступа к данным)
│ │ ├─ PageRepository.java
│ │ └─ SectionRepository.java
│ └─ error/ // (опц.) доменные исключения
│
├─ application
│ ├─ service/ // USE-CASES/бизнес-сценарии
│ │ └─ GalochkiService.java
│ ├─ dto/ // (опц.) app-DTO/Commands/Queries
│ └─ mapper/ // (опц.) маппинг Domain ↔ app-DTO
│
├─ adapters // КОНКРЕТНЫЕ реализации портов (хранилища)
│ ├─ xml/
│ │ ├─ xmlmodel/ // DTO под формат XML (JAXB/парсер). НЕ домен!
│ │ │ ├─ XmlPage.java
│ │ │ └─ XmlDay.java
│ │ ├─ io/ // чтение/запись файлов, блокировки, пути
│ │ │ ├─ XmlPageReader.java
│ │ │ └─ XmlPageWriter.java
│ │ ├─ mapper/ // XmlDTO ↔ Domain
│ │ │ └─ XmlPageMapper.java
│ │ └─ repo/ // Реализации доменных портов
│ │ └─ PageRepositoryXmlAdapter.java
│ │
│ └─ jpa/
│ ├─ entity/ // @Entity-сущности БД. НЕ домен!
│ │ ├─ PageEntity.java
│ │ └─ DayEntity.java
│ ├─ springrepo/ // интерфейсы Spring Data (внутренние)
│ │ └─ PageJpaRepo.java
│ ├─ mapper/ // Entity ↔ Domain
│ │ └─ PageEntityMapper.java
│ └─ repo/ // Реализации доменных портов (адаптеры)
│ └─ PageRepositoryJpaAdapter.java
│
├─ web
│ ├─ rest/ // REST-контроллеры (тонкие)
│ │ └─ PageController.java
│ ├─ dto/ // Web-DTO (Request/Response), валидация @Valid
│ │ ├─ PageRequestDto.java
│ │ └─ PageResponseDto.java
│ ├─ mapper/ // Domain ↔ Web-DTO
│ │ └─ PageDtoMapper.java
│ └─ advice/ // @ControllerAdvice: единый формат ошибок
│
└─ config
├─ Profiles.java // константы "db", "xml"
├─ AdapterConfig.java // подключает нужный адаптер по профилю/свойству
└─ JpaConfig.java // @EnableJpaRepositories/@EntityScan под профилем db
На этом с архитектурой всё. В следующих статьях рассмотрю чтение данных из xml-файлов.