Add-Ons

Boosting results with enhanced capabilities

Add-ons are optional, yet powerful extensions to the Result library, designed to provide extra features that can be integrated on demand.

These small, focused libraries provide a modular approach to extending the core functionalities of Results without adding unnecessary complexity.

  • Lazy results allow us to defer expensive calculations until absolutely necessary.
  • Fluent assertions for Result allow us to write expressive tests using AssertJ.
  • The Jackson datatype module for Result allows us to serialize and deserialize results using Jackson.
  • The Micronaut serialization support for Result allows us to serialize and deserialize results using Micronaut.

Lazy Results

How to defer expensive calculations with Results

Lazy results optimize performance by deferring costly operations until absolutely necessary. They behave like regular results, but only execute the underlying operation when an actual check for success or failure is performed.

How to Use this Add-On

Add this Maven dependency to your build:

Group ID Artifact ID Version
com.leakyabstractions result-lazy 1.0.0.0

Maven Central provides snippets for different build tools to declare this dependency.

Creating Lazy Results

We can use LazyResults.ofSupplier to create a lazy result.

Figure 33. Creating lazy Results
1 Supplier<Result<Integer, String>> supplier = () -> success(123);
2 Result<Integer, String> lazy = LazyResults.ofSupplier(supplier);

While suppliers can return a fixed success or failure, lazy results shine when they encapsulate time-consuming or resource-intensive operations.

Figure 34. Encapsulating expensive calculations
1 /* Represents the operation we may omit */
2 Result<Long, Exception> expensiveCalculation(AtomicLong timesExecuted) {
3   long counter = timesExecuted.incrementAndGet();
4   return success(counter);
5 }

This sample method simply increments and returns a counter for brevity. However, in a typical scenario, this would involve an I/O operation.

Skipping Expensive Calculations

The advantage of lazy results is that they defer invoking the provided Supplier for as long as possible. Despite this, you can screen and transform them like any other result without losing their laziness.

Figure 35. Skipping expensive calculations
 1 @Test
 2 void shouldSkipExpensiveCalculation() {
 3   AtomicLong timesExecuted = new AtomicLong();
 4   // Given
 5   Result<Long, Exception> lazy = LazyResults
 6       .ofSupplier(() -> expensiveCalculation(timesExecuted));
 7   // When
 8   Result<String, Exception> transformed = lazy.mapSuccess(Object::toString);
 9   // Then
10   assertNotNull(transformed);
11   assertEquals(0L, timesExecuted.get());
12 }

In this example, the expensive calculation is omitted because the lazy result is never fully evaluated. This test demonstrates that a lazy result can be transformed while maintaining laziness, ensuring that the expensive calculation is deferred.

Triggering Result Evaluation

Finally, when it’s time to check whether the operation succeeds or fails, the lazy result will execute it. This is triggered by using any of the terminal methods, such as hasSuccess.

Figure 36. Triggering Result evaluation
 1 @Test
 2 void shouldExecuteExpensiveCalculation() {
 3   AtomicLong timesExecuted = new AtomicLong();
 4   // Given
 5   Result<Long, Exception> lazy = LazyResults
 6       .ofSupplier(() -> expensiveCalculation(timesExecuted));
 7   // When
 8   Result<String, Exception> transformed = lazy.mapSuccess(Object::toString);
 9   boolean success = transformed.hasSuccess();
10   // Then
11   assertTrue(success);
12   assertEquals(1L, timesExecuted.get());
13 }

Here, the expensive calculation is executed because the lazy result is finally evaluated.

Handling Success and Failure Eagerly

By default, ifSuccess, ifFailure, and ifSuccessOrElse are treated as terminal methods. This means they eagerly evaluate the result and then perform an action based on its status.

