42. Провести code review сервиса конвертации валюты
Условие задачи:
Дан Spring-сервис, который конвертирует сумму из рублей в другую валюту, списывает деньги с рублёвого счёта, зачисляет их на валютный счёт и отправляет уведомление.
Необходимо провести code review, обратить внимание на стилистику, corner cases, дизайн классов и SOLID.
Код:
@Component
@RequiredArgsConstructor
public class Converter {
private final ConversationService cs;
private final AccountRepository accountRepository;
private final NotificationService notificationService;
/**
* Конвертирует sumInRub в валюту currencyCode
* по курсу на текущий момент и переводит
* с рублевого счета на валютный
*
* @param sumInRub сумма перевода в рублях
* @param currencyCode валюта перевода
* @param accountNumberRub номер рублевого счета
* @param accountNumber номер валютного счета
*/
@Transactional
public void convertFromRubbles(
double sumInRub,
String currencyCode,
String accountNumberRub,
String accountNumberCurr
) {
Double rate = cs.getRate("RUB", currencyCode);
Double convertedSum = sumInRub * rate;
accountRepository.decrease(
accountNumberRub,
sumInRub
);
accountRepository.increase(
accountNumberCurr,
convertedSum
);
notificationService.send("Перевод успешен");
}
}
Спойлеры к решению
Подсказки
BigDecimal.💡 Проверь входные параметры, существование счетов и достаточность средств.
💡 Подумай, стоит ли выполнять внешний вызов курса внутри DB-транзакции.
💡 Уведомление отправляется ещё до успешного commit.
💡 При конкурентных переводах уменьшение баланса должно выполняться атомарно.
Решение
Основные проблемы:
- для денег и курса используются
double/Double; - нет проверки
sumInRub > 0; - не проверяются
currencyCodeи номера счетов; - не обрабатывается отсутствие курса;
- не проверяется наличие достаточного остатка;
- метод получения курса выполняется внутри открытой транзакции;
- уведомление отправляется до успешного commit;
- ошибка
notificationServiceможет откатить перевод, хотя уведомление не является частью изменения баланса; - возможна race condition при одновременных списаниях;
- неясно, что означает
rate: умножать сумму на курс или делить — это должен определять контрактgetRate(); - название
convertFromRubbles()содержит опечатку, корректнееconvertFromRubles(); - имя
csнеинформативно; - в JavaDoc указан
accountNumber, а параметр называетсяaccountNumberCurr; - класс
Converterодновременно получает курс, выполняет перевод и отправляет уведомление.
Для денежных операций лучше использовать BigDecimal:
BigDecimal convertedAmount =
amount.multiply(rate)
.setScale(2, RoundingMode.HALF_UP);
Базовый вариант сервиса:
@Service
@RequiredArgsConstructor
public class CurrencyConversionService {
private final ConversationService conversionService;
private final AccountRepository accountRepository;
@Transactional
public void convertFromRubles(
BigDecimal amount,
String currencyCode,
String rubAccount,
String currencyAccount
) {
if (amount == null
|| amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException(
"Amount must be positive"
);
}
BigDecimal rate =
conversionService.getRate(
"RUB",
currencyCode
);
BigDecimal convertedAmount =
amount.multiply(rate)
.setScale(
2,
RoundingMode.HALF_UP
);
int decreased =
accountRepository.decreaseIfEnough(
rubAccount,
amount
);
if (decreased != 1) {
throw new IllegalStateException(
"Insufficient funds"
);
}
accountRepository.increase(
currencyAccount,
convertedAmount
);
}
}
Списание желательно выполнять одной атомарной операцией на уровне БД:
UPDATE account
SET balance = balance - :amount
WHERE account_number = :accountNumber
AND balance >= :amount;
Если изменена одна строка — средств хватило. Если 0 строк — счёт отсутствует или денег недостаточно.
Также получение курса желательно выполнять до короткой DB-транзакции, если ConversationService обращается во внешний сервис.
Уведомление:
notificationService.send("Перевод успешен");
лучше отправлять после успешного commit, например через @TransactionalEventListener(AFTER_COMMIT) или outbox.
Главные исправления: BigDecimal, валидация, атомарное списание, корректная граница транзакции и отправка уведомления только после успешного перевода.