java-retval

Java net.groboclown:retval Library

Library for Java 9+ programs to combine error messages and return values in a single return object, without using Exceptions.

Visit the project on GitHub.

Topics

User Guide

Introduction for Users

Why?

Sometimes, users want to know all the problems encountered by a program without going through the cycle of run, fix, run. Sometimes, you want to write code that is error aware without using exceptions to handle well known problem states.

Exceptions have their use, and this isn’t intended to replace them. However, they are limited in what they can do, and generally can make code less user-friendly by showing stack traces instead of human-readable messages.

What This Library Offers

The library has these fundamental classes:

These Ret* classes contain either an error state or a possible value; they cannot contain both. The auxiliary methods help make the program flow easier to work with them. These classes are acutely null aware by using the javax.annotation.Nullable and javax.annotation.Nonnull annotations, and giving names to the classes to distinguish them. You may find development easier if you use an IDE that is null annotation aware.

Along with these, the library provides some helper utility classes:

On top of this, if you’re running in a development environment, you can turn on monitoring, which helps you understand where problems or values may be dropped out of the application.

Importing into Your Project

Gradle projects will need to add the jar to the dependencies section:

dependencies {
  implementation 'net.groboclown:retval:2.4.0'
}

Maven projects will need to include the runtime dependency:

   <dependency>
      <groupId>net.groboclown</groupId>
      <artifactId>retval</artifactId>
      <version>2.4.0</version>
      <scope>runtime</scope>
    </dependency>

How To Use

In all the Ret* types, the normal flow involves first collecting information that could have problems, then using that data, if it is problem-free, to perform other operations.

class ServiceRunner {
  public static RetVal<ServiceRunner> loadService(String serviceMode) {
    return
            loadConfig(serviceMode)
                    .map(ServiceRunner::new);
  }

  static RetVal<Configuration> loadConfig(String serviceMode) {
    if ("daemon".equals(serviceMode)) {
      return RetVal.ok(Configuration.loadDaemon());
    } else if ("active".equals(serviceMode)) {
      return RetVal.ok(Configuration.loadActive());
    } else {
      return RetVal.fromProblem(LocalizedProblem.from("Invalid service mode"));
    }
  }

  ServiceRunner(Configuration config) {
    // ...
  }
}

When dealing with a library that can generate exceptions, the library can encapsulate those as well:

import java.io.FileReader;
import java.io.IOException;
import java.util.Properties;

class ReadFile {
  public RetVal<Properties> readPropertiesFile(File file) {
    try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8);) {
      final Properties ret = new Properties();
      ret.load(reader);
      return RetVal.ok(ret);
    } catch (final IOException e) {
      return RetVal.fromProblem(FileProblem.from(file, e));
    }
  }
}

If your program needs to handle many values before beginning the processing, then a ValueBuilder pattern can help. The value builder pattern helps avoid the tower of lambdas, and also correctly collects all the setup problems that the lambdas would otherwise skip.

import java.io.File;
import java.net.URL;
import java.util.Objects;

class WebAccess {
  private String jwtToken;
  private URL url;

  public void setJwtToken(String jwtToken) {
    this.jwtToken = jwtToken;
  }

  public String requireJwtToken() {
    return Objects.requireNonNull(this.jwtToken);
  }

  public void setUrl(URL url) {
    this.url = url;
  }

  public URL requireUrl() {
    return Objects.requireNonNull(this.url);
  }
}

class WebRunner {
  public RetVal<String> loadWebPage(File settingsDir) {
    return Ret.buildValue(new WebAccess())
            // The collector keeps gathering all the information,
            // even if the one before it encountered a problem.
            .with(createToken(settingsDir), WebAccess::setJwtToken)
            .with(readUrl(settingsDir), WebAccess::setUrl)

            // Evaluate the results into a valid object.
            // The code afterwards only runs if it has no discovered problems.
            .evaluate()
            .then((access) -> fetchUrl(access.requireUrl(), access.requireJwtToken()));
  }