Figure 37. Handling success and failure eagerly
 1 @Test
 2 void shouldHandleSuccessEagerly() {
 3   AtomicLong timesExecuted = new AtomicLong();
 4   AtomicLong consumerExecuted = new AtomicLong();
 5   Consumer<Long> consumer = x -> consumerExecuted.incrementAndGet();
 6   // Given
 7   Result<Long, Exception> lazy = LazyResults
 8       .ofSupplier(() -> expensiveCalculation(timesExecuted));
 9   // When
10   lazy.ifSuccess(consumer);
11   // Then
12   assertEquals(1L, timesExecuted.get());
13   assertEquals(1L, consumerExecuted.get());
14 }

In this test, we don’t explicitly unwrap the value or check the status, but since we want to consume the success value, we need to evaluate the lazy result first.

Furthermore, even if we wanted to handle the failure scenario, we would still need to evaluate the lazy result.

Figure 38. Handling success and failure eagerly
 1 @Test
 2 void shouldHandleFailureEagerly() {
 3   AtomicLong timesExecuted = new AtomicLong();
 4   AtomicLong consumerExecuted = new AtomicLong();
 5   Consumer<Exception> consumer = x -> consumerExecuted.incrementAndGet();
 6   // Given
 7   Result<Long, Exception> lazy = LazyResults
 8       .ofSupplier(() -> expensiveCalculation(timesExecuted));
 9   // When
10   lazy.ifFailure(consumer);
11   // Then
12   assertEquals(1L, timesExecuted.get());
13   assertEquals(0L, consumerExecuted.get());
14 }

In this other test, we use ifFailure instead of ifSuccess. Since the lazy result is evaluated to a success, the failure consumer is never executed.

Handling Success and Failure Lazily

When these conditional actions may also be skipped along with the expensive calculation, we can encapsulate them into a LazyConsumer instead of a regular Consumer. All we need to do is to create the consumer using LazyConsumer.of. Lazy consumers will preserve the laziness until a terminal method is eventually used on the result.

Figure 39. Handling success and failure lazily
 1 @Test
 2 void shouldHandleSuccessLazily() {
 3   AtomicLong timesExecuted = new AtomicLong();
 4   AtomicLong consumerExecuted = new AtomicLong();
 5   Consumer<Long> consumer = LazyConsumer
 6       .of(x -> consumerExecuted.incrementAndGet());
 7   // Given
 8   Result<Long, Exception> lazy = LazyResults
 9       .ofSupplier(() -> expensiveCalculation(timesExecuted));
10   // When
11   lazy.ifSuccess(consumer);
12   // Then
13   assertEquals(0L, timesExecuted.get());
14   assertEquals(0L, consumerExecuted.get());
15 }

Here, we use a lazy consumer with ifSuccess so the expensive calculation is skipped because the lazy result is never fully evaluated.

Conclusion

We learned how to defer expensive calculations until absolutely necessary. By leveraging lazy results, you can optimize performance by avoiding unnecessary computations and only evaluating the operation’s outcome when needed.

Fluent Assertions

How to assert Result objects fluently

You can use fluent assertions for Result objects to enhance the readability and expressiveness of your unit tests. These assertions are based on AssertJ, an open-source Java library that offers a fluent API for writing assertions in test cases.

An icon indicating this blurb contains information

AssertJ features a comprehensive and intuitive set of strongly-typed assertions for unit testing. It is a popular choice among Java developers due to its effective features and compatibility with various testing frameworks like JUnit and TestNG.

How to Use this Add-On

Add this Maven dependency to your build:

Group ID Artifact ID Version
com.leakyabstractions result-assertj 1.0.0.0

Maven Central provides snippets for different build tools to declare this dependency.

Asserting Result Objects

You can use ResultAssertions.assertThat in your tests to create fluent assertions for result objects.

Figure 40. Asserting Result objects
 1 import static com.leakyabstractions.result.assertj.ResultAssertions.assertThat;
 2 
 3 @Test
 4 void testAssertThat() {
 5   // When
 6   final Result<Integer, String> result = success(0);
 7   // Then
 8   assertThat(0).isZero();
 9   assertThat(result).hasSuccess(0);
10 }

If, for any reason, you cannot statically import assertThat, you can use ResultAssert.assertThatResult instead.

