17. Провести code review сервиса изменения баланса
Дан сервис UserBalanceService. Нужно сделать code review: найти проблемы, риски и предложить улучшения.
Код:
public class UserBalanceService {
@Autowired
UserBalanceRepository repository;
@Autowired
BalanceOperationsRepository balanceOperationsRepository;
@Autowired
CurrencyConvertRestClient currencyConvertRestClient;
@Autowired
NotificationRestClient notificationRestClient;
@Autowired
OperationLogRepository operationLogRepository;
@Value(""""app.settings.currency-default"""")
String defaultCurrency;
@Transactional
public void changeBalance(UUID userId, Double balance, String currency) {
if (userId == null || balance == null) {
throw new IllegalArgumentException(
""""Один или несколько аргументов равны null""""
);
}
if (currency == null) {
currency = defaultCurrency;
}
UserBalance userBalance = repository.getUserBalanceById(userId);
Double convertedAmount = currencyConvertRestClient.convert(
balance,
currency,
userBalance.getCurrency()
);
userBalance.setBalance(
userBalance.getAmount() + convertedAmount
);
try {
saveToLog(userId, balance, currency, null, true);
repository.save(userBalance);
} catch (DataAccessException e) {
saveToLog(
userId,
balance,
currency,
e.getMessage(),
false
);
notificationRestClient.notifyErrorBalanceChange(
userId,
balance,
currency
);
throw e;
}
notificationRestClient.notifyBalanceChangeOk(
userId,
balance,
currency
);
}
@Transactional
void saveToLog(
UUID userId,
Double balance,
String currency,
String errorMessage,
Boolean ok
) {
if (ok) {
operationLogRepository.addBalanceChangeOk(
userId,
balance,
currency
);
} else {
operationLogRepository.addBalanceChangeError(
userId,
balance,
errorMessage,
currency
);
}
}
}
Спойлеры к решению
Подсказки
BigDecimal.💡 Проверь
@Value, границы транзакции и self-invocation.💡 Два параллельных запроса могут потерять одно из изменений баланса.
💡 Внешние REST-вызовы лучше не держать внутри DB-транзакции.
Решение
Проблемы:
нет
@Serviceили другой явной регистрации класса как Spring-бина;field injection вместо constructor injection;
Doubleиспользуется для денег;в
@Valueотсутствует${...};не обработано отсутствие
UserBalance;setBalance()иgetAmount()выглядят несогласованно;REST-вызов выполняется внутри открытой транзакции;
возможен lost update при параллельных изменениях баланса;
@TransactionalнаsaveToLog()не создаёт отдельную транзакцию из-за self-invocation;error log откатится вместе с основной транзакцией;
success log вызывается ещё до успешного сохранения;
ошибка БД может возникнуть не в
save(), а только наflush/commit, поэтому текущийcatchпоймает не все ошибки сохранения;success notification вызывается до commit;
ошибка notification может скрыть исходное исключение;
Boolean okздесь не нужен;BalanceOperationsRepositoryне используется.
Как исправить
Для зависимостей использовать constructor injection, для денег — BigDecimal:
@Value("${app.settings.currency-default}")
private String defaultCurrency;
Внешнюю конвертацию валюты лучше выполнять до короткой write-транзакции.
Конкурентное изменение баланса лучше делать атомарно на уровне БД:
@Modifying
@Query("""
update UserBalance b
set b.amount = b.amount + :amount
where b.id = :userId
""")
int addAmount(
@Param("userId") UUID userId,
@Param("amount") BigDecimal amount
);
Тогда два параллельных запроса не перезапишут результат друг друга.
Основной сервис можно оставить координатором:
@Service
@RequiredArgsConstructor
public class UserBalanceService {
private final UserBalanceRepository repository;
private final CurrencyConvertRestClient currencyClient;
private final BalanceTransactionService balanceService;
private final OperationLogService operationLogService;
private final NotificationRestClient notificationClient;
@Value("${app.settings.currency-default}")
private String defaultCurrency;
public void changeBalance(
UUID userId,
BigDecimal amount,
String currency
) {
Objects.requireNonNull(userId, "userId");
Objects.requireNonNull(amount, "amount");
String sourceCurrency =
currency == null || currency.isBlank()
? defaultCurrency
: currency;
String targetCurrency = repository.findCurrencyById(userId)
.orElseThrow(() ->
new EntityNotFoundException(
"Balance not found: " + userId
)
);
BigDecimal converted = currencyClient.convert(
amount,
sourceCurrency,
targetCurrency
);
try {
balanceService.apply(
userId,
converted,
amount,
sourceCurrency
);
} catch (DataAccessException e) {
operationLogService.saveError(
userId,
amount,
sourceCurrency,
e.getMessage()
);
throw e;
}
notificationClient.notifyBalanceChangeOk(
userId,
amount,
sourceCurrency
);
}
}
Изменение баланса и success log выполняются одной короткой транзакцией:
@Service
@RequiredArgsConstructor
public class BalanceTransactionService {
private final UserBalanceRepository repository;
private final OperationLogRepository logRepository;
@Transactional
public void apply(
UUID userId,
BigDecimal convertedAmount,
BigDecimal originalAmount,
String currency
) {
if (repository.addAmount(userId, convertedAmount) != 1) {
throw new EntityNotFoundException(
"Balance not found: " + userId
);
}
logRepository.addBalanceChangeOk(
userId,
originalAmount,
currency
);
}
}
Если error log должен сохраниться независимо от rollback, его нужно вынести в отдельный Spring-бин:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveError(...) {
repository.addBalanceChangeError(...);
}
Уведомления в более надёжном варианте лучше отправлять после commit через @TransactionalEventListener(AFTER_COMMIT) или outbox.