  public RetVal<String> loadWebPageWrong(File settingsDir) {
    // In this model, the values are gathered without the POJO accessor,
    // but if the first data gatherer encounters an error, then the second
    // one is never run.  That makes for a quicker run time, but the end-user
    // will not know about that problem until the first one is fixed.
    return
        createToken(settingsDir)
      .then((token) ->
        readUrl(settingsDir)
      .then((url) ->
        fetchUrl(url, token)));
  }

  public RetVal<String> createToken(File settingsDir) {
    // ...
  }

  public RetVal<URL> readUrl(File settingsDir) {
    // ...
  }

  public RetVal<String> fetchUrl(URL url, String jwtToken) {
    // ...
  }
}

Example Use Cases

The usecases test package contains some complete examples of different ways to use this library.

Closeable Values

Some situations may arise where the returned value must be closed, such as with an I/O type. The simple approach is to require the closable value be passed as an argument to the creator.

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.function.Function;

class FileUtil {
  // One approach, 
  static <T> RetVal<T> processContents(File file, Function<String, T> func) {
    try (Reader reader = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) {
      return RetVal.ok(func.apply(readFully(file.getPath(), reader)));
    } catch (IOException e) {
      return RetVal.fromProblem(FileProblem.from(file, e));
    }
  }

  static String readFully(final String sourceName, final Reader reader) throws IOException {
    // ...
  }
}

The retval library also includes helper functions to correctly and fully protect your application against incorrect close semantics that can easily creep into your program.

import java.io.File;
import java.io.FileInputStream;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;

class FileUtilCloser {
  static <T> RetVal<T> processContentsCloser(File file, NonnullFunction<String, T> func) {
    return openFile(file)
            .then((reader) ->
                    Ret.closeWith(reader, (r) ->
                            readFullyWrapped(file.getPath(), reader)))
            .map(func);
  }

  static RetVal<Reader> openFile(File file) {
    try {
      return RetVal.ok(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8));
    } catch (IOException e) {
      return RetVal.fromProblem(FileProblem.from(file, e));
    }
  }

  static RetVal<String> readFullyWrapped(final String sourceName, final Reader reader)
          throws IOException {
    // ...
  }
}

This “close” version of the example requires a bit more complexity to use. However, it prevents some situations with exceptions that would otherwise cause error conditions to not be returned correctly. In particular, error catching on the close statement after exception handling could cause lost information.

Custom Problems

The library provides some simple problem classes to get you started. However, a more refined problem definitions can make for clearer error reporting.

All problem classes must implement the Problem interface. Because problems usually come from a user source, the additional interface SourcedProblem provides an example for adding additional information to the problem. You may find that you want a more robust problem source, perhaps a complex structure that includes both a source file and a path to a location in a Json document.

The library doesn’t put limitations on what your problem classes should be, but on the other hand, it doesn’t provide much functionality for robust problems. Future versions may include a larger variety of built-in problem classes.

Unit Tests

To add unit tests around the RetVal classes, you will want to add in a MockProblemMonitor to ensure the code doesn’t forget problems.

In JUnit 5, you can add code like this:

class ConfigurationReaderTest {
  MockProblemMonitor monitor;

  @Test
  void test_readProjectUser_noProjectValues() {
    final Properties props = new Properties();
    props.setProperty("projects", "p1");
    final RetVal<List<ProjectUser>> res = ConfigurationReader.readProjectUsers("p1", props);
    assertEquals(
            List.of(
                    LocalizedProblem.from("no `project.p1.name` property"),
                    LocalizedProblem.from("no `project.p1.users` property"),
                    LocalizedProblem.from("no `project.p1.url` property")
            ),
            res.anyProblems()
    );
    assertEquals(
            List.of(),
            this.monitor.getNeverObserved()
    );
  }