Figure 41. Using alternative static import
 1 import static com.leakyabstractions.result.assertj.ResultAssert.assertThatResult;
 2 import static org.assertj.core.api.Assertions.assertThat;
 3 
 4 @Test
 5 void testAssertThatResult() {
 6   // When
 7   final Result<Integer, String> result = success(0);
 8   // Then
 9   assertThat(0).isZero();
10   assertThatResult(result).hasSuccess(0);
11 }

Conclusion

We covered how to use fluent assertions for Results. This approach allows you to write clear and expressive tests, enhancing the maintainability of your unit tests while ensuring that Result objects behave as expected.

Jackson Module

How to serialize Result objects with Jackson

When using Result objects with Jackson we might run into some problems. The Jackson datatype module for Result solves them by making Jackson treat results as if they were ordinary objects.

An icon indicating this blurb contains information

Jackson is a Java library for JSON parsing and generation. It is widely used for converting Java objects to JSON and vice versa, making it essential for handling data in web services and RESTful APIs.

How to Use this Add-On

Add this Maven dependency to your build:

Group ID Artifact ID Version
com.leakyabstractions result-jackson 1.0.0.0

Maven Central provides snippets for different build tools to declare this dependency.

Test Scenario

Let’s start by creating a class ApiResponse containing one ordinary and one Result field.

Figure 42. Test Scenario
1 public class ApiResponse {
2 
3   @JsonProperty String version;
4   @JsonProperty Result<String, String> result;
5 
6   // Constructors, getters and setters omitted
7 }

Problem Overview

Then we will take a look at what happens when we try to serialize and deserialize ApiResponse objects.

Serialization Problem

Now, let’s instantiate an ApiResponse object.

Figure 43. Serialization problem
1 ApiResponse response = new ApiResponse();
2 response.setVersion("v1");
3 response.setResult(success("Perfect"));

And finally, let’s try serializing it using an object mapper.

Figure 44. Using an object mapper
1 ObjectMapper objectMapper = new ObjectMapper();
2 String json = objectMapper.writeValueAsString(response);

We’ll see that now we get an InvalidDefinitionException.

Figure 45. Invalid definition exception
Java 8 optional type `java.util.Optional<java.lang.String>` not supported by default:
add Module "com.fasterxml.jackson.datatype:jackson-datatype-jdk8" to enable handling

While this may look strange, it’s the expected behavior. When Jackson examined the result object, it invoked getSuccess and received an optional string value. But Jackson will not handle JDK 8 datatypes like Optional unless you register the appropriate modules.

Figure 46. Testing serialization problem
 1 @Test
 2 void testSerializationProblem() {
 3   // Given
 4   ApiResponse response = new ApiResponse("v1", success("Perfect"));
 5   // Then
 6   ObjectMapper objectMapper = new ObjectMapper();
 7   InvalidDefinitionException error = assertThrows(InvalidDefinitionException.class,
 8       () -> objectMapper.writeValueAsString(response));
 9   assertTrue(error.getMessage().startsWith(
10       "Java 8 optional type `java.util.Optional<java.lang.String>` not supported"));
11 }

This is Jackson’s default serialization behavior. But we’d like to serialize the result field like this:

Figure 47. Expected serialization
{
  "version": "v1",
  "result": {
    "failure": null,
    "success": "Perfect"
  }
}

Deserialization Problem

Now, let’s reverse our previous example, this time trying to deserialize a JSON object into an ApiResponse.

Figure 48. Deserialization problem
1 String json = "{\"version\":\"v2\",\"result\":{\"success\":\"OK\"}}";
2 ObjectMapper objectMapper = new ObjectMapper();
3 objectMapper.readValue(json, ApiResponse.class);

We’ll see that we get another InvalidDefinitionException. Let’s inspect the stack trace.

Figure 49. Invalid definition exception
Cannot construct instance of `com.leakyabstractions.result.api.Result`
 (no Creators, like default constructor, exist):
 abstract types either need to be mapped to concrete types,
 have custom deserializer, or contain additional type information

