I need some insights on my bank application written with Spring Boot. Specifically on the transactions aspect and anything I missed out.
TransactionEntity.java
package com.samuel.bankapi.models.entities;
import com.samuel.bankapi.enums.StatusType;
import com.samuel.bankapi.enums.TransactionType;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Entity
@Table(name = "transactions")
public class TransactionEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@ManyToOne(cascade = CascadeType.MERGE)
@JoinColumn(name = "sender_id", nullable = false)
private UserEntity sender;
@ManyToOne(cascade = CascadeType.MERGE)
@JoinColumn(name = "reciever_id", nullable = false)
private UserEntity reciever;
@Column(nullable = false)
private double amount;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private TransactionType transactionType;
@Column(nullable = false)
private String description;
@Column(nullable = false)
private Date transactionDate;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private StatusType status;
private String reference;
@Transient
private String transactionPin;
}
TransactionDto
package com.samuel.bankapi.models.dto;
import com.samuel.bankapi.models.entities.UserEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
import org.springframework.security.core.userdetails.User;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TransactionDto {
private String id;
private String sender;
private String reciever;
private double amount;
private String transactionType;
private String description;
private String status;
private String reference;
private String transactionPin;
}
TransactionReceiptEntity.java
package com.samuel.bankapi.models.entities;
import java.util.Date;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "transaction_receipts")
public class TransactionReceiptEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@ManyToOne(cascade = CascadeType.MERGE)
@JoinColumn(name = "transaction_id", nullable = false)
private TransactionEntity transaction;
private String transactionType;
private Date transactionDate;
private Double amount;
private Double previousBalance;
private Double newBalance;
@ManyToOne(cascade = CascadeType.MERGE)
@JoinColumn(name = "user_id", nullable = false)
private UserEntity user;
private String description;
private String status;
private String reference;
}
TransactionReceiptDto.java
package com.samuel.bankapi.models.dto;
import java.util.Date;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TransactionReceiptDto {
private String id;
private TransactionReadDto transaction;
private String transactionType;
private Date transactionDate;
private Double amount;
private Double previousBalance;
private Double newBalance;
private UserDto user;
private String description;
private String status;
private String reference;
}
TransactionReadDto.java
package com.samuel.bankapi.models.dto;
import com.samuel.bankapi.models.entities.UserEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TransactionReadDto {
private String id;
private UserDto sender;
private UserDto reciever;
private double amount;
private String transactionType;
private String description;
private String transactionDate;
private String status;
private String reference;
private String transactionPin;
}
TransactionAuditEntity.java
package com.samuel.bankapi.models.entities;
import com.samuel.bankapi.enums.ActionType;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Type;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Builder
@Table(name = "transaction_audits")
public class TransactionAuditEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@JoinColumn(name = "transaction_id", nullable = false)
@ManyToOne(cascade = CascadeType.MERGE)
private TransactionEntity transaction;
@Type(JsonType.class)
@Column(name = "old_data", columnDefinition = "jsonb")
private String oldData; // Stored as JSON string
@Type(JsonType.class)
@Column(name = "new_data", columnDefinition = "jsonb")
private String newData; // Stored as JSON string
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ActionType action;
@JoinColumn(name = "performed_by", nullable = false)
@ManyToOne(cascade = CascadeType.MERGE)
private UserEntity performedBy;
@Column(name = "performed_at", nullable = false)
private Date performedAt;
}
TransactionCtrl.java
package com.samuel.bankapi.controllers;
import com.samuel.bankapi.mappers.Mapper;
import com.samuel.bankapi.models.dto.TransactionDto;
import com.samuel.bankapi.models.dto.TransactionReadDto;
import com.samuel.bankapi.models.dto.UserDto;
import com.samuel.bankapi.models.dto.VerifyUserDto;
import com.samuel.bankapi.models.entities.TransactionEntity;
import com.samuel.bankapi.models.entities.UserEntity;
import com.samuel.bankapi.payload.TransactionMessage;
import com.samuel.bankapi.producer.KafkaJsonProducer;
import com.samuel.bankapi.services.TransactionService;
import com.samuel.bankapi.services.UserService;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/transactions")
public class TransactionCtrl {
@Autowired
TransactionService transactionService;
@Autowired
private KafkaJsonProducer kafkaJsonProducer;
@Autowired
UserService userService;
@Autowired
@Qualifier("transactionMapper")
Mapper<TransactionEntity, TransactionDto> transactionMapper;
@Autowired
@Qualifier("transactionReadMapper")
Mapper<TransactionEntity, TransactionReadDto> transactionReadMapper;
@Autowired
Mapper<UserEntity, VerifyUserDto> userMapper;
@GetMapping("/verify-and-get-user/{accountNumber}")
public ResponseEntity<VerifyUserDto> verifyAndGetUser(@PathVariable String accountNumber) {
if (userService.isExists(accountNumber)) {
System.out.println("User exists: " + accountNumber);
UserEntity userEntity = userService.getUser(accountNumber);
VerifyUserDto verifyUserDto = userMapper.mapTo(userEntity);
return new ResponseEntity<>(verifyUserDto, HttpStatus.OK);
} else {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
}
@GetMapping("/verify-balance/{amount}")
public ResponseEntity<?> verifyBalance(@PathVariable double amount) {
UserEntity userEntity = userService.getCurrentUser();
if (transactionService.isBalanceSufficient(userEntity, amount)) {
return new ResponseEntity<>(true, HttpStatus.OK);
} else {
return new ResponseEntity<>(false, HttpStatus.FORBIDDEN);
}
}
@PostMapping("")
// @Transactional
public ResponseEntity<?> createTransaction(@RequestBody TransactionDto transactionDto) {
try {
TransactionEntity transactionEntity = transactionMapper.mapFrom(transactionDto);
TransactionEntity createdTransactionEntity = transactionService.createTransaction(transactionEntity);
TransactionDto createdTransactionDto = transactionMapper.mapTo(createdTransactionEntity);
TransactionMessage transactionMessage = new TransactionMessage();
transactionMessage.setTransactionType(createdTransactionDto.getTransactionType());
transactionMessage.setTransactionDescription(createdTransactionDto.getDescription());
transactionMessage.setTransactionAmount(createdTransactionDto.getAmount());
transactionMessage.setTransactionDate(createdTransactionEntity.getTransactionDate());
UserEntity sender = userService.getUserById(createdTransactionDto.getSender());
UserEntity receiver = userService.getUserById(createdTransactionDto.getReciever());
VerifyUserDto senderDto = userMapper.mapTo(sender);
VerifyUserDto receiverDto = userMapper.mapTo(receiver);
transactionMessage.setSender(senderDto);
transactionMessage.setReceiver(receiverDto);
kafkaJsonProducer.sendMessage(transactionMessage);
return new ResponseEntity<>(createdTransactionDto, HttpStatus.CREATED);
} catch (Exception e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@GetMapping("")
public ResponseEntity<?> getAllTransactions() {
try {
List<TransactionEntity> transactionEntities = transactionService.getTransactions();
List<TransactionReadDto> transactionDtos = transactionEntities.stream().map(transactionReadMapper::mapTo).toList();
return new ResponseEntity<>(transactionDtos, HttpStatus.OK);
} catch (Exception e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
TransactionReceiptCtrl.java
package com.samuel.bankapi.controllers;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.samuel.bankapi.mappers.Mapper;
import com.samuel.bankapi.models.dto.TransactionReceiptDto;
import com.samuel.bankapi.models.entities.TransactionEntity;
import com.samuel.bankapi.models.entities.TransactionReceiptEntity;
import com.samuel.bankapi.models.entities.UserEntity;
import com.samuel.bankapi.services.TransactionReceiptService;
import com.samuel.bankapi.services.TransactionService;
import com.samuel.bankapi.services.UserService;
@RestController
@RequestMapping("/api/transactions/receipts")
public class TransactionReceiptCtrl {
@Autowired
private TransactionReceiptService transactionReceiptService;
@Autowired
private TransactionService transactionService;
@Autowired
private UserService userService;
@Autowired
private Mapper<TransactionReceiptEntity, TransactionReceiptDto> transactionReceiptMapper;
@GetMapping("")
public ResponseEntity<?> getReceipts() {
try {
List<TransactionReceiptEntity> receipts = transactionReceiptService.getReceipts();
List<TransactionReceiptDto> receiptDtos = receipts.stream().map(transactionReceiptMapper::mapTo).toList();
return new ResponseEntity<>(receiptDtos, HttpStatus.OK);
} catch (Exception e) {
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@GetMapping("/{id}/{userId}")
public ResponseEntity<?> getReceipt(@PathVariable String id, @PathVariable String userId) {
try {
TransactionEntity transaction = transactionService.getTransaction(id);
if (transaction == null) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
UserEntity user = userService.getUserById(userId);
if (user == null) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
TransactionReceiptEntity receipt = transactionReceiptService.getReceipt(transaction, user);
TransactionReceiptDto receiptDto = transactionReceiptMapper.mapTo(receipt);
return new ResponseEntity<>(receiptDto, HttpStatus.OK);
} catch (Exception e) {
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
This is were would appreciate more feedback
TransactionService
package com.samuel.bankapi.services;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.samuel.bankapi.enums.ActionType;
import com.samuel.bankapi.exceptions.TransactionException;
import com.samuel.bankapi.exceptions.UserException;
import com.samuel.bankapi.enums.StatusType;
import com.samuel.bankapi.models.UserPrincipal;
import com.samuel.bankapi.models.entities.TransactionAuditEntity;
import com.samuel.bankapi.models.entities.TransactionEntity;
import com.samuel.bankapi.models.entities.TransactionReceiptEntity;
import com.samuel.bankapi.models.entities.UserEntity;
import com.samuel.bankapi.repositories.TransactionReceiptRepo;
import com.samuel.bankapi.repositories.TransactionRepo;
import com.samuel.bankapi.repositories.UserRepo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.StreamSupport;
@Service
public class TransactionService {
@Autowired
private UserRepo userRepo;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TransactionRepo transactionRepo;
@Autowired
private TransactionAuditService transactionAuditService;
@Autowired
private UserService userService;
@Autowired
private TransactionReceiptRepo transactionReceiptRepo;
public boolean isBalanceSufficient(UserEntity userEntity, double amount) {
// check if the userEntity balance is greater than the amount
return userEntity.getBalance() >= amount;
}
private boolean usersActive(TransactionEntity createdTransactionEntity) {
if (!createdTransactionEntity.getSender().isActive() || !createdTransactionEntity.getReciever().isActive()) {
throw new TransactionException.InvalidTransactionException("Sender or receiver not an active account");
}
return true;
}
private boolean validateTransactionInput(TransactionEntity transactionEntity) {
// check if the sender and receiver are the same
if (transactionEntity.getSender().getId().equals(transactionEntity.getReciever().getId())) {
transactionEntity.setStatus(StatusType.FAILED);
transactionRepo.save(transactionEntity);
throw new TransactionException.InvalidTransactionException("Sender and receiver cannot be the same");
}
// check if the amount is greater than 0
if (transactionEntity.getAmount() < 100) {
transactionEntity.setStatus(StatusType.FAILED);
transactionRepo.save(transactionEntity);
throw new TransactionException.InvalidTransactionException("Amount must be greater than 100");
}
// check if the sender has sufficient balance
if (!isBalanceSufficient(transactionEntity.getSender(), transactionEntity.getAmount())) {
transactionEntity.setStatus(StatusType.FAILED);
transactionRepo.save(transactionEntity);
throw new TransactionException.InvalidTransactionException("Insufficient funds");
}
return true;
}
private boolean validateTransactionPin(TransactionEntity transactionEntity) {
// get the current logged in user
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
UserEntity userEntity = userRepo.findByUsername(principal.getUsername())
.orElseThrow(() -> new UserException.UserNotFoundException("User not found"));
if (userEntity.getUserRole().getRoleName().equals("user")) {
// check if the transaction pin is correct
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
if (!encoder.matches(transactionEntity.getTransactionPin(), userEntity.getTransactionPin())) {
transactionEntity.setStatus(StatusType.FAILED);
transactionRepo.save(transactionEntity);
throw new TransactionException.InvalidCredentials("Invalid transaction pin");
}
}
return true;
}
private Map<String, Double> processTransaction(TransactionEntity createdTransactionEntity) {
// Update the sender's and receiver's balances
UserEntity sender = createdTransactionEntity.getSender();
UserEntity receiver = createdTransactionEntity.getReciever();
double updatedSenderBalance = userService.decreaseBalance(sender.getId(), createdTransactionEntity.getAmount());
double updatedReceiverBalance = userService.increaseBalance(receiver.getId(), createdTransactionEntity.getAmount());
// Update the transaction status to COMPLETED
createdTransactionEntity.setStatus(StatusType.COMPLETED);
transactionRepo.save(createdTransactionEntity);
Map<String, Double> balances = new HashMap<>();
balances.put("senderBalance", updatedSenderBalance);
balances.put("receiverBalance", updatedReceiverBalance);
return balances;
}
public TransactionEntity createTransaction(TransactionEntity transactionEntity) throws JsonProcessingException {
// Initialize transaction
initializeTransaction(transactionEntity);
System.out.println("initialized successfully");
// Log audit for transaction initiation
logTransactionAudit(transactionEntity, null, objectMapper.writeValueAsString(transactionEntity), ActionType.CREATE);
System.out.println("log transaction audit");
// Validate transaction input
validateTransactionInputAndLogAudit(transactionEntity);
System.out.println("validated transaction input");
// Authenticate transaction
authenticateTransactionAndLogAudit(transactionEntity);
System.out.println("authenticated transaction");
// Pre-process transaction
preProcessTransactionAndLogAudit(transactionEntity);
System.out.println("pre-processed transaction");
// Process transaction
processTransactionAndLogAudit(transactionEntity);
System.out.println("processed transaction");
transactionEntity.setTransactionDate(new Date());
// create two transaction receipts for the sender and receiver
TransactionReceiptEntity senderReceipt = TransactionReceiptEntity.builder()
.transaction(transactionEntity)
.transactionType("DEBIT")
.transactionDate(transactionEntity.getTransactionDate())
.amount(transactionEntity.getAmount())
.previousBalance(transactionEntity.getSender().getBalance() +
transactionEntity.getAmount())
.newBalance(transactionEntity.getSender()
.getBalance())
.user(transactionEntity.getSender())
.description("Debit transaction")
.status("COMPLETED")
.reference(transactionEntity.getId())
.build();
transactionReceiptRepo.save(senderReceipt);
TransactionReceiptEntity receiverReceipt = TransactionReceiptEntity.builder()
.transaction(transactionEntity)
.transactionType("CREDIT")
.transactionDate(transactionEntity.getTransactionDate())
.amount(transactionEntity.getAmount())
.previousBalance(transactionEntity.getReciever().getBalance() -
transactionEntity.getAmount())
.newBalance(transactionEntity.getReciever()
.getBalance())
.user(transactionEntity.getReciever())
.description("Credit transaction")
.status("COMPLETED")
.reference(transactionEntity.getId())
.build();
transactionReceiptRepo.save(receiverReceipt);
return transactionEntity;
}
private void initializeTransaction(TransactionEntity transactionEntity) {
System.out.println("transactionEntity: " + transactionEntity);
transactionEntity.setTransactionDate(new Date());
transactionEntity.setStatus(StatusType.PENDING);
transactionRepo.save(transactionEntity);
transactionEntity.setStatus(StatusType.INITIATED);
}
private void logTransactionAudit(TransactionEntity transactionEntity, String oldData, String newData, ActionType action) throws JsonProcessingException {
TransactionAuditEntity transactionAuditEntity = TransactionAuditEntity.builder()
.transaction(transactionEntity)
.oldData(oldData)
.newData(newData)
.action(action)
.performedAt(new Date())
.performedBy(transactionEntity.getSender())
.build();
transactionAuditService.createTransactionAudit(transactionAuditEntity);
}
private void validateTransactionInputAndLogAudit(TransactionEntity transactionEntity) throws JsonProcessingException {
logTransactionAudit(transactionEntity,
objectMapper.writeValueAsString(Map.of("status", "INITIATED")),
objectMapper.writeValueAsString(Map.of("status", "VALIDATING")),
ActionType.VALIDATE);
if (validateTransactionInput(transactionEntity)) {
logTransactionAudit(transactionEntity,
objectMapper.writeValueAsString(Map.of("status", "VALIDATING")),
objectMapper.writeValueAsString(Map.of("status", "VALIDATED")),
ActionType.VALIDATE);
}
}
private void authenticateTransactionAndLogAudit(TransactionEntity transactionEntity) throws JsonProcessingException {
logTransactionAudit(transactionEntity,
objectMapper.writeValueAsString(Map.of("status", "VALIDATED")),
objectMapper.writeValueAsString(Map.of("status", "AUTHENTICATING")),
ActionType.AUTHENTICATE);
if (validateTransactionPin(transactionEntity)) {
logTransactionAudit(transactionEntity,
objectMapper.writeValueAsString(Map.of("status", "AUTHENTICATING")),
objectMapper.writeValueAsString(Map.of("status", "AUTHENTICATED")),
ActionType.AUTHENTICATE);
}
}
private void preProcessTransactionAndLogAudit(TransactionEntity transactionEntity) throws JsonProcessingException {
boolean isTransactionReadyForProcessing = isBalanceSufficient(transactionEntity.getSender(), transactionEntity.getAmount()) && usersActive(
transactionEntity
);
logTransactionAudit(transactionEntity,
objectMapper.writeValueAsString(Map.of("status", "AUTHENTICATED")),
objectMapper.writeValueAsString(Map.of("status", "PRE-PROCESSING")),
ActionType.PRE_PROCESS);
if (isTransactionReadyForProcessing) {
logTransactionAudit(transactionEntity,
objectMapper.writeValueAsString(Map.of("status", "PRE-PROCESSING")),
objectMapper.writeValueAsString(Map.of("status", "PRE-PROCESSED")),
ActionType.PRE_PROCESS);
} else {
transactionEntity.setStatus(StatusType.FAILED);
transactionRepo.save(transactionEntity);
throw new TransactionException.InvalidTransactionException("Transaction not ready for processing");
}
}
private void processTransactionAndLogAudit(TransactionEntity transactionEntity) throws JsonProcessingException {
logTransactionAudit(transactionEntity,
objectMapper.writeValueAsString(Map.of("status", "PRE-PROCESSED")),
objectMapper.writeValueAsString(Map.of("status", "PROCESSING")),
ActionType.PROCESS);
try {
Map<String, Double> balances = processTransaction(transactionEntity);
logTransactionAudit(transactionEntity,
objectMapper.writeValueAsString(Map.of("status", "PROCESSING")),
objectMapper.writeValueAsString(Map.of("status", "PROCESSED", "senderBalance", balances.get("senderBalance"), "receiverBalance", balances.get("receiverBalance"))),
ActionType.PROCESS);
} catch (Exception e) {
transactionEntity.setStatus(StatusType.FAILED);
transactionRepo.save(transactionEntity);
throw new TransactionException.BadTransaction("An error occurred during transaction");
}
}
public List<TransactionEntity> getTransactions() {
return StreamSupport.stream(transactionRepo.findAllByOrderByTransactionDateDesc().spliterator(), false).toList();
}
public TransactionEntity getTransaction(String id) {
return transactionRepo.findById(id).orElse(null);
}
}
TransactionAuditService.java
package com.samuel.bankapi.services;
import com.samuel.bankapi.models.entities.TransactionAuditEntity;
import com.samuel.bankapi.repositories.TransactionAuditRepo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class TransactionAuditService {
@Autowired
TransactionAuditRepo transactionAuditRepo;
public TransactionAuditEntity createTransactionAudit(TransactionAuditEntity transactionAuditEntity) {
return transactionAuditRepo.save(transactionAuditEntity);
}
}
TransactionReceiptService.java
package com.samuel.bankapi.services;
import java.util.List;
import java.util.stream.StreamSupport;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.samuel.bankapi.models.entities.TransactionEntity;
import com.samuel.bankapi.models.entities.TransactionReceiptEntity;
import com.samuel.bankapi.models.entities.UserEntity;
import com.samuel.bankapi.repositories.TransactionReceiptRepo;
@Service
public class TransactionReceiptService {
@Autowired
private TransactionReceiptRepo transactionReceiptRepo;
public List<TransactionReceiptEntity> getReceipts() {
return StreamSupport.stream(transactionReceiptRepo.findAll().spliterator(), false).toList();
}
public TransactionReceiptEntity getReceipt(TransactionEntity transaction, UserEntity user) {
return transactionReceiptRepo.findByTransactionAndUser(transaction, user);
}
}
1 Answer 1
Transactions - don't Reinvent the Wheel
Your implementation of money transfer between accounts is not atomic, there's nothing in the code that will prevent two concurrent threads from updating simultaneously the balance of the same account.
Instead, you should be using database transactions, which are sequences of operations which either succeeds or fails atomically (don't conflate with financial transactions you're trying to model). That's the tool you need to master to ensure data integrity in your relational database.
And you should also learn how to commit and rollback transactions with JDBC and with a JPA implementation such as Hibernate. You'll need all these if you wish to understand what Spring Data JPA is doing behind the scenes.
One might think they are wouldn't be dealing with this low-level stuff if they are using Spring Data JPA, but that's not true.
In terms of code, all that you need is only to define a two-line method updating the two accounts annotated with @Transactional
. That's it.
Under that hood via the means of AOP Spring will create an EntityManager
instance for this method and start a new transaction which will be committed after the method exits.
If you want to learn more, that's your homework, there's plenty of material on the topic
Exception-handling / Swallowed Exceptions
There are several occurrences of the following pattern in your controllers:
try {
// some logic
} catch (Exception e) {
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
This approach is detrimental, because by doing so you're swallowing any type of exception, hiding potential RuntimeException
s caused by bugs. And you don't want this to happen, instead you want to log such cases in order to be able to investigate them.
I see some of the methods you're invoking within these try
statements could throw business exceptions.
That is not the right way to handle business exceptions, either. You should choose the most appropriate response status code for each custom exception, rather than returning HTTP status 500, "Internal Server Error", which indicates an unexpected condition on the server side and is not suitable for describing application-specific exceptions.
By the way, any uncaught exception in a Spring controller will result in an API response with 500 "Internal Server Error" status by default
The proper way to handle exceptions thrown in controllers (and other layers on which controllers depend) when you're doing Spring, is to use @ControllerAdvice
. It allows you to create a global exception-handler, generating appropriate HTTP
-responses based on the exception types.
Here's a quick example:
@ControllerAdvice
public class GlobalExceptionHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// Handlers for each Business Exception producing appropriate responses
@ExceptionHandler(Exception.class)
ResponseEntity<String> logNonBusiness(Exception e) {
LOGGER.error("Unexpected Non-Business Exception: ", e);
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
And since you're already using Lombok, instead of defining logger manually, you can annotate this class with @Slf4j
which will generate a static logger for you.
The aspect behind the @ControllerAdvice
will always pick the most specific handler from matching the thrown exception. And only when there's no other matches, it will resort to the handler-method for Exception.class
.
Logging
By default, Spring Boot comes with logging facade SLF4J and Logback logging framework. You don't need to add any extra dependencies in order to try out the code I have shown above.
And a preconfigured default console appender is already available to you out-of-the-box. There's no justification to use System.out.println
instead of proper logging.
Input Validation
Basic validation should happen in the system boundary, in your DTOs. There's no point in propagating invalid data (e.g. : transaction amount is negative or less than minimal amount, sender and recipient accounts are the same). Instead of creating entities based on invalid data and calling services, you should throw and reply with 400 bad request right away.
You can make use Jakarta Bean Validation to validate the parameters of incoming DTOs.
Naming
Ctrl
- isn't a widely recognized abbreviation.
It doesn't make this controller names more readeable TransactionReceiptCtrl
, TransactionCtrl
. By skimping on characters, you're only adding more cognitive load.
StatusType
is not a very descriptive name for a standalone type. TransactionStatus
will communicate its intent better.
Data Types
Torben and Marvin also shared their thoughts on this, I suggest checking their comments
java.util.Date
->java.time.Instant
Don't use legacy date-time classes, and it's still there only for backward compatibility reasons.
java.util.Date
was superseded java.time.Instant
which represents a specific moment on the timeline in UTC.
And all ORM frameworks support Java 8 date-time classes for quite a while, there's no reason to use java.util.Date
or java.sql.Date
.
- Monetary values
Floating point arithmetic is inaccurate, any operation on double
values might result in a precision loss.
Although, floating point values can be persisted into database (databases have their equivalents of floating point types) or serialized for sending over network without loss of accuracy; and in the code you presented, you're not performing calculations on these double
values, ultimately you want to do so and double
is unsuitable for that purpose.
To represent monetary values you should be using either BigDecimal
or custom type encapsulating allocation logic and rounding rules (so that no cents are lost, instead, one party receives an extra cent while the fractions in the amounts received by others are dropped, keeping the total amount unchanged) as Martin Fowler suggested decades ago in his book "Patterns of Enterprise Application Architecture".
java.util.Date
in favor of the classes from thejava.time
package. \$\endgroup\$