  @Test
  void test_readProjectUser_ok() {
    final Properties props = new Properties();
    // setup properties correctly
    // ...
    
    // Run the code
    final RetVal<List<ProjectUser>> res = ConfigurationReader.readProjectUsers("p1", props);
    
    // Validate no errors.
    assertEquals(
            List.of(),
            res.anyProblems()
    );
    
    // Validate that no problems were dropped.
    assertEquals(
            List.of(),
            this.monitor.getNeverObserved()
    );

    // validate returned value
    final List<ProjectUser> projects = res.result();
    assertEquals(1, projects.size());
    // ...
  }

  @BeforeEach
  void beforeEach() {
    this.monitor = MockProblemMonitor.setup();
    // In all cases, we want tracing enabled to ensure we track observation status.
    this.monitor.traceEnabled = true;
  }

  @AfterEach
  void afterEach() {
    this.monitor.tearDown();
  }
  
}

This ensures that the list of problems matches the expected set. This takes advantage of the monitor system, by keeping track of every registered problem during the execution of the test case.

The beforeEach method creates and configures the problem monitor for use by the test, and the afterEach restores the problem monitor to its previous state.

The failure state checks ensure the list of expected problems return from RetVal.getProblems(). Tests can rely on deterministic ordering of the problems.

For success checks, it’s more helpful to first check for an empty list of problems, rather than checking isOk() state first. This allows the unit tests to report discovered problems in the error text, which helps in debugging failing tests.

Validating All Checks are Accounted For

By default, the library will silently ignore Problems that haven’t been checked. Leaving these objects unchecked can be a source of subtle bugs, where problems that may occur in some configurations won’t be passed on down stream.

However, you can set the environment variable or Java property RETVAL_MONITOR_DEBUG to true to enable logging when a tracked closeable or problem collection is garbage collected but not checked. This allows for better inspection of where these problem areas may live. Problems are sent to the Java logging mechanism’s warning level, along with a stack trace for where the object was first created.

For production environments, you can set the environment variable or Java property RETVAL_PRODUCTION to true. This will disable all monitoring capabilities, which enables optimizations that disables many checks, calls, and object allocations for the Ret* objects.

Under the covers, the code provides for two extension points - Ret* object creation and monitor implementation. You can’t control these directly through startup parameters, but your code can call static methods on the RetGenerator class and the ObservedMonitorRegistrar class. See the API documentation for more details if you are interested in changing these out. You can find examples of implementing and changing these mechanisms in the unit test helpers that come with the library.

Identifying Code Smells

The API is carefully constructed to push you down a path that doesn’t lose information. If you find yourself performing identity transforms, using no-op consumers, or filling the code with “if” statements, then you should reconsider your code logic.

class DataStore {
    private MyData myData;
    
    static RetVal<MyData> readData(File source) {
        // Example method for loading the MyData with the contents of the source file.
        // ...
    }
    
    RetVoid processData_poorlyThoughtOut(File source) {
        // This example runs using old-style Java that requires additional
        // path handling.  This pushes explicit error checking into the
        // readData caller.
        RetVal<MyData> res = readData(source);
        if (res.isOk()) {
            this.myData = res.result();
            return RetVoid.ok();
        }
        // res has a problem, so it can be directly forwarded.
        return res.forwardVoidProblems();
        
        // Returning as void would be even worse:
        // e.g.: return res.thenVoid((x) -> {});
    }
    
    RetVoid processData_better(File source) {
        return readData(source)
                // This one call will invoke the lambda function if the return value
                // from readData didn't cause problems.
                .thenVoid((value) -> { this.myData = value; });
    }
    
    static RetVal<DataStore> processData_evenBetter(File source) {
        // This particular version is an alternative to a constructor, and follows the
        // builder pattern better.  It hides the complexity around creating the DataStore
        // object + constructor invoking behind a single call.
        // In this way, the myData field could be made final.
        return readData(source)
                .map((value) -> new DataStore(value));
    }
}

Similarly, if you find your method uses combinations of ValueBuilder and ProblemCollector, then you may need to rethink things.