This behavior again makes sense. Essentially, Jackson cannot create new result objects because Result is an interface, not a concrete type.

Figure 50. Testing deserialization problem
 1 @Test
 2 void testDeserializationProblem() {
 3   // Given
 4   String json = "{\"version\":\"v2\",\"result\":{\"success\":\"OK\"}}";
 5   // Then
 6   ObjectMapper objectMapper = new ObjectMapper();
 7   InvalidDefinitionException error = assertThrows(InvalidDefinitionException.class,
 8       () -> objectMapper.readValue(json, ApiResponse.class));
 9   assertTrue(error.getMessage().startsWith(
10       "Cannot construct instance of `com.leakyabstractions.result.api.Result`"));
11 }

Solution Implementation

What we want, is for Jackson to treat Result values as JSON objects that contain either a success or a failure value. Fortunately, there’s a Jackson module that can solve this problem.

Registering the Jackson Datatype Module for Result

Once we have added Result-Jackson as a dependency, all we need to do is register ResultModule with our object mapper.

Figure 51. Registering the Jackson datatype module for Result
1 ObjectMapper objectMapper = new ObjectMapper();
2 objectMapper.registerModule(new ResultModule());

Alternatively, you can also make Jackson auto-discover the module.

Figure 52. Auto-discovering the module
1 objectMapper.findAndRegisterModules();

Regardless of the chosen registration mechanism, once the module is registered all functionality is available for all normal Jackson operations.

Serializing Results

Now, let’s try and serialize our ApiResponse object again:

Figure 53. Serializing a successful Result
 1 @Test
 2 void serializeSuccessfulResult() throws Exception {
 3   // Given
 4   ApiResponse response = new ApiResponse("v3", success("All good"));
 5   // When
 6   ObjectMapper objectMapper = new ObjectMapper();
 7   objectMapper.registerModule(new ResultModule());
 8   String json = objectMapper.writeValueAsString(response);
 9   // Then
10   assertTrue(json.contains("v3"));
11   assertTrue(json.contains("All good"));
12 }

If we look at the serialized response, we’ll see that this time the result field contains a null failure value and a non-null success value:

Figure 54. Actual serialization
{
  "version": "v3",
  "result": {
    "failure": null,
    "success": "All good"
  }
}

Next, we can try serializing a failed result.

Figure 55. Serializing a failed Result
 1 @Test
 2 void serializeFailedResult() throws Exception {
 3   // Given
 4   ApiResponse response = new ApiResponse("v4", failure("Oops"));
 5   // When
 6   ObjectMapper objectMapper = new ObjectMapper();
 7   objectMapper.findAndRegisterModules();
 8   String json = objectMapper.writeValueAsString(response);
 9   // Then
10   assertTrue(json.contains("v4"));
11   assertTrue(json.contains("Oops"));
12 } // End

We can verify that the serialized response contains a non-null failure value and a null success value.

Figure 56. Actual serialization
{
  "version": "v4",
  "result": {
    "failure": "Oops",
    "success": null
  }
}

Deserializing Results

Now, let’s repeat our tests for deserialization. If we read our ApiResponse again, we’ll see that we no longer get an InvalidDefinitionException.

Figure 57. Deserializing a successful Result
 1 @Test
 2 void deserializeSuccessfulResult() throws Exception {
 3   // Given
 4   String json = "{\"version\":\"v5\",\"result\":{\"success\":\"Yay\"}}";
 5   // When
 6   ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
 7   ApiResponse response = objectMapper.readValue(json, ApiResponse.class);
 8   // Then
 9   assertEquals("v5", response.getVersion());
10   assertEquals("Yay", response.getResult().orElse(null));
11 }

Finally, let’s repeat the test again, this time with a failed result. We’ll see that yet again we don’t get an exception, and in fact, have a failed result.

