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.
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.
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.
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.
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.
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.
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.
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.
![]() |
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.
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.
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.
![]() |
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.
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.
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.
1 ObjectMapper objectMapper = new ObjectMapper();
2 String json = objectMapper.writeValueAsString(response);
We’ll see that now we get an InvalidDefinitionException.
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.
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:
{
"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.
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.
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.
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.
1 ObjectMapper objectMapper = new ObjectMapper();
2 objectMapper.registerModule(new ResultModule());
Alternatively, you can also make Jackson auto-discover 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:
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:
{
"version": "v3",
"result": {
"failure": null,
"success": "All good"
}
}
Next, we can try 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.
{
"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.
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.
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).
![]() |
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.
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.
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.
curl 'http://localhost:8080/operations/last'
We’ll see that we get a Micronaut CodecException caused by a SerdeException.
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.
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:
{
"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.
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.
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.
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.
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.
{
"name": "clean",
"result": {
"failure": null,
"success": "All good"
}
}
Next, we can try 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:
{
"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.
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.
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.
