JavaRush /Курсы /Spring Data JPA /Бизнес-операция и unit of ...

Бизнес-операция и unit of work

Spring Data JPA
17 уровень , 0 лекция
Открыта

1. Репозиторий — не бизнес-операция

Когда вы только начинаете писать backend, возникает очень естественная (и очень опасная) привычка думать так: «Ну я же вызвал save() — значит “сохранил товар”». Или: «Я вызвал findById() — значит “получил товар”». Это мышление работает ровно до первого момента, когда вы добавляете вторую строчку кода — и внезапно понимаете, что “сохранить товар” уже означает: проверить категорию, проверить уникальность SKU, возможно, обновить связанные данные, и только потом реально писать в базу.

Репозиторий в Spring Data JPA — это удобная обёртка над доступом к данным. Он хорошо отвечает на вопросы уровня «достань», «сохрани», «проверь существование». Но бизнес-операция — это сценарий. Это история с началом и концом. И если вы путаете «шаг сценария» с «сценарием целиком», ваш код быстро превращается в набор случайных вызовов, которые сложно читать, опасно менять и неприятно отлаживать.

Чтобы почувствовать разницу, полезно сравнить два понятия: “техническое действие над данными” и “прикладное намерение”.

Бизнес-операции mini-shop

Если вы посмотрите на наш учебный домен mini-shop, то увидите, что настоящие действия звучат как нормальные человеческие глаголы. «Создать товар», «перекинуть товар в другую категорию», «поменять цену», «установить доступное количество на складе». Это не INSERT и не UPDATE, хотя база, конечно, в итоге получит именно их. Бизнес-операция — это то, что имеет смысл объяснить менеджеру, тестировщику и самому себе через неделю, не открывая логи Hibernate.

Важный момент: бизнес-операция почти всегда имеет условия “до” и “после”. Например, «создать товар в категории» означает, что категория должна существовать (иначе “в категории” просто некуда), а после операции товар должен быть сохранён и корректно связан с категорией. Если в середине что-то пошло не так, вы не хотите получить состояние «категорию прочитали, объект товара создали, но в базу не записали» — это не бизнес-результат, это просто “мы начали, но не закончили”.

Попробуем сказать то же самое более инженерно: бизнес-операция задаёт инварианты и границу ответственности. Инварианты — это правила “мир после операции должен быть в таком-то состоянии”. Граница ответственности — это «вот здесь операция начинается, вот здесь заканчивается, и всё внутри — один смысловой блок».

2. unit of work и граница транзакции

Термин unit of work звучит грозно, как будто это сейчас будет глава из книжки на 800 страниц. На самом деле идея простая: unit of work — это набор связанных действий над данными, которые должны завершиться вместе как единое целое. Не потому что так “красивее”, а потому что иначе приложение может оставить данные в частично обновлённом состоянии, а вы будете долго смотреть в монитор и спрашивать себя: «Почему в базе так странно?».

С точки зрения кода unit of work — это, чаще всего, один публичный метод сервиса, который выражает прикладное действие. Внутри могут быть чтения через репозитории, проверки, создание объектов, изменения полей, сохранения. Важно не количество строчек, а смысл: если эти шаги связаны одной целью и одним итоговым состоянием — это один unit of work.

С точки зрения базы данных unit of work обычно соответствует тому, что мы называем границей транзакции: момент, когда “операция началась” и “операция закончилась”. Даже если вы пока не думаете про конкретные аннотации и настройки, полезно держать в голове именно эту картину: один смысловой сценарий должен иметь один логический «контейнер целостности».

Пока это ещё смысловая рамка. Дальше нам останется сделать следующий шаг: оформить её так, чтобы Spring и база данных видели ту же самую границу.

Чтобы увидеть это визуально, представим операцию как цепочку шагов:

flowchart TD
    A["Бизнес-операция: создать товар"] --> B["Прочитать категорию"]
    B --> C["Собрать объект Product"]
    C --> D["Сохранить Product"]
    D --> E["Результат: товар существует и связан с категорией"]

Здесь важно, что это одна история. Репозитории дают нам кирпичики (шаги), а сервис — собирает дом из этих кирпичиков. И желательно так, чтобы дом не превращался в шалаш при первом ветре.