Figure 58. Deserializing a failed Result
 1 @Test
 2 void deserializeFailedResult() throws Exception {
 3   // Given
 4   String json = "{\"version\":\"v6\",\"result\":{\"failure\":\"Nay\"}}";
 5   // When
 6   ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
 7   ApiResponse response = objectMapper.readValue(json, ApiResponse.class);
 8   // Then
 9   assertEquals("v6", response.getVersion());
10   assertEquals("Nay", response.getResult().getFailure().orElse(null));
11 }

Conclusion

We learned how to serialize and deserialize Result objects using Jackson, demonstrating how the provided datatype module enables Jackson to treat Results as ordinary objects.

Micronaut Serialization

How to serialize Result objects with Micronaut

When using Result objects with Micronaut, we might run into some problems. The Micronaut serialization support for Result solves them by making Micronaut treat results as Serdeable (so they can be serialized and deserialized).

An icon indicating this blurb contains information

Micronaut is a modern, JVM-based framework for building lightweight microservices and serverless applications. It focuses on fast startup times and low memory usage. Although not as widely adopted as Spring Boot, it has gained popularity for its performance and innovative features.

How to Use this Add-On

Add this Maven dependency to your build:

Group ID Artifact ID Version
com.leakyabstractions result-micronaut-serde 1.0.0.0

Maven Central provides snippets for different build tools to declare this dependency.

Test Scenario

Let’s start by creating a record ApiOperation containing one ordinary and one Result field.

Figure 59. Test Scenario
1 /** Represents an API operation */
2 @Serdeable
3 public record ApiOperation(String name, Result<String, String> result) {
4 }

Problem Overview

We will take a look at what happens when we try to serialize and deserialize ApiOperation objects with Micronaut.

Serialization Problem

Now, let’s create a Micronaut controller that returns an instance of ApiOperation containing a successful result.

Figure 60. Serialization problem
1 @Controller("/operations")
2 public class ApiController {
3 
4     @Get("/last")
5     ApiOperation lastOperation() {
6         return new ApiOperation("setup", Results.success("Perfect"));
7     }
8 }

And finally, let’s run the application and try the /operations/last endpoint we just created.

Figure 61. Running the application
curl 'http://localhost:8080/operations/last'

We’ll see that we get a Micronaut CodecException caused by a SerdeException.

Figure 62. Serde exception
No serializable introspection present for type Success.
 Consider adding Serdeable. Serializable annotate to type Success.
 Alternatively if you are not in control of the project's source code,
 you can use @SerdeImport(Success.class) to enable serialization of this type.

Although this may look strange, it’s actually what we should expect. Even though we annotated ApiOperation as @Serdeable, Micronaut doesn’t know how to serialize result objects yet, so the data structure cannot be serialized.

Figure 63. Testing serialization problem
 1 @Test
 2 void testSerializationProblem(ObjectMapper objectMapper) {
 3   // Given
 4   ApiOperation op = new ApiOperation("setup", success("Perfect"));
 5   // Then
 6   SerdeException error = assertThrows(SerdeException.class,
 7       () -> objectMapper.writeValueAsString(op));
 8   assertTrue(error.getMessage().startsWith(
 9       "No serializable introspection present for type Success."));
10 }

This is Micronaut’s default serialization behavior. But we’d like to serialize the result field like this:

Figure 64. Expected serialization
{
  "name": "setup",
  "result": {
    "failure": null,
    "success": "Perfect"
  }
}

Deserialization Problem

Now, let’s reverse our previous example, this time trying to receive an ApiOperation as the body of a POST request.

Figure 65. Deserialization problem
 1 @Controller("/operations")
 2 public class ApiController {
 3 
 4     @Post("/notify")
 5     Map<String, String> notify(@Body ApiOperation op) {
 6         return op.result()
 7                 .mapSuccess(s -> Map.of("message", op.name() + " succeeded: " + s))
 8                 .orElseMap(f -> Map.of("error", op.name() + " failed: " + f));
 9     }
10 }

We’ll see that now we get an IntrospectionException. Let’s inspect the stack trace.

Figure 66. Introspection exception
No bean introspection available for type
 [interface com.leakyabstractions.result.api.Result].
 Ensure the class is annotated with
 io.micronaut.core.annotation.Introspected

