28. Провести ревью PaymentService и исправить перевод между счетами
Условие задачи:
Дан сервис перевода денег между двумя счетами. Метод должен получить счета из БД, изменить их балансы и отправить уведомления владельцам.
Нужно провести code review: найти ошибки компиляции и логики, проверить Dependency Injection и транзакционность операции, а также предложить исправленный вариант с сохранением общей структуры сервиса.
Код:
@Component
public class PaymentService{
@Autowired
private ComissionServiceImpl comissionService;
private NotificationService notificationService = new NotificationServiceImpl();
@Autowired
private AccountRepository accountRepository;
public void makePayment(AccountDto acc1, AccountDto acc2, Integer moneyAmount){
System.out.println(""Start payment"");
var account1 = accountRepository.findById(acc1.getAccountId());
var account2 = accountRepository.findById(acc1.getAccountId());
transfer(account1, account2, moneyAmount);
notificationService.sendNotification(account1.getAccountId());
notificationService.sendNotification(account2.getAccountId());
System.out.println(""End payment"");
}
@Transactional
public void transfer(Account acc1, Account acc2, Integer moneyAmount){
account1.setMoneyAmount(account1.getMoneyAmount() - moneyAmount);
account2.setMoneyAmount(account2.getMoneyAmount() + moneyAmount);
}
}
public class AccountDto {
private UUID accountId;
private Integer moneyAmount;
}
@Entity
@Table
public class Account{
@Id
private UUID accountId;
private Integer moneyAmount;
}
Спойлеры к решению
Подсказки
findById().💡 Обрати внимание, какой
accountId используется при загрузке второго счёта.💡
@Transactional вызывается через Spring proxy — внутренний вызов метода имеет значение.💡 Для всех зависимостей лучше использовать один способ DI.
💡 Подумай, что произойдёт при двух одновременных переводах с одного счёта.
Решение
Проблемы:
смешаны field injection и ручное создание зависимости через
new;сервис зависит от конкретного
ComissionServiceImpl, а не от интерфейса;comissionServiceвообще не используется;при загрузке второго счёта ошибочно используется
acc1.getAccountId();Spring Data
findById()возвращаетOptional<Account>, а код работает с результатом как сAccount;в
transfer()используются переменныеaccount1иaccount2, которых в этом методе нет;@Transactionalнаtransfer()не сработает при вызове изmakePayment()того же объекта;нет проверки входных DTO, идентификаторов и суммы;
не проверяется достаточность средств;
не обработан перевод на тот же счёт;
конкурентные переводы могут привести к lost update;
уведомления нельзя считать частью транзакции БД: они могут уйти до commit;
System.out.println()лучше заменить логгером;AccountDto.moneyAmountдля команды перевода не нужен, если сумма передаётся отдельным параметром.
Как лучше исправить
Транзакционную операцию с БД лучше вынести в отдельный сервис. Это одновременно решает проблему self-invocation и позволяет не держать отправку уведомлений внутри транзакции.
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentTransactionService transactionService;
private final NotificationService notificationService;
public void makePayment(
AccountDto from,
AccountDto to,
Integer amount
) {
if (from == null || to == null
|| from.getAccountId() == null
|| to.getAccountId() == null) {
throw new IllegalArgumentException(
"Accounts must be specified"
);
}
if (amount == null || amount <= 0) {
throw new IllegalArgumentException(
"Amount must be positive"
);
}
if (from.getAccountId().equals(to.getAccountId())) {
throw new IllegalArgumentException(
"Accounts must be different"
);
}
transactionService.transfer(
from.getAccountId(),
to.getAccountId(),
amount
);
notificationService.sendNotification(
from.getAccountId()
);
notificationService.sendNotification(
to.getAccountId()
);
}
}
Транзакционная часть:
@Service
@RequiredArgsConstructor
public class PaymentTransactionService {
private final AccountRepository accountRepository;
@Transactional
public void transfer(
UUID fromId,
UUID toId,
Integer amount
) {
Account from = accountRepository.findById(fromId)
.orElseThrow(() ->
new EntityNotFoundException(
"Account not found: " + fromId
)
);
Account to = accountRepository.findById(toId)
.orElseThrow(() ->
new EntityNotFoundException(
"Account not found: " + toId
)
);
if (from.getMoneyAmount() < amount) {
throw new IllegalStateException(
"Insufficient funds"
);
}
from.setMoneyAmount(
from.getMoneyAmount() - amount
);
to.setMoneyAmount(
to.getMoneyAmount() + amount
);
}
}
Так как Account загружены внутри транзакции и остаются managed entity, отдельный save() обычно не требуется — изменения будут сохранены через dirty checking.
При реальных конкурентных переводах одной @Transactional недостаточно. Два запроса могут одновременно прочитать один баланс и потерять обновление. Для банковского сценария нужна стратегия конкурентного доступа: например, pessimistic locking с одинаковым порядком блокировки счетов либо optimistic locking через @Version и retry.
Уведомление также лучше отправлять только после успешного commit — через @TransactionalEventListener(AFTER_COMMIT) или outbox, если требуется надёжная доставка.
Для денежных значений тип должен соответствовать модели хранения: если Integer означает минимальные денежные единицы, например копейки, это допустимо; если хранятся дробные суммы, нужен BigDecimal.