3. Примеры unit of work

createProduct()

Очень типичная ловушка новичка: если метод короткий, значит он “простой” и “не требует особого отношения”. Но короткий метод может описывать важную бизнес-операцию, где ошибка посередине приводит к плохому состоянию данных. В нашем проекте создание товара почти всегда требует хотя бы чтения категории (иначе нельзя корректно поставить ManyToOne) и сохранения самого товара. То есть уже минимум два обращения к данным.

Начнём с того, как обычно выглядит каркас сервиса каталога (он у вас уже есть или очень похож на то, что вы писали раньше):

import org.springframework.stereotype.Service;

@Service
public class CatalogService {
    // Репозиторий для доступа к категориям (чтение/проверки/сохранение)
    private final CategoryRepository categoryRepository;
    // Репозиторий для доступа к товарам (создание/поиск/изменение)
    private final ProductRepository productRepository;

    // Конструкторная инъекция: зависимости сервиса объявлены явно
    public CatalogService(CategoryRepository categoryRepository,
                          ProductRepository productRepository) {
        this.categoryRepository = categoryRepository;
        this.productRepository = productRepository;
    }
}

Теперь сама операция создания товара. Обратите внимание: в ней уже есть чтение и запись — даже если метод выглядит компактно.

import java.math.BigDecimal;

public Long createProduct(Long categoryId, String sku, String name, BigDecimal price) {
    // Шаг 1: читаем категорию (инвариант "товар создаём в существующей категории")
    Category category = categoryRepository.findById(categoryId).orElseThrow();

    // Шаг 2: собираем доменный объект в памяти
    Product product = new Product();
    product.setCategory(category); // связываем товар с категорией
    product.setSku(sku);           // устанавливаем SKU
    product.setName(name);         // устанавливаем название
    product.setPrice(price);       // устанавливаем цену

    // Шаг 3: сохраняем в базу (здесь и появляется INSERT)
    productRepository.save(product);

    // Возвращаем ID уже сохранённой сущности
    return product.getId();
}

Если перевести эту операцию на “язык базы”, у вас получится примерно такая последовательность SQL-действий (упрощённо):

-- 1) читаем категорию
select * from category where id = :categoryId;

-- 2) пишем товар
insert into product(...) values (...);

Важный вывод: бизнес-операция “создать товар” не равна “вызвать save”. save() — это всего лишь один шаг. А сама операция включает хотя бы проверку существования категории (через чтение) и саму запись товара.

И здесь появляется практическая мысль: если вы хотите понимать код, вам нужно именовать и группировать его по бизнес-операциям. Поэтому имя createProduct() — это не косметика, а часть инженерного контракта: оно говорит, что метод делает, где начинается действие и где должен быть виден итог.

moveProductToCategory()

Перенос товара в другую категорию выглядит как “ну просто поменять ссылку”. Но как только вы пишете код честно, вы видите минимум два чтения: надо достать сам товар и надо достать новую категорию. И только потом можно поменять связь и сохранить результат. То есть снова: несколько шагов data-access внутри одного намерения.

Вот компактный вариант такой операции:

public void moveProductToCategory(Long productId, Long categoryId) {
    // Шаг 1: читаем сам товар (мы должны быть уверены, что он существует)
    Product product = productRepository.findById(productId).orElseThrow();
    // Шаг 2: читаем целевую категорию (тоже должна существовать)
    Category category = categoryRepository.findById(categoryId).orElseThrow();

    // Шаг 3: меняем связь в доменной модели
    product.setCategory(category);

    // Шаг 4: сохраняем изменения в базу
    productRepository.save(product);
}

Если вы посмотрите на это глазами бизнеса, здесь есть одно действие: «перенести товар». Если вы посмотрите глазами базы, будет примерно так:

-- Читаем товар, который переносим
select * from product where id = :productId;

-- Читаем категорию, в которую переносим
select * from category where id = :categoryId;

-- Обновляем связь (FK) у товара
update product set category_id = :categoryId where id = :productId;

