Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Gianluca-X/DigitalMoney/llms.txt

Use this file to discover all available pages before exploring further.

Whenever a money transfer involves reading a balance and then writing an updated balance, there is an inherent window of time in which a second concurrent request could read the same stale balance. Without a concurrency control strategy, two simultaneous withdrawals from the same account could each see sufficient funds, both proceed, and collectively deduct more money than the account ever held. Digital Money House addresses this through a combination of declarative transactions, balance validation inside the transaction boundary, and JWT-bound sender identity.

The Problem: Race Conditions Without Locking

Consider two transfer requests that arrive at nearly the same time, both reading account A’s balance of $1,000:
  1. Thread 1 reads balance → $1,000. Plans to deduct $600.
  2. Thread 2 reads balance → $1,000 (same stale value). Plans to deduct $600.
  3. Thread 1 writes new balance → $400. ✅
  4. Thread 2 writes new balance → $400. ❌ — should be $-200 (rejected), but it succeeds because it used the stale read.
The account is now overdrawn by $200 even though the business rule forbids negative balances. The two writes raced, and the first write was silently overwritten by the second.

Solution 1 — @Transactional Boundaries

All cash transfer logic is wrapped in a single Spring @Transactional method. The annotation on makeTransferFromCash in TransferenceServiceImpl guarantees that every database operation within the method — deducting the sender’s balance, crediting the recipient’s balance, saving the Transference record, and saving both Activity entries — either all succeed together or all roll back together:
@Transactional
public void makeTransferFromCash(Long accountId, TransferRequestOutDTO transferRequest)
        throws AccountNotFoundException, BadRequestException {

    Account senderAccount = accountsRepository.findById(accountId)
            .orElseThrow(() -> new AccountNotFoundException("Cuenta inexistente"));

    if (senderAccount.getBalance().compareTo(transferRequest.getAmount()) < 0) {
        throw new InsufficientFundsException("Fondos insuficientes");
    }

    Account recipientAccount = findRecipientAccount(transferRequest.getRecipient());

    if (recipientAccount.getId().equals(senderAccount.getId())) {
        throw new BadRequestException("No puedes transferirte dinero a tu propia cuenta.");
    }

    senderAccount.setBalance(senderAccount.getBalance().subtract(transferRequest.getAmount()));
    recipientAccount.setBalance(recipientAccount.getBalance().add(transferRequest.getAmount()));

    accountsRepository.save(senderAccount);
    accountsRepository.save(recipientAccount);

    // ... Activity and Transference records saved atomically within the same transaction
}
If any step throws an exception — for instance, if saving the recipient account fails — Spring rolls the entire transaction back, leaving both balances exactly as they were before the call.
Because the balance check and the balance update both happen inside the same @Transactional boundary, a validation failure (e.g. insufficient funds) is guaranteed to roll back any partial state changes before any write reaches the database.

Solution 2 — JWT-Derived Sender Identity

The sender’s account is never accepted as a client-supplied parameter for the actual deduction. Instead, the accounts-service JwtAuthenticationFilter resolves the authenticated user’s Account entity from the email embedded in the JWT and stores it as the Spring Security principal. Controllers then retrieve the account from the security context rather than trusting an arbitrary account ID from the request body. This means a client cannot forge a request that debits a different user’s account — even if they know the target account ID. The JWT signature ensures the email cannot be tampered with, and the email is the authoritative link to the sender’s Account record.

Business Validations That Complement Transactions

Transaction boundaries work together with the following business-rule validations applied before any balance change is committed:
ValidationException thrown
Transfer amount must be greater than zeroBadRequestException
Sender and recipient accounts must differBadRequestException
Sender must have sufficient fundsInsufficientFundsException
Card used for deposit must belong to the accountUnauthorizedException
Sender and recipient accounts must existAccountNotFoundException / ResourceNotFoundException
All of these checks execute within the same @Transactional boundary, so a validation failure is guaranteed to roll back any partial state changes.

Atomic Activity Recording

Every balance-changing operation records one or more Activity entries in the same transaction:
OperationActivities recorded
Cash transfer outtransfer-out on sender account
Cash transfer intransfer-in on recipient account
Card depositdeposit on the depositing account
Because all saves happen inside the same @Transactional call, the activity log is always consistent with the actual balance: if the transfer rolls back, the activity record rolls back with it. There is no scenario in which a balance changes without a matching activity entry, or vice versa.

Concurrency Testing

The test suite validates transfer correctness under concurrent load using ExecutorService-based multi-threaded tests in TransferenceServiceTest. Multiple threads submit competing transfer requests simultaneously; the tests then assert that the final balances are exactly correct — confirming that the combination of atomic transactions and balance validation prevents any money from being created or destroyed by concurrent execution.
The unit tests use Mockito mocks and verify that both accountsRepository.save() calls and both activityRepository.save() calls are made exactly once per transfer, ensuring no partial state is ever persisted.

Build docs developers (and LLMs) love