Архитектура приложения на Java: разделение на слои Domain, Application, Persistence, Web.

Ещё несколько месяцев назад я начал делать новый проект, который в теории должен быть совсем не сложным: обычный 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());
    }
}

Как это всё связывается?

  1. Контроллер вызывает сервис.
  2. Сервис работает с портом UserRepository.
  3. Spring по активному профилю подставляет реализацию порта – здесь UserRepositoryJpaAdapter, который внутри использует JpaRepository.
  4. Адаптер маппит 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-файлов.

Оставьте комментарий