This behavior again makes sense. Essentially, Micronaut cannot create new result objects, because Result is not annotated as @Introspected or @Serdeable.

Figure 67. Testing deserialization problem
 1 @Test
 2 void testDeserializationProblem(ObjectMapper objectMapper) {
 3   // Given
 4   String json = """
 5       {"name":"renew","result":{"success":"OK"}}""";
 6   // Then
 7   IntrospectionException error = assertThrows(IntrospectionException.class,
 8       () -> objectMapper.readValue(json, ApiOperation.class));
 9   String errorMessage = error.getMessage(); // Extract error message
10   // Verify error message
11   assertTrue(errorMessage.startsWith("No bean introspection available " +
12       "for type [interface com.leakyabstractions.result.api.Result]."));
13 } // End

Solution Implementation

What we want, is for Micronaut to treat Result values as JSON objects that contain either a success or a failure value. Fortunately, there’s an easy way to solve this problem.

Adding the Serde Imports to the Classpath

All we need to do now is add Result-Micronaut-Serde as a Maven dependency. Once the @SerdeImport is in the classpath, all functionality is available for all normal Micronaut operations.

Serializing Results

Now, let’s try and serialize our ApiOperation object again.

Figure 68. Serializing a successful Result
 1 @Test
 2 void serializeSuccessfulResult(ObjectMapper objectMapper)
 3     throws IOException {
 4   // Given
 5   ApiOperation op = new ApiOperation("clean", success("All good"));
 6   // When
 7   String json = objectMapper.writeValueAsString(op);
 8   // Then
 9   assertEquals("""
10       {"name":"clean","result":{"success":"All good"}}""", json);
11 }

If we look at the serialized response, we’ll see that this time the result field contains a success field.

Figure 69. Actual serialization
{
  "name": "clean",
  "result": {
    "failure": null,
    "success": "All good"
  }
}

Next, we can try serializing a failed result.

Figure 70. Serializing a failed Result
 1 @Test
 2 void serializeFailedResult(ObjectMapper objectMapper)
 3     throws IOException {
 4   // Given
 5   ApiOperation op = new ApiOperation("build", failure("Oops"));
 6   // When
 7   String json = objectMapper.writeValueAsString(op);
 8   // Then
 9   assertEquals("""
10       {"name":"build","result":{"failure":"Oops"}}""", json);
11 }

We can verify that the serialized response contains a non-null failure value and a null success value:

Figure 71. Actual serialization
{
  "name": "build",
  "result": {
    "failure": "Oops",
    "success": null
  }
}

Deserializing Results

Now, let’s repeat our tests for deserialization. If we read our ApiOperation again, we’ll see that we no longer get an IntrospectionException.

Figure 72. Deserializing a successful Result
 1 @Test
 2 void deserializeSuccessfulResult(ObjectMapper objectMapper)
 3     throws IOException {
 4   // Given
 5   String json = """
 6       {"name":"check","result":{"success":"Yay"}}""";
 7   // When
 8   ApiOperation response = objectMapper.readValue(json, ApiOperation.class);
 9   // Then
10   assertEquals("check", response.name());
11   assertEquals("Yay", response.result().orElse(null));
12 }

Finally, let’s repeat the test again, this time with a failed result. We’ll see that yet again we don’t get an exception, and in fact, have a failed result.

Figure 73. Deserializing a failed Result
 1 @Test
 2 void deserializeFailedResult(ObjectMapper objectMapper)
 3     throws IOException {
 4   // Given
 5   String json = """
 6       {"name":"start","result":{"failure":"Nay"}}""";
 7   // When
 8   ApiOperation response = objectMapper.readValue(json, ApiOperation.class);
 9   // Then
10   assertEquals("start", response.name());
11   assertEquals("Nay", response.result().getFailure().orElse(null));
12 }

Conclusion

We learned how to serialize and deserialize Result objects using Micronaut, demonstrating how the provided @SerdeImport enables Micronaut to treat Results as Serdeable objects.