85. Code Review: @Transactional + внешний HTTP вызов #
Условие задачи:
📌 Есть сервис, который делает внешний HTTP-вызов и сохраняет результат в БД. Нужно провести код-ревью: какие проблемы в этом коде и как их исправить.
Код:
@Service
public class RestClient {
@Transactional
public void doWork() {
var obj = restTemplate.postForObject(...);
dbService.saveObj(obj);
}
}
Спойлеры к решению
Подсказки
💡 Не держите транзакцию БД во время сетевого запроса — это блокирует ресурсы и увеличивает риск длительных блокировок.
💡 Обработайте ошибки/таймауты внешнего вызова (timeouts, retries/circuit breaker, фолбэки).
💡 Сделайте
💡 Разделите ответственность: внешний вызов отдельно, сохранение — в отдельной транзакции.
💡 Продумайте идемпотентность: если внешний вызов прошёл, а транзакция откатилась — возможны дубли/несогласованность.
💡 Обработайте ошибки/таймауты внешнего вызова (timeouts, retries/circuit breaker, фолбэки).
💡 Сделайте
RestTemplate/клиент биновым и внедряйте через конструктор.💡 Разделите ответственность: внешний вызов отдельно, сохранение — в отдельной транзакции.
💡 Продумайте идемпотентность: если внешний вызов прошёл, а транзакция откатилась — возможны дубли/несогласованность.
Решение
@Configuration
class HttpClientConfig {
@Bean
RestTemplate restTemplate() {
var factory = new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(2_000);
factory.setReadTimeout(3_000);
return new RestTemplate(factory);
}
}
@Service
@RequiredArgsConstructor
class RestClientService {
private final RestTemplate restTemplate;
private final DbService dbService;
public void doWork() {
// 1) Внешний HTTP-вызов ВНЕ транзакции БД
ResponseDto obj = callRemote();
// 2) Отдельная короткая транзакция только на запись в БД
save(obj);
}
private ResponseDto callRemote() {
try {
// пример: добавьте идемпотентный ключ, если поддерживается провайдером
var headers = new HttpHeaders();
headers.add("Idempotency-Key", UUID.randomUUID().toString());
var request = new HttpEntity<>(buildRequest(), headers);
return restTemplate.postForObject("https://remote/api", request, ResponseDto.class);
} catch (RestClientResponseException ex) {
// HTTP 4xx/5xx
throw new ExternalServiceException("Remote error: " + ex.getRawStatusCode(), ex);
} catch (ResourceAccessException ex) {
// таймауты/сети
throw new ExternalServiceException("Remote timeout/unavailable", ex);
}
}
@Transactional
protected void save(ResponseDto obj) {
// валидация данных перед сохранением
if (obj == null) {
throw new IllegalArgumentException("Remote response is null");
}
dbService.saveObj(obj);
}
private RequestDto buildRequest() {
// сконструируйте DTO запроса
return new RequestDto(/* ... */);
}
}
/** Пример доменных исключений — не даём "прятать" сетевые ошибки как SQL */
class ExternalServiceException extends RuntimeException {
ExternalServiceException(String msg, Throwable cause) { super(msg, cause); }
}
/** Пример DbService — транзакция может жить здесь, если хочется инкапсулировать слой */
@Service
@RequiredArgsConstructor
class DbService {
private final ObjRepository repo;
@Transactional(propagation = Propagation.MANDATORY)
public void saveObj(ResponseDto obj) {
repo.save(map(obj));
}
private ObjEntity map(ResponseDto dto) {
// mapper DTO -> Entity
return new ObjEntity(/* ... */);
}
}
Что исправлено и почему (кратко):
🚫 Убрали длительную БД-транзакцию вокруг сетевого вызова: теперь транзакция короткая и только на
save(...).🧯 Добавили таймауты и обработку ошибок для HTTP клиента, чтобы не висеть бесконечно.
🧱 Разделили слои и ответственности, внедрение зависимостей — через конструктор.
🔁 Продумана идемпотентность (Idempotency-Key) для избежания дублей при повторах.
✅ Явные доменные исключения: проще различать сбои внешнего сервиса и БД.