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 .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 .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:
Issue report:
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 .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 .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/luismunizsparkers/spring-data-jdbc-try
Issue report: https://github/spring-projects/spring-data-commons/issues/3257
Share Improve this question edited Mar 20 at 10:20 Luis Muñiz asked Mar 19 at 9:33 Luis MuñizLuis Muñiz 4,8491 gold badge30 silver badges46 bronze badges 12 | Show 7 more comments1 Answer
Reset to default 1I'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/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/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")
);
}
updateStatus
method. I doubt the converters in Spring are the problem, what they do is return the result of a query method wrapped in aTry
if the response asks for it. It would also be interesting to see what your test does, do you use the actual repository or are you mocking things? – M. Deinum Commented Mar 19 at 10:51updateStatus
method. Spring Data uses reflection and default ways of executing a query. It will execute a query and based on the type requestedTry
in this case it will wrap the result of the query in aTry
. So what I suspect what is happening is that your custom method is not being excluded due to some missing annotation like@NoRepositoryBean
or something like that. – M. Deinum Commented Mar 19 at 14:08