13. Сделать рефакторинг сервиса пользователей
Условие задачи:
Дан сервис UserService, который создаёт пользователей и связывает их с регионом.
Необходимо провести code review и исправить проблемы, связанные с:
созданием и внедрением зависимостей;
использованием
ApplicationContext;транзакциями;
ошибками компиляции;
стилем и именованием;
обработкой списка пользователей;
выбором корректной границы транзакции.
Код:
class UserService {
private UserRepository repo = new UserRepository();
private RegionService regionService;
public UserService(final ApplicationContext appCtx) {
regionService = appCtx.getBean(
"regionService",
RegionService.class
);
}
public void processNewUsers(
final List<User> users,
String regionName
) {
// ...
users = createUsers(users);
// ...
users.stream()
.foreach(u ->
regionService.updateRegionLink(
u.getId(),
regionName
)
);
}
@Transactional
public List<User> createUsers(
final List<User> users
) {
return users.stream()
.map(u -> repo.saveUser(u))
.collect(Collectors.toList());
}
private User getUser(final int userId) {
return repo.getUserById(userId);
}
}
Спойлеры к решению
Подсказки
UserRepository или доставать зависимости из ApplicationContext.💡 Параметр
users объявлен final, поэтому присвоить ему новый список нельзя.💡 В Stream API метод называется
forEach(), а не foreach().💡
@Transactional на createUsers() не сработает при обычном внутреннем вызове из processNewUsers() через this.💡 Нужно определить настоящую транзакционную границу: только создание пользователей или весь сценарий целиком.
💡 Если обновление региона — удалённый вызов, одна JDBC-транзакция всё равно не сделает весь процесс атомарным.
Решение
В исходном коде есть как минимум две ошибки компиляции.
Параметр:
final List<User> users
нельзя переназначать:
users = createUsers(users);
Нужно сохранить результат в отдельную переменную:
List<User> savedUsers = createUsers(users);
Также в Stream API нет метода:
foreach(...)
Правильно:
forEach(...)
Главная архитектурная проблема — работа с зависимостями.
Так делать не следует:
private UserRepository repo = new UserRepository();
Spring-сервис не должен самостоятельно создавать repository. Кроме того, если UserRepository является интерфейсом Spring Data, такой код вообще не скомпилируется.
Также нежелательно использовать Service Locator:
appCtx.getBean("regionService", RegionService.class);
Зависимости лучше явно передавать через конструктор:
@Service
public class UserService {
private final UserRepository userRepository;
private final RegionService regionService;
public UserService(
UserRepository userRepository,
RegionService regionService
) {
this.userRepository = userRepository;
this.regionService = regionService;
}
// ...
}
Теперь зависимости:
явно видны;
обязательны;
могут быть
final;легко подменяются в тестах;
создаются и управляются Spring.
Есть важная проблема с транзакцией.
Сейчас:
public void processNewUsers(...) {
createUsers(users);
}
@Transactional
public List<User> createUsers(...) {
...
}
createUsers() вызывается из того же объекта.
При стандартном proxy-based AOP вызов выглядит так:
Spring proxy
↓
processNewUsers()
↓
this.createUsers()
Внутренний вызов не проходит через proxy ещё раз, поэтому @Transactional на createUsers() не создаёт ожидаемую транзакцию.
Если создание пользователей и обновление региональных связей являются одной локальной бизнес-операцией в одной БД, транзакционной точкой входа логичнее сделать processNewUsers():
@Service
public class UserService {
private final UserRepository userRepository;
private final RegionService regionService;
public UserService(
UserRepository userRepository,
RegionService regionService
) {
this.userRepository = userRepository;
this.regionService = regionService;
}
@Transactional
public void processNewUsers(
List<User> users,
String regionName
) {
Objects.requireNonNull(users, "users");
Objects.requireNonNull(regionName, "regionName");
List<User> savedUsers = createUsers(users);
savedUsers.forEach(user ->
regionService.updateRegionLink(
user.getId(),
regionName
)
);
}
private List<User> createUsers(
List<User> users
) {
return users.stream()
.map(userRepository::saveUser)
.collect(Collectors.toList());
}
private User getUser(int userId) {
return userRepository.getUserById(userId);
}
}
Теперь вызов:
processNewUsers(...)
приходит извне через Spring proxy, поэтому транзакция действительно начинается перед выполнением метода.
createUsers() становится обычным приватным вспомогательным методом и отдельная @Transactional ему не нужна.
Однако транзакционную границу нужно выбирать по реальной природе RegionService.
Если:
regionService.updateRegionLink(...)
изменяет данные в той же БД и участвует в том же PlatformTransactionManager, общая транзакция может быть оправдана:
создать User
+
обновить Region
+
COMMIT
При исключении изменения можно откатить вместе.
Если же RegionService выполняет HTTP-вызов, отправляет сообщение или работает с независимым хранилищем, JDBC-транзакция не делает весь процесс атомарным.
Например:
User сохранён
↓
внешний RegionService успешно обновлён
↓
следующая операция падает
↓
локальная БД делает ROLLBACK
Внешнее изменение уже невозможно откатить обычной @Transactional.
В таком сценарии нужно отдельно проектировать согласованность: например, через событие/outbox, retry и идемпотентность.
Ещё один момент — вызов:
savedUsers.forEach(user ->
regionService.updateRegionLink(
user.getId(),
regionName
)
);
делает одну операцию на каждого пользователя.
Если users может быть большим списком и RegionService поддерживает batch-операцию, эффективнее использовать что-то вроде:
regionService.updateRegionLinks(
savedUsers,
regionName
);
Это особенно важно, если каждый вызов приводит к отдельному SQL или сетевому запросу.
Метод:
private User getUser(int userId)
в представленном фрагменте нигде не используется.
Если он действительно не нужен, его следует удалить.
Если это операция публичного API сервиса, тогда private некорректен и нужно явно определить контракт, включая поведение при отсутствии пользователя.
Например, если repository возвращает Optional:
public User getUser(int userId) {
return userRepository.findById(userId)
.orElseThrow(() ->
new EntityNotFoundException(
"User not found: " + userId
)
);
}
Но изменять видимость только ради самого рефакторинга не нужно — это зависит от назначения метода.
Итоговый минимальный вариант, если весь сценарий должен выполняться в одной локальной транзакции:
@Service
public class UserService {
private final UserRepository userRepository;
private final RegionService regionService;
public UserService(
UserRepository userRepository,
RegionService regionService
) {
this.userRepository = userRepository;
this.regionService = regionService;
}
@Transactional
public void processNewUsers(
List<User> users,
String regionName
) {
Objects.requireNonNull(users, "users");
Objects.requireNonNull(regionName, "regionName");
List<User> savedUsers = users.stream()
.map(userRepository::saveUser)
.collect(Collectors.toList());
savedUsers.forEach(user ->
regionService.updateRegionLink(
user.getId(),
regionName
)
);
}
}
Основные проблемы исходного кода:
repository создаётся вручную вместо DI;
используется
ApplicationContextкак Service Locator;зависимости не
final;отсутствует нормальный constructor injection;
final-параметрusersпытаются переназначить;написано
foreachвместоforEach;@Transactionalна self-invokedcreateUsers()не работает ожидаемым образом;транзакционная граница выбрана неочевидно;
возможны многочисленные отдельные вызовы
RegionService;getUser()в представленном коде не используется;имя
repoменее информативно, чемuserRepository.
Ключевое исправление — не просто перенести @Transactional, а сначала определить, какие операции действительно должны составлять одну атомарную транзакцию.