DEV Community

psstepniewski
psstepniewski

Posted on • Edited on

How to model result?

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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 every resultCode can increase theirs number.
  • It is hard to predict which field are nulls. CardPaymentResult contains many nullable fields which presence depends on resultCode. It is easy to make mistake and get famous NullPointerException. Moreover, when you meet CardPaymentResult reference in code you can't simply predict what resultCodes 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 with CLIENT_HAS_NO_FUND, SINGLE_TRANSACTION_LIMIT_EXCEEDED or DAILY_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 of CardPaymentResult with resultCode which should never be present in given context.
  • In every case when you need to handle one resultCode in unique way you need to add if condition to check if you deal with this unique resultCode.

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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 proper resultCode is used in given context.
  • You still will need to use if condition to handle specific resultCode in unique way if you deal with Result type. But now it is possible that you will deal with specific type like PaymentOk, it won't be always general Result type. Moreover, Java 17 introduces pattern matching for switch 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();
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
            };
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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"));
    }
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
eldridgekamryn06 profile image
eldridgekamryn

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.