1. Разделение read-model и write-model
Если вы когда-нибудь видели репозиторий на 200 строк с методами вида findTop20ByStatusAndCreatedAtBetweenAndCustomerEmailContainingIgnoreCaseOrderBy..., то вы уже встречались с классической проблемой: репозиторий постепенно превращается в «кладовку». Туда складывают всё: и запись, и чтение, и отчёты, и массовые операции, и «ещё одну маленькую выборочку, ну пожалуйста». Hibernate при этом не меняется, но понять, какой SQL улетит, становится сложнее, чем понять, зачем в Java придумали var.
Проблема не в том, что Spring Data JPA плохой. Он наоборот слишком удобный. И именно поэтому он провоцирует смешивание режимов работы. А режимы разные. Чтение часто должно быть тонким, проекционным, с paging и сортировкой. Запись — наоборот, entity-ориентированная, с find + mutate, транзакцией, dirty checking и понятным flush-моментом. Когда всё это в одном интерфейсе, вы на уровне кода теряете «подсказки» о семантике.
И здесь становится видно, что у слоя репозиториев есть два разных режима жизни. save(), findById(), getReferenceById() и lifecycle delete читаются через states, managed-сущности и flush-cycle. Specification, projections и paging читаются уже через форму запроса и цену чтения. Поэтому здесь не появляется второй параллельный слой — мы просто режем один разросшийся ProductRepository на два честных контракта.
Давайте посмотрим на анти-пример. В учебных проектах он выглядит почти безобидно, а в реальной кодовой базе он обычно заканчивается «почему этот метод вдруг делает 17 запросов?».
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Здесь в одном месте смешаны разные режимы:
// - проекционное чтение (summary)
// - bulk/batch операции
// - entity-ориентированные запросы
ProductSummary findSummaryById(Long id);
// Bulk-удаление выглядит «как обычный delete», но семантически это другой режим работы.
void deleteAllInBatch(Iterable<Product> products);
// Возвращаем entity — значит наружу может утечь managed-сущность и связанные эффекты (lazy, flush и т.п.).
List<Product> findAllByStatus(ProductStatus status);
// ... и ещё “всего 40 маленьких методов”
}
Формально это работает. Но по смыслу это уже три разных репозитория в одном пальто: здесь и projection-чтение, и bulk-подобный delete, и entity-чтение. А вам хочется, чтобы код сам подсказывал: «я сейчас читаю список в админке» или «я сейчас меняю состояние агрегата».
2. Репозиторий как контракт: read vs write
Репозиторий — это не просто “DAO, но моднее”. Это контракт между вашим сервисным кодом и persistence layer. И хороший контракт отвечает на вопрос: какую форму данных я отдаю и какую семантику я гарантирую. Если контракт неявный, сервис вынужден “догадываться”, а затем вы удивляетесь: “почему внутри read-сценария мы случайно получили managed-entity и словили лишний flush?”. Спойлер: потому что контракт был «на авось».
Чтобы не держать это всё в голове, полезно честно признать: в обычном backend’е есть две большие категории сценариев. Чтения и записи. Иногда они пересекаются (например, «прочитать и изменить»), но это всё равно write-сценарий, потому что у него есть unit of work и ответственность за корректность изменений.
Ниже — таблица, которая фиксирует эту идею на уровне engineering-мышления (а не философии).
| Ось сравнения | Write-подход (write-model) | Read-подход (read-model) |
|---|---|---|
| Главная цель | корректно изменить состояние | дёшево и предсказуемо отдать данные |
| Основной тип данных | Entity (managed) | Projection / DTO (не managed) |
| Транзакция | почти всегда нужна | часто нужна, но readOnly=true и без лишнего flush |
| Ключевой механизм Hibernate | dirty checking + flush | “не тащить лишнее”: projection, paging, fetch-план |
| Главный риск | merge-ловушки, каскады, accidental update | N+1, overfetching, “взяли entity и случайно ушли в lazy” |
| Как должен выглядеть контракт | «дай мне entity, я буду менять» | «дай мне нужные поля, не больше» |
Когда репозиторий «универсальный», вы в сигнатуре метода не видите, где вы на этой таблице находитесь. А если репозитории разделены, контракт становится очевиднее: один интерфейс для entity-ориентированных операций, другой — для query-операций.
3. Паттерн WriteRepository + QueryRepository
Переход к явному repository layer чаще всего не требует никакой магии. Это больше про дисциплину структуры: мы разделяем read-контракт и write-контракт и удерживаем сервисный слой как место, где живёт unit of work. Репозитории при этом становятся простыми: write-репозиторий даёт минимальные операции над entity, а query-репозиторий даёт методы для чтений под конкретные use cases. То, что раньше жило в одном ProductRepository, мы просто раскладываем по роли: entity-flow отдельно, query-flow отдельно.
Полезно видеть это схемой. Не потому что диаграммы спасают мир, а потому что мозг любит картинки, а не случайные вызовы productRepository.findAll() в середине бизнес-логики.
flowchart TD
API["Web/CLI слой
не центр курса"] --> S1["CatalogWriteService
@Transactional"]
API --> S2["CatalogReadService
@Transactional(readOnly=true)"]
S1 --> WR["ProductWriteRepository
entity-oriented"]
S2 --> QR["ProductQueryRepository
projection/spec/paging"]
WR --> H["Hibernate Session
persistence context"]
QR --> H
H --> DB["PostgreSQL"]
Обратите внимание: репозиторий не становится «умнее». Он становится честнее. А интеллект остаётся в сервисе, где вы проектируете transaction boundary, порядок операций и решаете, когда вам нужна managed-entity, а когда лучше прочитать DTO. На write-side остаются save(), findById(), getReferenceById() и lifecycle delete; на query-side — Specification, projections, paging и fluent query API.
Есть ещё одна приятная штука: разделение можно сделать так, чтобы компилятор помогал. То есть read-репозиторий физически не будет иметь метода save(). Это очень по-взрослому: вместо «не делайте так» мы делаем «у вас так не скомпилируется». Прямо как с final: он не убеждает, он запрещает.
4. Два репозитория для Product
Теперь давайте приземлим идею на наш проект Commerce Persistence Lab и сделаем это максимально «учебно», то есть понятно и без лишних финтов. Мы возьмём Product как центральную сущность каталога и сделаем два контракта: один — для write-операций, другой — для query-чтений.
Сразу небольшое методическое замечание: мы будем использовать Spring Data JPA так, чтобы интерфейсы были узкими. Для этого можно опираться на org.springframework.data.repository.Repository как на “marker interface” и объявлять только нужные методы. Реальная реализация всё равно будет делегировать в стандартный SimpleJpaRepository, но наружу мы покажем только то, что хотим показывать.
Write-репозиторий: минимум для entity-flow
Write-репозиторий должен быть скучным. Серьёзно. Чем скучнее, тем лучше. Он нужен, чтобы сервис мог принять новый объект (создание), загрузить существующий (изменение) и, при необходимости, удалить. Никаких projections, никаких “summary list”, никаких “найди 200 штук и рассортируй”.
import java.util.Optional;
import org.springframework.data.repository.Repository;
public interface ProductWriteRepository extends Repository<Product, Long> {
// Вводим новый объект в persistence flow (создание или сохранение detached-объекта).
Product save(Product product);
// Для write-сценариев важно явное «нашли или нет» — отсюда Optional.
Optional<Product> findById(Long id);
// Иногда нужна ссылка (proxy) по id, чтобы построить связь без загрузки всей сущности.
Product getReferenceById(Long id);
}
Обратите внимание на getReferenceById(). Мы явно показываем, что иногда нам нужна ссылка по id, а не полная загрузка. Это хорошо сочетается с тем, что мы уже изучали: reference — это инструмент для построения связи, но он не обещает вам немедленной загрузки состояния.
Если вам нужно удаление “по lifecycle”, можно добавить:
import org.springframework.data.repository.Repository;
public interface ProductWriteRepository extends Repository<Product, Long> {
// Lifecycle-delete: работает через EntityManager.remove(...) и учитывает состояние persistence context.
void delete(Product product);
}
Я специально не добавляю сюда batch-delete методы. Не потому что они «запрещены навсегда», а потому что семантически это другой режим: bulk-подобное поведение и риск stale persistence context. Такой метод должен быть виден как «опасный режим», а не как “удобная перегрузка delete”.
Projection для чтений: ProductSummary
Для read-слоя мы заранее выбираем форму данных. Например, для списка товаров в админке нам почти никогда не нужен весь Product с его внутренностями и потенциальными связями. Нам достаточно sku, name и status. Это прекрасный кандидат для interface-based projection.
import com.example.commerce.catalog.entity.ProductStatus;
public interface ProductSummary {
// Projection-интерфейс: Hibernate/Spring Data подставят значения полей в getters.
String getSku();
String getName();
ProductStatus getStatus();
}
Если другому экрану понадобится ещё и цена, это уже отдельный read-контракт, а не повод каждый раз переопределять один и тот же ProductSummary. Важно само правило: в списке мы читаем тонко.
Query-репозиторий: чтения под use case
Query-репозиторий не должен уметь сохранять. Мы прямо лишим его save() на уровне интерфейса. Зато дадим ему paging, projections и, если нужно, спецификации.
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.repository.Repository;
public interface ProductQueryRepository
extends Repository<Product, Long>, JpaSpecificationExecutor<Product> {
// Read-контракт: наружу отдаём проекцию, а не managed-entity.
// Плюс paging — чтобы список был предсказуем по нагрузке.
Page<ProductSummary> findByStatus(ProductStatus status, Pageable pageable);
}
Здесь красивый момент: ProductQueryRepository всё ещё работает с сущностью Product (потому что спецификации строятся вокруг entity-модели), но наружу он отдаёт ProductSummary. И это как раз то самое “read-model vs write-model”: сущность нужна как “карта таблицы”, но результат выдаём в проекции.
Если нужен динамический фильтр, тот же query-side использует findBy(spec, q -> q.as(ProductSummary.class).page(pageable)): меняется не слой, а только форма чтения.
Если вам нужно чтение “карточки товара” (detail), это тоже может быть отдельный метод, который возвращает другой контракт (например, ProductDetailsView) или DTO. Главное — не заставлять один метод быть “и списком, и карточкой, и отчётом”.
5. Сервисный слой: write и read
Разделение репозиториев — это половина дела. Вторая половина — не начать тут же обходить это разделение “потому что так быстрее”. В нашем проекте сервисный слой — место, где живёт transaction boundary и unit of work. Значит, write-сервис должен работать в обычной транзакции и менять managed-entity, а read-сервис — быть readOnly и возвращать проекции/DTO без утечки managed-графа наружу.
Write-сервис: создание и изменение без save()
Сценарий изменения товара — классическая история “find + mutate”. Мы читаем entity, она становится managed, меняем поля и выходим из метода. Hibernate сам сделает dirty checking и отправит UPDATE в нужный момент flush-цикла.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogWriteService {
private final ProductWriteRepository productWriteRepository;
public CatalogWriteService(ProductWriteRepository productWriteRepository) {
this.productWriteRepository = productWriteRepository;
}
@Transactional
public void renameProduct(Long id, String newName) {
// В write-сценарии явно загружаем сущность в persistence context...
Product p = productWriteRepository.findById(id).orElseThrow();
// ...меняем managed-entity...
p.renameTo(newName);
// ...и НЕ вызываем save(): UPDATE появится из dirty checking при flush/commit.
}
}
В этом коде нет save(), и это нормально. Да, вы можете оставить save() “для единообразия”, но тогда вы выращиваете у команды ложную привычку: «изменил — сохрани». А Hibernate работает иначе: «изменил managed — оно само синхронизируется при flush».
Создание товара — другая история. Там объект новый, и его нужно завести в persistence flow. Это как раз честный сценарий для save().
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogWriteService {
private final ProductWriteRepository productWriteRepository;
public CatalogWriteService(ProductWriteRepository productWriteRepository) {
this.productWriteRepository = productWriteRepository;
}
@Transactional
public Long createProduct(String sku, String name) {
// Новый объект ещё не managed — его нужно «прикрепить» к persistence context.
Product p = new Product(sku, name);
// save() здесь уместен: это точка входа нового объекта в persistence flow.
Product saved = productWriteRepository.save(p);
// Обычно id уже будет доступен после persist (в зависимости от стратегии генерации).
return saved.getId();
}
}
Read-сервис: paging + projection
Read-сервис должен возвращать read-модель. То есть то, что “похоже на данные для экрана/таблицы”, а не “кусок managed мира Hibernate”.
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogReadService {
private final ProductQueryRepository productQueryRepository;
public CatalogReadService(ProductQueryRepository productQueryRepository) {
this.productQueryRepository = productQueryRepository;
}
@Transactional(readOnly = true)
public Page<ProductSummary> loadActive(Pageable pageable) {
// readOnly-транзакция + проекция => меньше шансов случайно утащить managed-граф наружу.
return productQueryRepository.findByStatus(ProductStatus.ACTIVE, pageable);
}
}
Заметьте, как это читается: CatalogReadService возвращает Page<ProductSummary>. Даже если человек открыл этот метод без контекста, он понимает: это чтение, это страница, это summary. Там не будет “случайной” сериализации entity с lazy-связями, и там не будет смысла “случайно поменять поле и получить UPDATE”.
6. Имена и пакеты в repository layer
Когда проект растёт, самая дорогая валюта — это внимание разработчика. И если код заставляет постоянно “угадывать”, он начинает стоить дорого: в отладке, в code review, в онбординге новичков. Хорошая структура пакетов и имён — это не эстетика, это способ сделать поведение persistence layer заметным заранее, до запуска приложения и чтения SQL trace.
В Commerce Persistence Lab у нас package-by-feature, и это отлично сочетается с read/write split. Для каталога можно держать структуру примерно так:
com.example.commerce.catalog/
├─ entity/
│ ├─ Product
│ └─ ProductDetails
├─ repository/
│ └─ ProductWriteRepository
├─ query/
│ ├─ ProductQueryRepository
│ └─ ProductSummary
└─ service/
├─ CatalogWriteService
└─ CatalogReadService
Здесь важно, что query-код и query-контракты физически лежат отдельно. Тогда “случайно” не появится метод “сохранить товар” в query-репозитории, а “случайно” не появится “вернуть summary” в write-репозитории. Даже если появится, это будет видно по странному месту расположения.
И ещё один момент, который часто игнорируют: имена методов. Если метод делает bulk-подобную операцию, он должен называться так, чтобы мозг сразу включал красную лампочку “stale persistence context возможен”. Например, не deleteAll(), а purgeSoftDeletedProductsInBatch() или хотя бы deleteAllInBatch(...) (как у Spring Data) — уже лучше, потому что слово InBatch не даёт думать, что это обычный lifecycle delete.
То же самое с чтениями. Метод findAll() в query-репозитории — почти всегда слишком общий. Список в админке, карточка товара, отчёт по остаткам — это три разных use case, и им нужны разные read-модели. Хорошие названия вроде findActiveSummaries(...), findCatalogGrid(...), findProductCard(...) не “длиннее ради красоты”, а честнее по семантике.
7. Типичные ошибки при разделении repository layer
Ошибка №1: “Разделили репозитории, но всё равно возвращаем entity из read-сервиса”.
Иногда люди делают ProductQueryRepository, но методы там всё равно возвращают Product. На бумаге разделение есть, а по факту read-слой тащит managed-сущности в места, где они не нужны. Итог предсказуем: кто-то добавляет toString() в лог, кто-то сериализует entity, кто-то случайно трогает lazy-связь — и вы снова лечите симптомы, которые уже однажды проходили в модуле fetching.
Ошибка №2: “Read-репозиторий случайно получил save() — и всё стало можно”.
Это обычно происходит, когда read-репозиторий по привычке наследуют от JpaRepository, чтобы “не писать лишнего”. В результате у вас появляется техническая возможность вызвать save() из read-кода. И рано или поздно кто-то это сделает (не потому что плохой, а потому что дедлайн). Узкие интерфейсы на основе Repository помогают тем, что компилятор становится вашим тимлидом: он просто не даёт сделать странный ход.
Ошибка №3: “Write-сервис вызывает save() после каждого изменения managed-entity”.
Даже при правильном разделении репозиториев можно притащить старую привычку “изменил — сохрани”. Это не только лишний шум, но и риск включить неправильное мышление у всей команды. Правильная опора — find + mutate, dirty checking и flush-цикл. save() — это вход нового/detached объекта в persistence flow, а не кнопка “на всякий случай”.
Ошибка №4: “Спрятали bulk/batch операции в обычный write-репозиторий, а потом удивились stale state”.
Batch delete, bulk update/delete и другие «быстрые операции над строками» — полезны, но они семантически отличаются от entity-ориентированного lifecycle. Если вы положите их рядом с обычными CRUD-методами без явного названия и без отдельного места в структуре, следующий разработчик выполнит их в середине транзакции и продолжит работать с уже загруженными entity, которые не синхронизированы. И это будет не баг разработчика, а баг контракта: контракт не предупредил.
Ошибка №5: “Смешали readOnly и write в одном сервисном методе, потому что ‘мне так удобно’”.
Read-сервис с @Transactional(readOnly=true) должен оставаться про чтение. Если внутри него вы начинаете менять entity, рассчитывать на dirty checking и “как-нибудь оно сохранится”, вы получаете код, который очень трудно объяснить по SQL trace. Здесь как раз и помогает разделение: write-операции живут в write-сервисе, и их transactional профиль не должен быть случайным.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