И вот здесь очень полезно привыкнуть мыслить не “вызовами репозитория”, а “картой операции”. У вас есть несколько шагов, и они логически связаны. Они нужны, чтобы в конце получился осмысленный итог: товар должен ссылаться на новую категорию. Если бы вы разнесли это на случайные методы и начали вызывать “частями”, вы бы быстро получили сценарии вроде «категорию нашли, а товар не нашли», «товар нашли, но категория не активна», «товар перенесли, но дальше код упал и внешняя часть системы считает, что перенос не случился». И это всё — не проблемы JPA. Это проблемы организации бизнес-операции.

Обновление остатков

Остатки (StockItem) — классическая зона, где “кажется просто”, пока не появляется хотя бы одна проверка. Допустим, у нас есть операция “установить доступное количество товара”. Уже на этом уровне вам обычно нужно: найти запись остатков, проверить, что количество не отрицательное, изменить поле, сохранить. И это всё — один сценарий.

Вот маленький пример, который легко читать:

public void setAvailableQuantity(Long productId, int quantity) {
    // Шаг 1: читаем запись остатков по товару
    StockItem stockItem = stockItemRepository.findByProductId(productId).orElseThrow();

    // Шаг 2: бизнес-проверка (инвариант "количество не может быть отрицательным")
    if (quantity < 0) {
        // Это не "ошибка базы", это ошибка входных данных операции
        throw new IllegalArgumentException("Quantity must be >= 0");
    }

    // Шаг 3: применяем изменение в доменной модели
    stockItem.setAvailableQuantity(quantity);

    // Шаг 4: сохраняем новый результат в базу
    stockItemRepository.save(stockItem);
}

Можно спорить о том, какой тип исключения лучше, и куда вынести проверку (и мы ещё много раз будем спорить, потому что это хобби у разработчиков). Но с точки зрения сегодняшней лекции важнее другое: эта операция — unit of work, потому что она выражает цель и гарантирует итоговое состояние. На входе у нас “хочу установить количество”, на выходе у нас “в базе действительно новое количество, и оно не отрицательное”.

Если думать репозиториями, можно случайно начать писать так: «в одном месте прочитал, в другом месте поменял, в третьем сохранил». А потом удивляться, почему код стал нелинейным и хрупким. unit of work дисциплинирует: держим шаги рядом, чтобы сценарий был виден целиком.

4. Граница unit of work

Очень естественная ошибка после знакомства с unit of work — решить, что “раз всё должно быть вместе, давайте в один метод запихнём вообще всё”. Так делать не надо. unit of work — не оправдание для гигантизма. Это способ сделать границу операции видимой, а не способ построить сервис-”чёрную дыру”, куда падают все действия проекта.

Хорошая граница обычно читается по глаголу и по обещанию результата. Если метод называется createProduct(), то он должен сделать ровно это: создать товар. Он может внутри читать категорию, валидировать поля, сохранять, но он не должен внезапно “заодно” пересчитывать отчёты, массово обновлять другие товары и запускать распродажу (это уже похоже на сюжет плохого фильма, где сценаристу лень писать новую сцену). Если метод называется moveProductToCategory(), то он про перенос, а не про “а ещё переименовать категорию, если она пустая”.

Иногда помогает мыслить так: у операции есть понятный вход и понятный выход. Вход — параметры метода и необходимые чтения данных. Выход — состояние базы, которое мы считаем корректным. Если вы не можете коротко сформулировать, что гарантирует метод, это тревожный знак: возможно, вы смешали несколько операций.

Для наглядности можно держать в голове небольшую таблицу (не как “правило закона”, а как подсказку):

Вопрос к себе Если ответ “да” — это признак unit of work
Можно ли описать действие одним глаголом? Значит, у операции есть цель и граница
Есть ли несколько шагов чтения/записи, которые логически связаны? Значит, шаги надо собрать в один сценарий
Есть ли «нельзя оставить частичный результат»? Значит, операция должна завершаться как целое
Можно ли дать методу имя, которое не стыдно показать человеку? Значит, вы проектируете по смыслу, а не по технике

И снова: это не “чек-лист обязаловки”, а способ не потерять смысл. Мы не пытаемся превратить backend в религию. Мы пытаемся сделать код читаемым и предсказуемым.

5. Сервис и репозиторий: роли

