Search code examples
javaspring-bootspring-data-jpa

How to catch check for Uniqueness in Spring Boot 3


In my Spring Boot 3 project with hibernate 6 I am trying to check the a name is not in use before updating (and saving) a entity.

I want to leave the method annotated with @Transactional and I want to be able to pass a message to the UI if the update/save fails.

There is a number of existing answers, but they all have issues. Here is what I have tried:

1. Catch DataIntegtrityException

This way throws a custom exception - the con is that it doesn't work with @Transactional

    //@Transactional - doesn't catch exception with @Transactional
    public CarCreateDto updateCar(CarCreateDto updateCarDto, Long id) {
        Car Car = CarRepository.findById(id).orElseThrow(() -> new EntityNotFoundException("Car with " + id + " not found"));
        try {
            CarMapper.partialUpdate(updateCarDto, Car);
            CarRepository.save(Car);
            return CarMapper.toDto(Car);
        } catch (DataIntegrityViolationException e) {
            throw new CarAlreadyExistsException("Car with name already exist");
        }
    }

2. Catch DataIntegtrityException with saveAndFlush

Same as above but uses saveAndFlush and works with @Transactional. Can't find the reference but the con is that saveAndFlush is not good to use.

    @Transactional
    public CarCreateDto updateCar(CarCreateDto updateCarDto, Long id) {
        Car Car = CarRepository.findById(id).orElseThrow(() -> new EntityNotFoundException("Car with " + id + " not found"));
        try {
            CarMapper.partialUpdate(updateCarDto, Car);
            CarRepository.save(Car);
            return CarMapper.toDto(Car);
        } catch (DataIntegrityViolationException e) {
            throw new CarAlreadyExistsException("Car with name already exist");
        }
    }

3. Run a pre-save check on the database

The con here is a extra call to the database. Also potential for concurrency issues.

    @Transactional
    public CarCreateDto updateCar(CarCreateDto updateCarDto, Long id) {
    Car Car = CarRepository.findById(id).orElseThrow(() -> new EntityNotFoundException("Car with " + id + " not found"));
    boolean samename = updateCarDto.getName().equalsIgnoreCase(Car.getName());
    if (samename || !samename && !CarRepository.existsByName(updateCarDto.getName())) {
        CarMapper.partialUpdate(updateCarDto, Car);
        CarRepository.save(Car);
        return CarMapper.toDto(Car);
    } else {
        throw new DatabaseUniqueConstraintException("Car with name " + updateCarDto.getName() + " already exists");
    }
}

4. Put the catch on the controller

This just feels wrong - not a controller responsibility to catch DB exceptions.

@PutMapping(value = "/{id}", consumes = "application/json")
ResponseEntity<CarCreateDto> updateCarDto(@Valid @RequestBody CarCreateDto updateCar, @PathVariable Long id) {
    System.out.println("wait");
    try {
        CarCreateDto Car = CarService.updateCar(updateCar, id);
        return new ResponseEntity<>(Car, HttpStatus.OK);
    } catch (DataIntegrityViolationException e) {
        throw new CarAlreadyExistsException("Car with name already exist");
    }
}

So what is the correct way to handle this exception for a uniqueness check?


Solution

  • tl;dr: Use option 2.

    Let's take a look at the CrudRepository::save() and JpaRepository::saveAndFlush() implemented in SimpleJpaRepository: First, our goal is talking to the database and getting an immediate response (constraint violation or success) before going over the catch block. Isolated on our goal saveAndFlush() with @Transactional has same behaviour as save() without @Transactional. As long as you don't open a transaction the database is called directly when executing save(). But if you start a transaction and use save() the database is queried at a later stage (when you flush or commit). So the save()-method doesn't return an exception.

    This means that saveAndFlush() is the right way to use here because it has the same effect like save() without @Transactional. If saveAndFlush() would be "not good to use", it would also be save().

    As you want to call the database directly I strongly recommend you to use saveAndFlush() here:

    @Transactional
        public CarCreateDto updateCar(CarCreateDto updateCarDto, Long id) {
            Car Car = CarRepository.findById(id).orElseThrow(() -> new EntityNotFoundException("Car with " + id + " not found"));
            try {
                CarMapper.partialUpdate(updateCarDto, Car);
                CarRepository.saveAndFlush(Car);
                return CarMapper.toDto(Car);
            } catch (DataIntegrityViolationException e) {
                throw new CarAlreadyExistsException("Car with name already exist");
            }