springspring-dataspring-data-jdbc

My Custom Repository returns a vavr Try. Why does Spring Data jdbc wrap it in another vavr.Try?


I'm using spring boot 3.4.3.

I am trying to figure out best practices on how to use Spring, and I want to be able to return errors as types instead of throwing exceptions. That is my premise. If it turns out it's impossible to do that with Spring, I will have to find a way to live with that. However, i have been able to read code in the actual framework that encourages that.

For example, the Transaction Advice does nicely catch transactional methods that return a Try and roll back the transaction if the returned Try instance is a Failure.

However I'm very surprised by the following behavior on the Custom repository fragments.

A little bit of code:

public interface OrderRepository extends ListCrudRepository<Order, Integer>, CustomOrderRepository {}

public interface CustomOrderRepository {
    public Try<String> tryUpdateStatus(Integer orderId, String newStatus);
    public String updateStatus(Integer orderId, String newStatus);
}

The implementation does not really matter for this. I can show the client code, though:

    public Try<String> outOfStock(Integer orderId) {
        return orderRepository.updateStatus(orderId, "OUT_OF_STOCK");
    }

When I run my tests, I find that my client code is receiving a Try<Try<String>>

It looks like Spring is wrapping my result in a Try.

Some of the code I have been able to find seems to confirm it:

In org.springframework.data.repository.util.QueryExecutionConverters

    static {

        WRAPPER_TYPES.add(WrapperType.singleValue(Future.class));
        UNWRAPPER_TYPES.add(WrapperType.singleValue(Future.class));
        WRAPPER_TYPES.add(WrapperType.singleValue(ListenableFuture.class));
        WRAPPER_TYPES.add(WrapperType.singleValue(CompletableFuture.class));
        UNWRAPPER_TYPES.add(WrapperType.singleValue(ListenableFuture.class));
        UNWRAPPER_TYPES.add(WrapperType.singleValue(CompletableFuture.class));

        ALLOWED_PAGEABLE_TYPES.add(Slice.class);
        ALLOWED_PAGEABLE_TYPES.add(Page.class);
        ALLOWED_PAGEABLE_TYPES.add(List.class);
        ALLOWED_PAGEABLE_TYPES.add(Window.class);

        WRAPPER_TYPES.add(NullableWrapperToCompletableFutureConverter.getWrapperType());

        UNWRAPPERS.addAll(CustomCollections.getUnwrappers());

        CustomCollections.getCustomTypes().stream().map(WrapperType::multiValue).forEach(WRAPPER_TYPES::add);

        CustomCollections.getPaginationReturnTypes().forEach(ALLOWED_PAGEABLE_TYPES::add);

        if (VAVR_PRESENT) {

            // Try support
            WRAPPER_TYPES.add(WrapperType.singleValue(io.vavr.control.Try.class));
            EXECUTION_ADAPTER.put(io.vavr.control.Try.class, it -> io.vavr.control.Try.of(it::get));
        }
    }

If the VAVR library is detected, it will add an execution adapter to the org.springframework.data.repository.core.support.QueryExecutionResultHandler by going through the conversionService. I find this extremely odd.

If I get it right, it will modify the interface of my custom method and change the type it returns?

Am I missing something. Maybe I'm misusing the Custom repository ?

Could an experienced spring-data developer help out a novice trying to get their bearings?

Thanks in advance!

NB: If you need more code I'll be happy to provide it.

Update

A commenter asked for the implementation of updateStatus:

@Repository
class CustomOrderRepositoryImpl implements CustomOrderRepository {
    private final JdbcTemplate jdbcTemplate;

    public CustomOrderRepositoryImpl(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public String updateStatus(Integer orderId, String newStatus) {
        String sql = "UPDATE orders SET status = ? WHERE id = ?";
        var updated = jdbcTemplate.update(sql, newStatus, orderId);
        if (updated == 0) {
            throw ErrorContext.notFound("Order " + orderId + " not found");
        } else {
            return newStatus;
        }
    }

    public Try<String> tryUpdateStatus(Integer orderId, String newStatus) {
        return Try.of(() -> updateStatus(orderId, newStatus));
    }
}

Update 2 (Test code and test result)

Please not that this is not an issue with the test runtime, as I have the same problems in production code.

This is an integration test, running on a real database through and through.

@Subject(OrderRepository)
@SpringBootTest(classes = [SpringbootTemplateApplication])
@Transactional
class OrderRepositoryIntegrationSpec extends Specification{
    @Subject
    @Autowired
    private OrderRepository orderRepository