Самая практичная мысль из сегодняшней лекции такая: репозиторий должен оставаться библиотекой операций доступа к данным, а сервис — сценарием, который собирает эти операции в бизнес-операцию. Если вы начинаете “прятать” сценарий в репозиторий, вы получаете методы, которые слишком много знают о бизнес-правилах. Если вы начинаете “раскидывать” сценарий по нескольким классам без одной точки входа, вы получаете код, где невозможно увидеть операцию целиком.

В нашем проекте CatalogService и InventoryService должны звучать как набор нормальных доменных действий. Даже без обсуждения конкретных транзакционных настроек (мы пока говорим именно о смысле), структура сервисов уже подсказывает границы unit of work.

Например, такие методы читаются как “операции”, а не как “случайные хелперы”:

public void renameCategory(Long categoryId, String newName) {
    // Читаем категорию: операция имеет смысл только для существующей сущности
    Category category = categoryRepository.findById(categoryId).orElseThrow();

    // Меняем состояние: это и есть "rename" на уровне домена
    category.setName(newName);

    // Сохраняем результат: фиксируем новое имя в базе
    categoryRepository.save(category);
}

Или так:

public void changeProductPrice(Long productId, BigDecimal newPrice) {
    // Читаем товар, цену которого меняем
    Product product = productRepository.findById(productId).orElseThrow();

    // Применяем изменение в доменной модели
    product.setPrice(newPrice);

    // Сохраняем новое состояние
    productRepository.save(product);
}

В обоих случаях у вас видно: мы читаем сущность, меняем состояние, сохраняем. Это сценарий. Он понятен как единое действие. И самое приятное: такой код легко расширять. Хотите добавить проверку newPrice >= 0? Добавляете её рядом. Хотите запретить менять цену неактивного товара? Проверяете статус тут же. unit of work помогает держать инварианты в одном месте, а не искать их по проекту как потерянные носки после стирки.

6. Типичные ошибки при работе с unit of work

Ошибка №1: считать, что “если метод короткий — значит это один шаг”.
Длина метода вообще не гарантирует его семантическую простоту. createProduct() может занимать 10 строк, но внутри уже будет чтение категории и запись товара. Если вы мыслите только размером кода, вы пропускаете риск частично выполненного сценария и теряете смысловую границу операции.

Ошибка №2: проектировать API сервиса как набор “технических” методов вместо доменных действий.
Методы вроде process(), handle(), doWork() звучат загадочно, но плохо. Они не помогают понять, что именно является unit of work. Когда сервисные методы называются предметно (renameCategory(), moveProductToCategory(), setAvailableQuantity()), граница операции становится очевидной даже без комментариев.

Ошибка №3: размазывать один сценарий по нескольким местам и потом собирать его “в голове”.
Очень легко начать писать так, что чтение делается в одном методе, проверка — в другом, запись — в третьем, а в итоге “операция” существует только в вашем воображении. На практике это приводит к хрупкости: вы меняете один кусок и не замечаете, как ломаете другой. unit of work — это как раз привычка держать связанные шаги рядом.

Ошибка №4: пытаться “спрятать бизнес” в репозиторий, потому что там же база.
Репозиторий действительно ближе к данным, но это не делает его правильным местом для orchestration. Если вы начинаете добавлять в репозиторий методы вида moveProductToCategory(productId, categoryId) и внутри делаете несколько чтений и бизнес-проверок, вы превращаете data-access слой в полубизнесовый слой. Через некоторое время репозиторий разрастается, и команда перестаёт понимать, где заканчивается доступ к данным и начинается смысл операции.

Ошибка №5: оценивать необходимость “целостной операции” по количеству репозиториев.
Иногда операция трогает один репозиторий, но всё равно является важным unit of work. Например, изменение цены товара может затрагивать один ProductRepository, но оно всё равно должно быть оформлено как понятное доменное действие, потому что у него есть инварианты и ожидаемый итог. Количество репозиториев — не критерий. Критерий — смысл и риск частичного результата.

1
Задача
Spring Data JPA, 17 уровень, 0 лекция
Недоступна
Создание товара как одна сервисная операция
Создание товара как одна сервисная операция
1
Задача
Spring Data JPA, 17 уровень, 0 лекция
Недоступна
Перенос товара в другую категорию
Перенос товара в другую категорию
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