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.
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));
}
}
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)
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?
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
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")
);
}