    def "It updates an order's status"() {
        given:
        def order = new Order(null, anyString(), "NEW", [])
        var saved = orderRepository.save(order)


        when:
        def newStatus = orderRepository.updateStatus(saved.id, "OUT_OF_STOCK")

        then:
        newStatus == "OUT_OF_STOCK"
    }

    def "It updates an order's status using tryUpdateStatus"() {
        given:
        def order = new Order(null, anyString(), "NEW", [])
        var saved = orderRepository.save(order)


        when:
        def attempt = orderRepository.tryUpdateStatus(saved.id, "OUT_OF_STOCK")

        then:
        attempt.onFailure {
            assert false: "Unexpected failure: ${it}"
        }.onSuccess { status ->
            assert status == "OUT_OF_STOCK"
        }.success
    }
}

When this Specificaation class runs, The first test succeeds, but the second fails, with the following error:

Condition not satisfied:

status == "OUT_OF_STOCK"
|      |
|      false
Success(OUT_OF_STOCK)

Expected :OUT_OF_STOCK
Actual   :Success(OUT_OF_STOCK)

Update 3 - This is looking more and more like a bug

I have added a method in the base repository:

public interface OrderRepository extends ListCrudRepository<Order, Integer>, CustomOrderRepository {
    @Modifying
    @Query("UPDATE orders SET status = :newStatus WHERE id = :orderId")
    public Try<Boolean> tryUpdateStatus2(Integer orderId, String newStatus);
}

And modified my test:


    def "It updates an order's status using tryUpdateStatus"() {
        given:
        def order = new Order(null, anyString(), "NEW", [])
        var saved = orderRepository.save(order)


        when:
        def attempt2 = orderRepository.tryUpdateStatus2(saved.id(), "OUT_OF_STOCK")

        then:
        attempt2.onFailure {
            assert false: "Unexpected failure: ${it}"
        }.onSuccess { success ->
            assert success
        }.success


        when:
        def attempt = orderRepository.tryUpdateStatus(saved.id, "OUT_OF_STOCK")

        then:
        attempt.onFailure {
            assert false: "Unexpected failure: ${it}"
        }.onSuccess { status ->
            assert status == "OUT_OF_STOCK" //FAILURE HERE
        }.success
    }

I'm not allowed to return Try from my custom repositories? If I do, I need to unwrap Try twice?

Update 4: Bug report and reproduction repo

I have created a reproduction github repo and a bug report in spring-data-commons.

Repo: https://github.com/luismunizsparkers/spring-data-jdbc-try

Issue report: https://github.com/spring-projects/spring-data-commons/issues/3257


Solution

  • I'm answering this myself with a workaround until the issue I created in the spring-data-commons project is closed. It has currently (2025-03-24) been assigned and has not yet been handled, one way or another.

    This is the issue:

    https://github.com/spring-projects/spring-data-commons/issues/3257

    In the meantime, I have created a class that will unwrap calls to repositories if they are returning Try<Try<T>> :

    public class QueryExecutionConverters {
        /**
         * Temporary workaround to issue https://github.com/spring-projects/spring-data-commons/issues/3257
         * (Anomalous behavior of spring-data custom repositories returning io.vavr.control.Try)
         *
         * @param resultFromSpringDataRepository potentially actually a Try<Try<T>>
         * @return unwraps the value if it is a Try
         * @param <T> The expected type of the value
         */
        public static <T> Try<T> fixbug(Try<T> resultFromSpringDataRepository) {
            if (resultFromSpringDataRepository.isSuccess()) {
                @SuppressWarnings("rawtypes")
                Try typeLess= resultFromSpringDataRepository;
                Object val = typeLess.get();
                if (Try.class.isAssignableFrom(val.getClass())) {
                    //noinspection unchecked
                    return (Try<T>) val;
                } else {
                    return resultFromSpringDataRepository;
                }
            } else {
                return resultFromSpringDataRepository;
            }
        }
    }
    
    

    This is how you'd use it, in a transactional facade service accessing a repository:

        public Try<String> outOfStock(Long orderId) {
            return QueryExecutionConverters.fixbug(
               orderRepository.tryUpdateStatus(orderId, "OUT_OF_STOCK")
            );
        }