Introduction
Method call often returns some result. For example let's assume we have method which creates new object: Payment
. Now imagine Payment
creation fails because passed amount
is negative. In this case our method can just throw IllegalArgumentException
. So far we have two possible method results:
- returned
Payment
instance - thrown
IllegalArgumentException
.
It was a simple example, I will complicate it a little. Let's assume we already have Payment
instance, and it is a specific payment type - CardPayment
. New CardPayment
instance represents only intention to collect money. CardPayment
provides execute
method which calls external service to realize cash flow. What are possible results? Same as before we can get some object - information about successful payment. Method could also throw an Exception
if external service fails or it is not accessible (timeout for example). But CardPayment
can fail also for many business reasons: client has no fund on the card, payment exceeded single transaction amount limit, payment exceeded daily transactions amounts sum limit or card has been blocked. These results are regular and common situations, so they shouldn't be model by throwing an Exception
. Better choice is to design the execute()
method interface which covers all described cases. How to model many possible business results?
Disclaimer: I consider only method synchronous result. Asynchronous solutions like event publish/subscribe is
out of the scope of this post.
Problem
Let's summarize all possible results of described above CardPayment#execute
method:
- success execution - payment made correctly, result contains external system success payment id,
- client has no fund on the card - result contains datetime field named
nextTryAfter
, all future card payments before this date will be rejected by external service - payment exceeded single transaction maximum amount limit - result contains field
singleTransactionLimit
which is value of exceeded limit value, - payment exceeded daily transactions amounts sum limit - result contains field
dailySumLimit
which is value of exceeded limit value, - card has been blocked - result contains external system blocked card id.
Our goal is to model all possible business results of the execute
method.
Single class for all cases
In the simplest solution all above results can be designed with single class, for example:
public class Example1 {
record CardPaymentResult(String resultCode,
String successPaymentId,
LocalDateTime nextTryAfter,
BigInteger singleTransactionLimit,
BigInteger dailySumLimit,
String blockedCardId) {}
public void example1() {
// success execution - payment made correctly
new CardPaymentResult("PAYMENT_OK", "vanoh5ailuChay6p", null, null, null, null);
// client has no fund on the card,
new CardPaymentResult("CLIENT_HAS_NO_FUND", null, LocalDateTime.of(2021, 11, 30, 10, 0, 0), null, null, null);
// payment exceeded single transaction maximum amount limit,
new CardPaymentResult("SINGLE_TRANSACTION_LIMIT_EXCEEDED", null, null, BigInteger.valueOf(20000), null, null);
// payment exceeded daily transactions amounts sum limit,
new CardPaymentResult("DAILY_TRANSACTIONS_LIMIT_EXCEEDED", null, null,null, BigInteger.valueOf(50000), null);
// card has been blocked.
new CardPaymentResult("CARD_BLOCKED", null, null,null, null, "eoZi5chu");
}
}
In the above listing I use record
Java keyword introduced in Java 14. Thanks to it, the Java compiler auto generates getter methods, toString()
, hashcode()
and equals()
methods, so you don't have to write that boilerplate code yourself. Java record
is immutable, no setter methods are generated.
You can easily find disadvantages of this solution:
-
CardPaymentResult
has many fields and everyresultCode
can increase theirs number. - It is hard to predict which field are nulls.
CardPaymentResult
contains many nullable fields which presence depends onresultCode
. It is easy to make mistake and get famousNullPointerException
. Moreover, when you meetCardPaymentResult
reference in code you can't simply predict whatresultCodes
are possible in given context and which fields can be nulls. For example, you must discover context like "scheduling next payment after failure" to know that you are dealing withCLIENT_HAS_NO_FUND
,SINGLE_TRANSACTION_LIMIT_EXCEEDED
orDAILY_TRANSACTIONS_LIMIT_EXCEEDED
because for other resultCodes there is no sense to retry with new payment (PAYMENT_OK
- we already succeed, we don't need to retry,CARD_BLOCKED
- we are sure all future retries will fail). It is also possible you pass by mistake instance ofCardPaymentResult
withresultCode
which should never be present in given context. - In every case when you need to handle one
resultCode
in unique way you need to addif
condition to check if you deal with this uniqueresultCode
.
We definitely can design it better.
Class per case
Second solution is to create dedicated class for every result case.
public class Example2 {
interface Result {}
interface Results {
record PaymentOk(String resultCode, String successPaymentId) implements Result {}
record ClientHasNoFund(String resultCode, LocalDateTime nextTryAfter) implements Result {}
record SingleTransactionLimitExceeded(String resultCode, BigInteger singleTransactionLimit) implements Result {}
record DailyTransactionsLimitExceeded(String resultCode, BigInteger dailySumLimit) implements Result {}
record CardBlocked(String resultCode, String blockedCardId) implements Result {}
}
public void example2() {
// success execution - payment made correctly
new Results.PaymentOk("PAYMENT_OK", "vanoh5ailuChay6p");
// client has no fund on the card,
new Results.ClientHasNoFund("CLIENT_HAS_NO_FUND", LocalDateTime.of(2021, 11, 30, 10, 0, 0));
// payment exceeded single transaction maximum amount limit,
new Results.SingleTransactionLimitExceeded("SINGLE_TRANSACTION_LIMIT_EXCEEDED", BigInteger.valueOf(20000));
// payment exceeded daily transactions amounts sum limit,
new Results.DailyTransactionsLimitExceeded("DAILY_TRANSACTIONS_LIMIT_EXCEEDED", BigInteger.valueOf(50000));
// card has been blocked.
new Results.CardBlocked("CARD_BLOCKED", "eoZi5chu");
}
}
Pay attention that every possible result implements Result
interface. Moreover, all Result
descendant are grouped in Results
interface. Thanks to it, if you will start typing Example2.Results.
IDE will prompt with all possible Results.
Comparing to the previous solution:
- There are five classes with small number of fields instead of one big class with many fields. If new
resultCode
appears, instead of modifying one big class a new small class will be defined. It is compatible with principle "open for extension, closed for modification". - You are not dealing with null fields. In every case all fields are not-null. Moreover, you can use specific class
instead of general
Result
type to be sure that type with properresultCode
is used in given context. - You still will need to use
if
condition to handle specificresultCode
in unique way if you deal withResult
type. But now it is possible that you will deal with specific type likePaymentOk
, it won't be always generalResult
type. Moreover, Java 17 introduces pattern matching forswitch
condition, which improves code readability, for example:
public static class PatternMatchingExample {
public Message messageFor(Result result) {
return switch (result) {
case Results.PaymentOk v -> paymentOkMessage(v);
case Results.ClientHasNoFund v -> clientHasNoFund(v);
case Results.SingleTransactionLimitExceeded v -> singleTransactionLimitExceeded(v);
case Results.DailyTransactionsLimitExceeded v -> dailyTransactionsLimitExceeded(v);
case Results.CardBlocked v -> cardBlocked(v);
default -> defaultMessage();
};
}
}
Before Java 17 you probably would use sequence of instanceof
if
conditions:
public class InstanceOfExample {
public Message messageFor(Result result) {
if(result instanceof Results.PaymentOk) {
return paymentOkMessage((Results.PaymentOk) result);
}
else if(result instanceof Results.ClientHasNoFund) {
return clientHasNoFund((Results.ClientHasNoFund) result);
}
else if(result instanceof Results.SingleTransactionLimitExceeded) {
return singleTransactionLimitExceeded((Results.SingleTransactionLimitExceeded) result);
}
else if(result instanceof Results.DailyTransactionsLimitExceeded) {
return dailyTransactionsLimitExceeded((Results.DailyTransactionsLimitExceeded) result);
}
else if(result instanceof Results.CardBlocked) {
return cardBlocked((Results.CardBlocked) result);
}
else {
return defaultMessage();
}
}
}
Little note: some programmers say that using instanceof
keyword always breaks polymorphism concept. I disagree with this opinion. According to wikipedia:
In programming languages and type theory, polymorphism is the provision of a single interface to entities of different types[1] or the use of a single symbol to represent multiple different types.[2]The concept is borrowed from a principle in biology where an organism or species can have many different forms or stages.[3]
In this case you deal with classes which represent results, not a behaviors. Their goal is to carry data, not perform actions. They are not have any id, they just are simple data structures. Our classes are not entities, but value objects.
Other options...
Above solution is good for case when action has many results which contains different data (successPaymentId
or nextTryAfter
or singleTransactionLimit
or dailySumLimit
or blockedCardId
). But this solution not always fit best. If your component returns many results but their data are not needed, you can use enum type instead of a collection
of classes, for example:
public class Example3 {
public enum Result {
PAYMENT_OK, CLIENT_HAS_NO_FUND, SINGLE_TRANSACTION_LIMIT_EXCEEDED, DAILY_TRANSACTIONS_LIMIT_EXCEEDED, CARD_BLOCKED
}
public class PatternMatchingExample {
public Message messageFor(Result result) {
return switch (result) {
case PAYMENT_OK -> paymentOkMessage();
case CLIENT_HAS_NO_FUND -> clientHasNoFund();
case SINGLE_TRANSACTION_LIMIT_EXCEEDED -> singleTransactionLimitExceeded();
case DAILY_TRANSACTIONS_LIMIT_EXCEEDED -> dailyTransactionsLimitExceeded();
case CARD_BLOCKED -> cardBlocked();
default -> defaultMessage();
};
}
}
}
If your component returns only two different results you can use abstraction like Either
class from vavr.io
(javaslang
) library:
import io.vavr.control.Either;
public class Example4 {
record PaymentOk(String resultCode, String successPaymentId) implements Example2.Result {}
record PaymentFail(String resultCode, String failReason) implements Example2.Result {}
public Either<PaymentFail, PaymentOk> example4() {
//omitted code
return Either.right(new PaymentOk("PAYMENT_OK", "vanoh5ailuChay6p"));
}
}
Finally, if you have component with many results which have similar structure, the solution with single class for all cases (described at the beginning of this post) may fit best. You always can refactor it later when complexity will increase.
There just not exists the best solution for every case, everything depends on problem details. In this post I wanted to describe few approaches how you can model many business results. It is common problem and I hope you will feel a little more comfortable with it after this post.
All Java examples from this post you will find at my github.
Originally published at https://stepniewski.tech.
Top comments (1)
Modeling results involves establishing a systematic approach to analyze and interpret data, ensuring a comprehensive understanding of the outcome. Whether it's in the realm of statistics, scientific experiments, or business analytics, the process typically begins with defining clear objectives and selecting appropriate variables. Following data collection, various statistical or computational models are applied to derive meaningful insights and predictions. For instance, in the context of sports analytics, like in the assessment of Daily Results, this modeling could involve statistical methods to analyze player performance, team dynamics, and other relevant factors. The goal is to create a model that accurately represents the relationships within the data, enabling users to draw informed conclusions and make data-driven decisions. The iterative nature of the modeling process allows for refinement and optimization, ensuring the accuracy and reliability of the results obtained, be it in daily sports outcomes or any other analytical domain.