The Result
class provides a functional approach for managing results from calls that may fail.
<dependency>
<groupId>com.tiggee.commons</groupId>
<artifactId>result</artifactId>
<version>0.1.0</version>
</dependency>
For example, given
public class MyUserRepo {
public Result<Account> accountFrom(final String username) { ... }
}
public class MyInvoiceRepo {
public Result<Invoice> invoiceFor(final long accountId, final LocalDate billingPeriodStart) { ... }
}
public class MyPaymentProcessor {
public Result<Payment> processPayment(final long invoiceId, final BigDecimal amount) { ... }
}
and our task is to process the payment for the user's account, for the balance in the invoice. How this
would look when using the Result
class is shown below.
public class Main {
public Result<Payment> processPaymentFor(final String username) {
return accountRepo.accountFrom(username)
.andThen(account -> invoiceRepo.invoiceFor(account.accountId(), now()))
.andThen(invoice -> processor.processPayment(invoice.invoiceId(), invoice.balance()));
}
}
Without the Result
class, you would need to check the result, catch exceptions, and wrap results
into if
statements. When assuming that the caller to Main.processPaymentFor(...)
will handle
all the exceptions, then we can have this. But now we have pushed all that error handling logic up
to the caller.
public class Main {
public Payment processPaymentFor(final String username) {
Account account = accountRepo.accountFrom(username);
if(Objects.nonNull(account)) {
Invoice invoiceRepo.invoiceFor(account.accountId(), now());
if(Objects.nonNull(invoice)) {
return processor.processPayment(invoice.invoiceId, invoice.balance());
}
else {
throw new InvoiceNotFoundException();
// or
// return Payment.empty()
}
}
else {
throw new AccountNotFoundException();
// or
// return Payment.empty()
}
}
}
When the account is not found or is null
, then we need to either throw an exception, or return an empty
Payment
object. Neither of these are ideal, and maintaining consistency throughout the code becomes
cumbersome.
What the Result
class does is push the logic for handling exceptions down to the methods that are the
source of the exceptional conditions. For example, when accessing a database, the repository method
catches all the database exceptions, and creates a Result
object that wraps the result. The logic
for handling the exceptions is now where it should be, and the caller only needs to worry about success;
failures can be passed on, letting upstream code now that the call resulted in a failure.
In the Result
example above, the call to accountFrom(username)
returns a Result<Account>
. When
an account is successfully found for the username, then that account is wrapped in the result. On
the other hand, if the account is not found, or if there is a database issue, or if more than one
account is returned, then the result wraps the failure.
The result.andThen(...)
method only executes the function specified in the argument if the result
on which it is being called is a success. Otherwise, it's type is mapped to match the same type that
the function returns. In this way, in the above example, even if the account cannot be found, a
Result<Payment>
is returned, and that result is a failure.
The sample code below shows an example of a repository that returns a product based on a product ID.
public class MyOtherRepo {
public Result<Product> productFor(final long productId) {
try {
final ProductsDao dao = entityManager
.createQuery(
"select product from ProductsDao as product where product.id = :" + PRODUCT_ID,
ProductsDao.class
)
.setParameter(PRODUCT_ID, productId)
.getSingleResult();
return Result.<Product>builder().success(convertToProduct(dao)).build();
}
catch(NoResultException e) {
return Result.<Product>builder()
.notFound("Unable to find the product with the requested ID")
.addMessage("product_id", productId)
.addMessage("exception", e.getMessage())
.build();
}
catch(NonUniqueResultException e) {
return Result.<Product>builder()
.indeterminant("More than one product with the requested ID exists (should never happen)")
.addMessage("product_id", productId)
.addMessage("exception", e.getMessage())
.build();
}
catch(PersistenceException e) {
return Result.<Product>builder()
.failed("Unable to retrieve requested product")
.addMessage("product_id", productId)
.addMessage("exception", e.getMessage())
.build();
}
}
}
In the next sections we describe how to use Result
. Broadly speaking, to use a result, you
will need to know how to
- construct the result,
- query the result for status, values, and messages
- transform results
- chain results
- create and manage transactional boundaries
A Result
has three parts.
status
- the status can be success, failed, bad request, indeterminant, not found, connection failed.value
- the value wrapped by theResult
messages
- messages describing the outcome of the result
And there are some rules
- Every success
Result
must have a value. - Every non-success
Result
must have anerror
message.
And there are some basic conventions
- Unsuccessful outcomes do not have a value.
When an outcome fails, the action does not result in a value. Therefore, unsuccessful
Result
s do not have a value. - Messages are not intended to hold the outcome of a success.
Messages should merely be informational. The outcome of a successful action should be
encapsulated in the
Result
value. A successful outcome, may be enhanced by some informational messages. - Unsuccessful outcomes should be explained.
When an action fails, the
Result
should have messages explaining the failure and provide relevant state information to help understand the failure.
The Result
is constructed with using a builder that helps manage the rules and conventions listed
in the previous section. To construct a Result
describing an action's successful outcome, we can
use the builder's success(...)
method. The following example shows how to create a Result
that
wraps a Product
, and represents the successful outcome of, say, retrieving a product from some
data store.
final Product desiredProduct = ...;
final Result<Product> result = Result.<Product>builder().success(desiredProduct).build();
Notice that the success(...)
method expects a Product
. Generally, the generic type, T
, of the
Result<T>
is the argument required by the success(final T value)
method.
Suppose the desired product was not found in our data store. In this case, we can represent this in one of two ways. We can treat this as a failure, or as a not-found.
final Product desiredProduct = ...; // not found
final Result<Product> failed = Result.<Product>builder()
.failed("Product not found")
.addMessage("product_id", productId)
.build();
// or
final Result<Product> notFound = Result.<Product>builder()
.notFound("Product not found")
.addMessage("product_id", productId)
.build();
In both cases, the Result
represents the fact that the outcome was not successful. The only
difference in the above two Result
s is the status: in the first case it will be Result.Status.FAILED
and in the second case it will be Result.Status.NOT_FOUND
.
When a method call returns a Result
, we need to be able to query that result to determine whether
the outcome was a success, and if so, get the value. Or, if the outcome failed, what type of failure
and why did it fail.
Recall the MyOtherProduct.productFor(final long productId)
method from earlier. This method returns
a Result<Product>
, and specifically, captures four possible outcomes.
success
- When the product is found based on its product ID, then returns theProduct
.not found
- When no product is found with the specified product ID, then returns the statusResult.Status.NOT_FOUND
and three messages: the requirederror
message; the requested product ID; and, the message from the caught exception.indeterminant
- When more than one product is found with the "unique" ID, then returns the statusResult.Status.INDETERMINANT
and three messages: the requirederror
message; the request product ID; and, the message from the caught exception.failed
- When there is a persistence exception other than the two preceding it, then returns the statusResult.Status.FAILED
and, again, three messages: the requirederror
message; the request product ID; and, the message from the caught exception. In this case, the exception message may supply us with relevant information. For example, maybe we couldn't connect to the database, or was the SQL invalid, etc.
The most basic way to determine the status of a Result
is the status()
method. For example, suppose
we have a method that returns a Result<Product>
based on a specified product ID.
public Result<Product> productFor(final long productId) {}
final Product desiredProduct = ...;
return Result.<Product>builder().success(desiredProduct).build();
}
Then we can call that method and query the outcome status.
// ...
final Result<Product> result = productFor(314);
if(result.status() == Result.Status.SUCCESS) {
// do something
}
else {
// do something else
}
Alternatively, you could call the isSuccess()
method which requires that a value has been set.
// ...
final Result<Product> result = productFor(314);
if(result.isSuccess()) {
// do something
}
else {
// do something else
}
In many cases, we want the value when successful, or some default value when it failed.
final Product product = productFor(314).orElse(Product.empty());
// or
final Product product = productFor(314).orElseGet(() -> Product.empty());
// or
final Product product = productFor(314).orElseGet(result -> {
LOGGER.warn("Product not found; messages: {}", result.messages());
return Product.empty();
});
You may want to perform an action, only when the outcome succeeded, but keep the original Result
.
final Result<Product> result = productFor(314)
.onSuccess(product -> LOGGER.info("Got my product; product ID: {}", product.productId()));
// or
productFor(314)
.ifSuccess(product -> LOGGER.info("Got my product; product ID: {}", product.productId()));
In the first case the result
returned from the onSuccess(...)
method is a reference to the
Result
returned from productFor(314)
, and we logged the fact that the product was successfully
retrieved. In the second case, the ifSuccess(...)
method does not return anything, and the message
is only logged.
You may also want to do something based on the result being a success, and the value of the result satisfying some condition.
if(productFor(314).satisfies(value -> value > 100 * Math.PI)) { ... }
The satisfies(...)
methods accepts a Predicate
and returns the result of evaluating the result's
value against the predicate.
The Result
class provides a number of methods for querying the results. Please see the java docs.
The power of the Result
class comes from its ability to map, flat-map, and chain results.
The Result
class is a monad that provides map and flat-ap (andThen) operations.
Recall the earlier code snippet.
public class Main {
public Result<Payment> processPaymentFor(final String username) {
return accountRepo.accountFrom(username)
.andThen(account -> invoiceRepo.invoiceFor(account.accountId(), now()))
.andThen(invoice -> processor.processPayment(invoice.invoiceId(), invoice.balance()));
}
}
Here we get a Result<Account>
from the accountFrom(...)
method. We then do a flat-map operation
on the Result<Invoice>
returned from the invoiceFor(...)
method, which results in a Result<Invoice>
.
And then, we do another flat-map operation on the Result<Payment>
returned from the processPayment(...)
method, which ultimately returns a Result<Payment>
. If any of the steps fail, the final Result<Payment>
represents a failure, but no matter which result failed, a Result<Payment>
is always returned.
Suppose the call to accountFrom(...)
failed because the username didn't exist. In this case, neither
the invoiceFor(...)
method nor the processPayment(...)
would be called. Rather, they would be
short-circuited, and a failure Result<Payment>
would be returned.
The result values can also be mapped. For a contrived example, suppose in the code snippet above, you would like
to return Result<Account>
rather than the Result<Payment>
. The code snippet below shows how.
public class Main {
public Result<Account> processPaymentFor(final String username) {
return accountRepo.accountFrom(username)
.andThen(account -> invoiceRepo.invoiceFor(account.accountId(), now())
.andThen(invoice -> processor.processPayment(invoice.invoiceId(), invoice.balance()))
.map(payment -> account)
);
}
}
In this case, we need to keep account
in scope for the map(...)
function, and then just simply
map the payment value to the account value.
Cases arise where the status of the result determines that transformation. The Result
class provides
a variant of the andThen(...)
method that accepts two functions, the first is called when the status is
a success, and the second is called when the status is not a success.
public class Main {
public Result<Payment> processPaymentFor(final String username) {
return accountRepo.accountFrom(username)
.andThen(account -> invoiceRepo.invoiceFor(account.accountId(), now()))
.andThen(invoice -> processor.processPayment(invoice.invoiceId(), invoice.balance()))
.andThen(
payment -> audit.log(payment).map(logged -> payment), // payment succeeded
result -> audit.logFailure(result).map(logged -> payment) // payment failed
);
}
}
In this case, if the payment was successfully processed, then it is logged. Otherwise, the failure is
logged. The above code snippet makes the assumption that the calls to log the payment or failure,
both return a Result<T>
, and therefore we can map that result back to the required Payment
object.
Basing the transformation of a successful result on the value is also a common need. The Result
class
provides a four meetsCondition(...)
methods for this use case. The meetsCondition(...)
methods
all have the same semantics, but differ on whether the arguments are suppliers or functions.
The meetsCondition(...)
methods are defined by meetsCondition = f(predict, meetsPredicate, doesNotMeetPredicate): result
.
The function takes a predicate that it applies against the value of the result, and then if the value
meets the predicate, calls the meetsPredicate
function (or supplier). If the value doesn't meet
the predicate, then calls the doesNotMeetPredicate
function (or supplier).
public class Main {
public Result<Payment> processPaymentFor(final String username) {
return accountRepo.accountFrom(username)
.andThen(account -> invoiceRepo.invoiceFor(account.accountId(), now()))
.meetsCondition(
invoice -> invoice.balance() > 0,
invoice -> processor.processPayment(invoice.invoiceId(), invoice.balance()),
() -> Result.<Payment>builder().success(Payment.empty()).build()
);
}
}
In the above example, if the invoice has no balance, then there is no need to process the payment, and instead, we can just return a success result wrapping an empty payment. The four variations are shown below and provide combinations of suppliers and functions.
Result<R> meetsCondition(
Predicate<T> predicate,
Supplier<Result<R>> predicateMet,
Supplier<Result<R>> predicateNotMet) {...}
Result<R> meetsCondition(
Predicate<T> predicate,
Function<T, Result<R>> predicateMet,
Function<T, Result<R>> predicateNotMet) {...}
Result<R> meetsCondition(
Predicate<T> predicate,
Supplier<Result<R>> predicateMet,
Function<T, Result<R>> predicateNotMet) {...}
Result<R> meetsCondition(
Predicate<T> predicate,
Function<T, Result<R>> predicateMet,
Supplier<Result<R>> predicateNotMet) {...}
Note that in the above code snippet, T
is the type of the result's value, and R
is the type
of the value that is returned by the meetsCondition(...)
method. In the example code snippet
the meetsCondition(...)
method has a Predicate<Invoice>
as the predicate, which checks to
see if a balance is due on the invoice. It has a Function<Invoice, Result<Payment>>
that is
called if there is a balance on the invoice. And It has a Supplier<Result<Payment>>
that is
called when there is no balance on the invoice.
The Result
class provides a generalized mechanism for managing transaction boundaries across
chained results. In this way, the transactions can span multiple data sources, and, for example,
roll-backs can be tailored to the specifics of your code.
There are two transaction(...)
methods, which are essentially them same, exception that one has
takes a predicate that determines whether the call should be transactional. The following code
snippet shows the two functions.
public class Result<T> {
public <V> Result<V> transaction(Supplier<Result<V>> boundedFunction,
Function<T, Result<Boolean>> commitFunction,
Function<T, Result<Boolean>> rollbackFunction) {...}
public <V> Result<V> transaction(Predicate<T> transactional,
Supplier<Result<V>> boundedFunction,
Function<T, Result<Boolean>> commitFunction,
Function<T, Result<Boolean>> rollbackFunction) {...}
}
The first transaction(...)
method accepts a bounded function that supplies a result with a value of
type V
. It then accepts two functions:
- a commit function that is called when the bounded function returns a success, and
- a roll-back function that is called when the bounded function returns a failure.
The bounded function defines the transaction boundary. Consider
public class Main {
public Result<Payment> processPaymentFor(final String username) {
return Transaction.newInstance(31415, true)
.transaction(
() -> accountRepo.accountFrom(username)
.andThen(account -> invoiceRepo.invoiceFor(account.accountId(), now()))
.andThen(invoice -> processor.processPayment(invoice.invoiceId(), invoice.balance())),
Transaction::commit,
Transaction::rollback
);
}
}
where a mock Transaction
class is defined below (generally you can use Spring's transactions, or create ones for,
say Cassandra that provide compensating queries for the rollback).
public class Transaction {
private final boolean isNewTransaction;
private final String transactionId;
private Transaction(final String id, final boolean isNew) {
this.transactionId = id;
this.isNewTransaction = isNew;
}
public static Result<Transaction> newInstance(final String id, final boolean isNew) {
return Result.<Transaction>builder().success(new Transaction(id, isNew)).build();
}
public boolean isNew() {
return isNewTransaction;
}
public Result<Boolean> commit() {
return Result.<Boolean> builder().success(true).build();
}
public Result<Boolean> rollback(final boolean willSucceed) {
return Result.<Boolean> builder().success(true).build();
}
}
In the code example, if all the operations are successful, the commit()
function is called, otherwise,
the rollback()
function is called. These functions can be more complex functions, as needed.
To use Spring's transactions, you'll need to create a simple transaction factory that interacts
with Spring's PlatformTransactionManager
. The commit(TransactionStatus status)
function would then call the
PlatformTransactionManager.commit(TransactionStatus status)
method, and then create a Result
to return.
Our example, would be
public class Main {
public Result<Payment> processPaymentFor(final String username) {
return transactionFactory.newOrExistingTransaction("process-payment")
.transaction(
TransactionStatus::isNewTransaction,
() -> accountRepo.accountFrom(username)
.andThen(account -> invoiceRepo.invoiceFor(account.accountId(), now()))
.andThen(invoice -> processor.processPayment(invoice.invoiceId(), invoice.balance())),
transactionFactory::commit,
transactionFactory::rollback
);
}
}
where the transactionFactory.newOrExistingTransaction(...)
returns Spring's TransactionStatus
, and that
TransactionStatus
is passed to the transactionFactory.commit(TransactionStatus status)
and
transactionFactory.rollback(TransactionStatus status)
methods.