- Java 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .github/workflows | ||
| .mvn | ||
| concurrent | ||
| result | ||
| support | ||
| .gitignore | ||
| CHANGELOG.md | ||
| CONTRIBUTING.md | ||
| LICENSE.txt | ||
| mvnw | ||
| mvnw.cmd | ||
| NOTICE | ||
| pom.xml | ||
| README.md | ||
| RELEASING.md | ||
Result – Functional Error Handling
Maven Central | Changelog | Contributing | License
The purpose of this small framework is to provide a simple and frictionless way to do functional error handling in Java.
It does so by using a Result class, which is a specialization of the generic Either class, which improves readability
and addresses the following pain points often present in other frameworks.
- Checked exceptions in lambda functions.
- Try-catch blocks in a functional pipeline.
- User defined types for errors.
- A construct for for-comprehension with lazy loading.
- Simple concurrent for-comprehension.
The framework borrows ideas from various sources and condenses them into two small and simple classes, Result and Concurrent,
which covers all the points above. These sources mainly include:
Concurrent provides a simple way to do concurrent for-comprehension, see examples below.
It's also recommended that Result is used together with the Purity Maven plugin to have compiler support for pure functions.
Together, they form a very strong programming model for functional programming in Java.
The goal of this framework is not to replace other Java constructs like e.g. Optional or collection types like some functional frameworks do, but instead interop with the standard Java library.
The goal is also not to enforce a very strict functional approach to application design, but rather to be pragmatic and allow teams to adopt this style of programming at their own pace. This approach works best in real world scenarios where team members have different kinds of backgrounds and experience levels. It also makes it easier to gradually introduce functional design principles into legacy codebases.
The API of the Result class tries to follow the standard naming conventions found in the Java ecosystem.
Using Result has minimal performance overhead on happy-path since it's garbage free (after JIT warmup) and uses no branching operators (if-else).
It will typically perform better on error-path than traditional Java code since no stack trace has to be constructed when using functional error handling.
The framework has no external dependencies, except for the JSpecify nullability annotations. This makes it lean with a low attack surface.
Requirements
Result and Concurrent are independently versioned.
Result requires minimum Java 21.
Concurrent requires that you choose a matching JDK version e.g., 1.0.0-JDK26.
Note that Concurrent requires preview features enabled since it uses Java Structured Concurrency internally (which is a preview feature).
Concurrent is therefore optional as a separate dependency.
According to Oracle preview features are fully specified, implemented and tested, but its API surface may change between JDK releases. Since the Structured Concurrency API surface is kept strictly internal to this framework it can be used safely in production.
Maven dependencies
The framework is available on Maven Central.
<dependency>
<groupId>no.nhn.result</groupId>
<artifactId>result</artifactId>
<version>${result.version}</version>
</dependency>
<dependency>
<groupId>no.nhn.result</groupId>
<artifactId>concurrent</artifactId>
<version>${concurrent.version}</version>
</dependency>
Result
User defined error types
Note that user defined error types need to implement the Result.Err interface. See example below.
For convenience, the Result.Err interface has predefined error types Result.Err.ErrMsg
and Result.Err.ErrEx which can be used in simple scenarios.
A common scenario is that you've got a functional pipeline which works on multiple types of errors. Due to the way Java generics have been made, this is not possible in a user-friendly way. It's therefore recommended that you always use a common interface type for errors in your functional pipelines.
/**
* Used for functional error handling.
* Errors we can handle through normal control flow, i.e. business/user/validation errors, *not* technical errors.
* For technical errors which we cannot recover from it's ok to throw an exception.
*/
public sealed interface ServiceError extends Result.Err {
String message();
int errorCode();
record JsonParseErr(String message, int errorCode) implements ServiceError {
}
record InvalidUserInputErr(String message, int errorCode) implements ServiceError {
}
record BusinessRuleErr(String message, int errorCode) implements ServiceError {
}
sealed interface AuthorizationError extends ServiceError {
record EndpointNotPermittedErr(String message, int errorCode) implements AuthorizationError {
}
record ConfidentialAddressErr(String message, int errorCode) implements AuthorizationError {
}
}
}
/**
* Example of an error not part of an interface.
*/
record CodeError(String code) implements Result.Err {
}
Functional pipeline
Typical usage is to chain together a functional pipeline. Below is an example of a value object UserName which extracts data from a Nimbus JWT claim.
Note that when chaining together a functional pipeline it's important to keep the implementation details out of the lambda functions for the sake of readability.
Instead create small and targeted functions which can be reused in the pipeline.
Such functions can be seen in the Str and ErrorMsg classes in the examples below.
Compose the functions so that you can use method references as much as possible to improve readability.
// A value object.
public record UserName(String name) {
public static final String CLAIM_NAME = "user_name";
public UserName {
Objects.requireNonNull(name); // Note: Avoid null as much as possible in functional programming.
}
// Note that ErrMsg is an error type in Result.Err for convenience which can be imported statically.
// Normally you would use a custom error type in your own code.
// Also note that we're keeping implementation details out of the lambda functions so that we can use
// method references for good readability.
public static Result<ErrMsg, UserName> fromClaims(JWTClaimsSet claims) {
return Result.tryOf(() -> claims.getStringClaim(CLAIM_NAME), ex -> invalidClaimError(ex, CLAIM_NAME))
.filter(Str::isNotEmpty, _ -> emptyClaimError(CLAIM_NAME))
.filter(Str::lengthLessThanOrEqualTo100, _ -> tooLargeClaimError(LENGTH_100, CLAIM_NAME))
.tryOf(Str::urlDecode, _ -> invalidClaimError("Failed to URL decode content", CLAIM_NAME))
.flatMap(UserName::isSuperHero)
.map(UserName::new);
}
// Put business logic, or complex validation, in a separate function and call it with the
// flatMap-method from the functional pipeline.
private static Result<ErrMsg, String> isSuperHero(String name) {
if (name.contains("superman") || name.contains("batman") || name.contains("wonder woman"))
return Result.ok(name);
else
return Result.err(invalidClaimError("Must be super hero", CLAIM_NAME));
}
}
// Targeted functions for validation.
// The class is named specifically for having good readability in the functional pipeline using method references.
public final class Str {
public static final int LENGTH_512 = 512;
public static final int LENGTH_100 = 100;
public static final int LENGTH_3 = 3;
public static final Pattern WHITELISTED_INPUT_CHARACTERS = Pattern.compile("[^0-9a-zA-ZæøåÆØÅ.,=()\\[\\]:%?@\\^~*/+\\-_!\\\\ ]");
private Str() {
}
public static boolean lengthGreaterThanOrEqualTo3(String str) {
return str.length() >= LENGTH_3;
}
public static boolean lengthLessThanOrEqualTo512(String str) {
return str.length() <= LENGTH_512;
}
public static boolean lengthLessThanOrEqualTo100(String str) {
return str.length() <= LENGTH_100;
}
public static boolean isEmpty(@Nullable String str) {
return str == null || str.isEmpty();
}
public static boolean isNotEmpty(@Nullable String str) {
return !isEmpty(str);
}
public static String urlDecode(String str) throws IllegalArgumentException {
return URLDecoder.decode(str, UTF_8);
}
public static boolean hasOnlyWhitelistedChars(String str) {
return !WHITELISTED_INPUT_CHARACTERS.matcher(str).find();
}
public static String findIllegalWhitelistedChar(String str) {
var matcher = WHITELISTED_INPUT_CHARACTERS.matcher(str);
return matcher.find() ? matcher.group(0) : "";
}
}
// Targeted functions for errors made for having good readability in functional pipelines.
public final class ErrorMsg {
private ErrorMsg() {
}
public static Result.Err.ErrMsg invalidClaimError(Exception ex, String claimName) {
return new Result.Err.ErrMsg(
"Invalid claim '" + claimName + "': " + ex.getMessage()
);
}
public static Result.Err.ErrMsg invalidClaimError(String message, String claimName) {
return new Result.Err.ErrMsg(
"Invalid claim '" + claimName + "': " + message
);
}
public static Result.Err.ErrMsg emptyClaimError(String claimName) {
return new Result.Err.ErrMsg(
"Invalid claim '" + claimName + "': Cannot be empty."
);
}
public static Result.Err.ErrMsg tooLargeClaimError(int maxLength, String claimName) {
return new Result.Err.ErrMsg(
"Invalid claim '" + claimName + "': Too large, max length is '" + maxLength + "' characters."
);
}
public static Result.Err.ErrMsg tooLargeHeaderError(int maxLength, String headerName) {
return new Result.Err.ErrMsg(
"Invalid header '" + headerName + "': Too large, max length is '" + maxLength + "' characters."
);
}
public static Result.Err.ErrMsg tooSmallHeaderError(int minLength, String headerName) {
return new Result.Err.ErrMsg(
"Invalid header '" + headerName + "': Too small, minimum length is '" + minLength + "' characters."
);
}
public static Result.Err.ErrMsg invalidHeaderError(String message, String headerName) {
return new Result.Err.ErrMsg(
"Invalid header '" + headerName + "': " + message
);
}
public static Result.Err.ErrMsg invalidWhitelistedCharsHeaderError(String input, String headerName) {
return new Result.Err.ErrMsg(
"Invalid header '" + headerName + "': Found illegal character '" + findIllegalWhitelistedChar(input) + "'."
);
}
}
Value objects
Use value objects as much as possible in functional programming for type safety and readability. Value objects should have domain-specific names like e.g. AccountId, UserName or SubCategory. Avoid stringly typed code where every parameter is a string or primitive data type.
As shown below, value objects compose beautifully with Result.all (for-comprehension).
Use static factory methods, like UserName::fromClaims above, to ensure you always create value objects in a valid state.
Objects in an invalid state should never occur.
Only use pure functions in the value objects. Do not perform any side effects (state mutation) in the factory method like e.g. calling a database, logging or sending a Kafka message (infrastructure code). Send all the states needed to create the value object as parameters to the factory method. Infrastructure code is code which is not part of the functional domain (Kafka, database, logging, http, etc.).
The services classes (or feature slices) should be responsible for orchestrating business rules (value objects) and technical infrastructure (side effects). See the example below for details.
// Another example of a value object. Should only have pure functions and immutable data.
public record SourceSystem(String name) {
public static final String HEADER_NAME = "source-system";
private static Result<ErrMsg, SourceSystem> fromHeader(Map<String, String> headers) {
return Result.<ErrMsg, String>of(headers.get(HEADER_NAME))
.filter(Str::isNotEmpty, _ -> emptyHeaderError(HEADER_NAME))
.filter(Str::lengthGreaterThanOrEqualTo3, _ -> tooSmallHeaderError(LENGTH_3, HEADER_NAME))
.filter(Str::lengthLessThanOrEqualTo512, _ -> tooLargeHeaderError(LENGTH_512, HEADER_NAME))
.tryOf(Str::urlDecode, _ -> invalidHeaderError("Failed to URL decode content", HEADER_NAME))
.filter(Str::hasOnlyWhitelistedChars, sys -> invalidWhitelistedCharsHeaderError(sys, HEADER_NAME))
.map(SourceSystem::new);
}
}
// Service classes orchestrates business rules (value objects with pure functions) and technical infrastructure (side effects).
// It's a good place to call a database, perform logging or send a Kafka message. Use the run()-method to perform side effects.
public final class AuthService {
public record AuthData(UserName userName, SourceSystem sourceSystem) {
}
private AuthService() {
}
// Value objects composes very well with the Result.all method (for-comprehension).
public static Result<ErrMsg, AuthData> getAuthData(Map<String, String> headers, JWTClaimsSet claims) {
return Result.all(
() -> UserName.fromClaims(claims),
() -> SourceSystem.fromHeader(headers)
)
.map((userName, sourceSystem) ->
new AuthData(userName, sourceSystem)
)
.run(
err -> logError(err.msg()),
authData -> produceAuditLogKafkaMessage(authData)
);
}
private static void logError(String msg) {
// Perform logging in service classes (side effect). Use a logging framework.
System.out.println("Error: " + msg);
}
private static void produceAuditLogKafkaMessage(AuthData authData) {
// Produce Kafka message (side effect), typically in a background thread.
Concurrent.run(
() -> AuditLogger.produceKafkaMessage(authData)
);
}
}
// Controllers should perform http related side effects, like reading requests and writing responses.
// Use the run()-method to perform side effects.
public final class AuthController {
public static final ObjectMapper jsonMapper = new ObjectMapper();
public record ResponseBody(String userName, String sourceSystem) {
}
private AuthController() {
}
// Note: Context holds the request and response objects (Javalin).
public static void handleAuthRequest(Context ctx) {
AuthService.getAuthData(ctx.headers(), ctx.claims())
.run(
err -> ctx.status(401).result("An error occurred: " + err.msg()),
authData -> ctx.status(200).result(responseBody(authData))
);
}
private static byte[] responseBody(AuthData authData) throws JsonProcessingException {
var body = new ResponseBody(authData.userName().name(), authData.sourceSystem().name());
return jsonMapper.writeValueAsBytes(body);
}
}
The way of programming outlined above separates pure domain/business logic (value objects) from infrastructure code (Kafka, database, logging, http, etc.). By keeping the value objects pure, you can easily test them without having to mock any infrastructure code. The icing on the cake is that you can use the Purity Maven plugin to have compiler support for pure functions to ensure that you don't break purity in value objects.
If you combine this programming model with feature slices (or bounded context in DDD) you have a very strong architectural design to tame coupling and cohesion in your application as it grows and ages. The design is both simple and has no unnecessary abstraction.
This architectural design is also called Functional Core/Imperative Shell (FC/IS).
About functional error handling
In the example above, let's say the first filter-operation in SourceSystem::fromHeader gives an error.
In that case the methods following it will be called, but the lambda functions will never get run.
That means the error object will "bubble" down, not up as with exceptions, and all methods will be called as in normal control flow, but no lambda function gets executed.
The Result class does this by internally using either a Success or Failure object.
The Failure object simply returns itself directly out, without doing anything, when calling normal methods like e.g. filter or map.
The Success object returns itself directly out, without doing anything, when calling error methods like e.g. mapErr.
Avoiding nested scopes
Sometimes you have inner scopes which depends on return values from the outer scopes.
Typically, in functional programming you see the use of nested scopes to get around this, e.g. flatMap() within a flatMap(), but this makes the code hard to read.
Other times procedural error handling is used, but that requires you to manually check whether the Result isErr() or isOk() before getting the value which can be error-prone.
Instead, use the all()-method on the Result instance for avoiding nested scopes like shown in the example below.
In this example, the jwt variable is returned from the parseTokenToJwt()-method, and is next available as input to all the parameters in the all()-method.
There's also more documentation about Result.all below.
// Using Result.all to keep 'jwt' in scope for the functions which needs it.
private static Result<AuthError, AccessTokenBearer> parseToken(BearerAccessToken token) {
return parseTokenToJwt(token.getValue(), HEADER_AUTHORIZATION)
.all(
jwt -> Result.ok(jwt),
jwt -> parseAndValidateIssuer(jwt)
)
.map((jwt, issuer) ->
new AccessTokenBearer(jwt, issuer)
);
}
DON'T: Procedural error handling to avoid a nested scope.
// Avoiding a nested scope with procedural error handling.
private static Result<AuthError, AccessTokenBearer> parseToken(String token, String headerName) {
var jwtResult = parseTokenToJwt(token, headerName);
if (jwtResult.isErr())
return Result.err(jwtResult.getErrOrThrow());
var jwt = jwtResult.getValueOrThrow();
return parseAndValidateIssuer(jwt)
.fold(Result::err, issuer -> Result.ok(new AccessTokenBearer(jwt, issuer)));
}
DON'T: Functional error handling with nested scopes.
private static Result<AuthError, AccessTokenBearer> parseToken(String token, String headerName) {
// return parseTokenToJwt(token, headerName)
// .flatMap(jwt -> parseAndValidateIssuer(jwt)
// .flatMap(issuer -> Result.ok(new AccessTokenBearer(jwt, issuer))));
return parseTokenToJwt(token, headerName)
.fold(
err -> Result.err(err),
jwt -> parseAndValidateIssuer(jwt)
.fold(
err -> Result.err(err),
issuer -> Result.ok(new AccessTokenBearer(jwt, issuer))
)
);
}
Orchestrating the control flow
A bit more complex example with a service class orchestrating overall control flow and multiple error types.
// Service classes orchestrates business rules (value objects with pure functions) and technical infrastructure (side effects).
// It's a good place to call a database, perform logging or send a Kafka message. Use the run()-method to perform side effects.
public final class InternalService {
private static final ObjectMapper jsonMapper = new ObjectMapper();
public sealed interface ServiceError extends Result.Err {
String message();
int errorCode();
record JsonParseError(String message, int errorCode) implements ServiceError {
}
record InvalidUserInputError(String message, int errorCode) implements ServiceError {
}
record BusinessRuleError(String message, int errorCode) implements ServiceError {
}
}
public record InputDTO(String value1, String value2) {
}
public record OutputDTO(String one, String two) {
}
private InternalService() {
}
public static Result<ServiceError, OutputDTO> create(String requestBody) {
return parseRequestBody(requestBody)
.flatMap(InternalService::validateInput)
.flatMap(InternalService::validateBusinessRules)
.onOk(dto -> {
writeToDatabase(dto);
produceAuditLogKafkaMessage(dto);
})
.map(_ -> new OutputDTO("Thank", "you"))
.onErr(err -> logServiceError(err));
}
private static Result<ServiceError, InputDTO> parseRequestBody(String requestBody) {
try {
return Result.ok(jsonMapper.readValue(requestBody.getBytes(), InputDTO.class));
} catch (Exception ex) {
return Result.err(new JsonParseError("Invalid request body", 1));
}
}
private static Result<ServiceError, InputDTO> validateInput(InputDTO inputDTO) {
return Result.<ServiceError, InputDTO>of(inputDTO)
.filter(Objects::nonNull, _ -> userInputError("Missing input", 2))
.filter(DTO::value1NonNull, _ -> userInputError("Missing value1", 3))
.filter(DTO::value2NonNull, _ -> userInputError("Missing value2", 4))
.tryOf(DTO::value2IsInt, _ -> userInputError("Value2 must be digit", 5))
.fold(Result::err, _ -> Result.ok(inputDTO));
}
// Internal class with targeted functions for validating the InputDTO above.
// The class is named specifically for having good readability in the functional pipeline using method references.
static class DTO {
static boolean value1NonNull(InputDTO dto) {
return dto.value1 != null;
}
static boolean value2NonNull(InputDTO dto) {
return dto.value2 != null;
}
static boolean value2IsInt(InputDTO dto) throws NumberFormatException {
return Result.tryOf(() -> Integer.parseInt(dto.value2)).isOk();
}
static InvalidUserInputError userInputError(String msg, int errorCode) {
return new InvalidUserInputError(msg, errorCode);
}
}
private static Result<ServiceError, InputDTO> validateBusinessRules(InputDTO inputDTO) {
if (inputDTO.value1.contains("business") || inputDTO.value2.contains("2"))
return Result.err(new BusinessRuleError("Illegal values", 6));
else
return Result.ok(inputDTO);
}
private static void writeToDatabase(InputDTO inputDTO) {
// Write to database, performing a side effect.
// Throws exception on technical errors like connection problems etc.
// No functional error handling on errors you can't do anything about.
}
private static void produceAuditLogKafkaMessage(InputDTO inputDTO) {
// Produce Kafka message (side effect), typically in a background thread.
Concurrent.run(
() -> AuditLogger.produceKafkaMessage(inputDTO)
);
}
private static void logServiceError(ServiceError err) {
// Log the error.
}
}
Using specific subtypes of errors in the functional pipeline
A common scenario is that you've got a functional pipeline which works on multiple types of errors. Due to the way Java generics have been made, this is not possible in a user-friendly way. It's therefore recommended that you always use a common interface type for errors in your functional pipelines.
A workaround is to use the fold()-method inside of flatMap() for upcasting to the interface type as shown below, but it's not user friendly. Instead, stick to just using interface types for errors as Java generics want you to do.
The example below is how to NOT do it.
public final class InternalService {
public sealed interface ServiceError extends Result.Err {
String message();
record InvalidUserInputError(String message) implements ServiceError {
}
record BusinessRuleError(String message) implements ServiceError {
}
}
public record OutputDTO(String one, String two) {
}
private InternalService() {
}
// DON'T: Use the fold()-method for upcasting to the interface type inside of flatMap() in the functional pipeline.
public static Result<ServiceError, OutputDTO> validate(InputDTO inputDTO) {
return Result.<ServiceError, OutputDTO>of(inputDTO)
.flatMap(dto -> validateInput(dto).fold(Result::err, Result::ok)) // DON'T do this, instead return the ServiceError interface from validateInput().
.flatMap(dto -> validateBusinessRules(dto).fold(Result::err, Result::ok)) // DON'T do this, instead return the ServiceError interface from validateBusinessRules().
.map(_ -> new OutputDTO("Thank", "you"));
}
// Return the ServiceError interface from this method instead of InputDTO.
private static Result<InvalidUserInputError, InputDTO> validateInput(InputDTO inputDTO) {
// Also DON'T: The pipeline below is bad practice.
// Put the implementation details in the lambda functions into smaller targeted functions
// and use method references instead for better readability.
return Result.<InvalidUserInputError, InputDTO>of(inputDTO)
.filter(Objects::nonNull, dto -> new InvalidUserInputError("Missing input"))
.filter(dto -> dto.value1 != null, dto -> new InvalidUserInputError("Missing value1"))
.filter(dto -> dto.value2 != null, dto -> new InvalidUserInputError("Missing value2"))
.tryOf(dto -> Integer.parseInt(dto.value2), ex -> new InvalidUserInputError("Value2 must be digit"))
.fold(Result::err, value2 -> Result.ok(inputDTO));
}
// Return the ServiceError interface from this method instead of InputDTO.
private static Result<BusinessRuleError, InputDTO> validateBusinessRules(InputDTO inputDTO) {
if (inputDTO.value1.contains("business") || inputDTO.value2.contains("2"))
return Result.err(new BusinessRuleError("Illegal values"));
else
return Result.ok(inputDTO);
}
}
Using the switch-case construct
You can do error handling using a switch-case if you find it more readable, although it's not the preferred way.
public final class UrlConsumer {
public sealed interface UrlConsumerError extends Result.Err {
record EndpointError(String message) implements UrlConsumerError {
}
record InvalidUserInputError(String message) implements UrlConsumerError {
}
}
public record OutputDTO(String content) {
}
private UrlConsumer() {
}
public static Result<UrlConsumerError, OutputDTO> consumeUrl(String url) {
return switch (validate(url)) {
case Failure<UrlConsumerError, String> err -> Result.err(err.error());
case Success<UrlConsumerError, String> ok -> consume(ok.result());
};
}
private static Result<UrlConsumerError, String> validate(String inputUrl) {
return Result.<UrlConsumerError, String>of(inputUrl)
.filter(Objects::nonNull, url -> new InvalidUserInputError("Missing url"))
.filter(url -> url.startsWith("https"), url -> new InvalidUserInputError("Illegal url"));
}
private static Result<UrlConsumerError, OutputDTO> consume(String url) {
try (var httpClient = HttpClient.newBuilder().build()) {
var request = HttpRequest.newBuilder().uri(new URI(url)).GET().build();
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200)
return Result.err(new EndpointError("Illegal endpoint response"));
return Result.ok(new OutputDTO(response.body()));
} catch (Exception e) {
return Result.err(new EndpointError("Error requesting endpoint"));
}
}
}
Result.all
The main use case for Result.all is to retrieve several independent values including error handling, and then do some work on all the values afterward. It's the Java version of for-comprehension in functional programming. For-comprehension is syntactic sugar to avoid nested scopes in flatMap methods. It makes the code a lot more readable and a pleasure to maintain.
All input functions are lazily loaded. If any of the input functions returns an error, then the error is returned out directly and the subsequent functions are not run. If all is ok, the map-method in the example below is run.
public final class UserService {
public record UserDetails(String name, Long phoneNumber, Integer creditCard) {
}
private UserService() {
}
public static Result<ErrMsg, UserDetails> getUserDetails() {
return Result.all(
() -> getName(),
() -> getPhoneNumber(),
() -> getCreditCard()
)
.map((name, phoneNumber, creditCard) ->
new UserDetails(name, phoneNumber, creditCard)
);
}
private static Result<ErrMsg, String> getName() {
return Result.ok("Name");
}
private static Result<ErrMsg, Long> getPhoneNumber() {
return Result.ok(123456L);
}
private static Result<ErrMsg, Integer> getCreditCard() {
return Result.ok(987654);
}
}
Result run
To perform a side effect like e.g., writing to a HTTP response or sending a Kafka message use the run()-method. This method returns the current Result object back out unchanged and requires that you handle both success and error scenarios.
public final class UserController {
public static final ObjectMapper jsonMapper = new ObjectMapper();
private UserController() {
}
// Note: Context holds the request and response objects.
public static void status(Context ctx) {
UserService.getUserDetails()
.run(
errMsg -> ctx.status(INTERNAL_SERVER_ERROR_500).result("Error getting user details: " + errMsg.msg()),
userDetails -> ctx.status(OK_200).result(jsonMapper.writeValueAsBytes(userDetails))
);
}
}
public final class KafkaMessageProducer {
private KafkaMessageProducer() {
}
public static void produceStatusMessage() throws IllegalStateException {
Concurrent.all(
() -> StatusDb.hasActiveStatus(),
() -> StatusDb.getUserStatus()
)
.run(
Result.Err::throwErr, // Note: Convenience function for throwing IllegalStateException if error.
(hasActiveStatus, userStatus) ->
StatusKafkaProducer.produceStatusMessage(hasActiveStatus, userStatus)
);
}
}
A bit more complex example. You can do a static import on Result.Err::value to make the pipeline look better.
public static Result<ServiceError, Flag> create(String fhirResource, AuthData authData, ApiVersion apiVersion) {
return parseFhirResource(Flag.class, fhirResource)
.flatMap(flag -> validateFhirProfile(flag, authData, apiVersion))
.map(flag -> changePatientNinToPseudonym(flag, apiVersion, authData, setPatientRefFunc))
.run(Err::noop, flag -> {
// Note using the Result.Err::noop convenience method as a no-op on error.
CriticalInfoDb.create(toDTO(flag, apiVersion, authData));
})
.map(flag -> changePatientPseudonymToNin(flag, apiVersion, authData, setPatientRefFunc))
.run(Err::noop, flag -> {
AuditLogger.produceKafkaMessage(CREATE, authData, flag, FlagStatus::hasDeletedStatus);
StatusService.produceKafkaMessage(authData);
logResourceDefects(flag, authData);
})
.map(flag -> correctResourceDefects(flag))
.run(
err -> logServiceError(FlagService.class, authData, err),
flag -> StatisticsLogger.logStatistics(authData, flag, CREATE)
);
}
Result.any
The main use case for this method is to avoid nested try-catch blocks. If the first input parameter returns an error, then try the next parameter and so forth to choose the first available alternative. If all fail the first one is returned as an error. The input parameters are lazily loaded.
DON'T: Nested try-catch blocks.
public final class SomeService {
public record SomeType(String info) {
}
private SomeService() {
}
public static SomeType getSomeType() {
try {
return fromSource1();
} catch (Exception e) {
try {
return fromSource2();
} catch (Exception e2) {
try {
return fromSource3();
} catch (Exception e3) {
throw new RuntimeException(e);
}
}
}
}
public static SomeType fromSource1() {
throw new IllegalStateException(); // Simulate exception.
}
public static SomeType fromSource2() {
throw new IllegalStateException(); // Simulate exception.
}
public static SomeType fromSource3() {
return new SomeType("value");
}
}
Instead, do this (functional error handling).
public final class SomeService {
public record SomeType(String info) {
}
private SomeService() {
}
public static Result<ErrMsg, SomeType> getSomeType() {
return Result.any(
() -> fromSource1(),
() -> fromSource2(),
() -> fromSource3()
);
}
public static Result<ErrMsg, SomeType> fromSource1() {
return Result.err(new ErrMsg("Error occurred 1")); // Simulate error.
}
public static Result<ErrMsg, SomeType> fromSource2() {
return Result.err(new ErrMsg("Error occurred 2")); // Simulate error.
}
public static Result<ErrMsg, SomeType> fromSource3() {
return Result.ok(new SomeType("value"));
}
}
Simplifying try-catch
public final class SomeService {
private SomeService() {
}
public static void tryCatchExample() {
// Before
Stat statistic = null;
try {
statistic = createStatistics(resource);
logtoConsole(stat);
} catch (Exception ex) {
logError(ex);
statistic = Stat.EMPTY;
}
// After
var statistic = Result.tryOf(() -> createStatistics(resource))
.onOk(stat -> logToConsole(stat))
.onErr(err -> logError(err.ex())) // The err variable will in this case be of type Result.Err.ErrEx from the tryOf() method.
.orElseGet(() -> Stat.EMPTY);
}
}
Using checked exceptions in lambda functions
Both Result and Concurrent support using checked exceptions in lambda functions. This means that you can call a function which throws a checked exception without wrapping it into typically a RuntimeException in a try-catch block. If thrown, the checked exception will simply bubble up the stack as normal.
public final class SomeService {
// Before, use static initializer block to deal with checked exceptions.
private static final String JWK = getProperty("jwk");
private static final RSAKey RSA_KEY;
private static final RSASSASigner JWS_SIGNER;
static {
try {
RSA_KEY = RSAKey.parse(JWK);
JWS_SIGNER = new RSASSASigner(RSA_KEY.toPrivateKey());
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
// After, use Result.tryOf (imported statically).
private static final String JWK = getProperty("jwk");
private static final RSAKey RSA_KEY = tryOf(() -> RSAKey.parse(JWK)).getValueOrThrow();
private static final RSASSASigner JWS_SIGNER = tryOf(() -> new RSASSASigner(RSA_KEY.toPrivateKey())).getValueOrThrow();
}
public final class UrlConsumer {
public record OutputDTO(String content) {
}
private UrlConsumer() {
}
public static Result<ErrMsg, OutputDTO> consumeUrl(String url) {
return validate(url)
.flatMap(UrlConsumer::consume); // Note: The consume-method throws a checked exception.
}
private static Result<ErrMsg, String> validate(String inputUrl) {
return Result.<ErrMsg, String>of(inputUrl)
.filter(Objects::nonNull, _ -> new ErrMsg("Missing url"))
.filter(url -> url.startsWith("https"), _ -> new ErrMsg("Illegal url"));
}
// Note: This method throws a checked exception. It can be used seamlessly in the flatMap-method above.
private static Result<ErrMsg, OutputDTO> consume(String url) throws Exception {
try (var httpClient = HttpClient.newBuilder().build()) {
var request = HttpRequest.newBuilder().uri(new URI(url)).GET().build();
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200)
return Result.err(new ErrMsg("Illegal endpoint response"));
return Result.ok(new OutputDTO(response.body()));
}
}
}
Concurrent.all
Has an identical API with Result.all, and provides a simple way to do concurrent for-comprehension.
This makes it easy to run independent work concurrently in the exact same manner as synchronous code for excellent maintainability.
This way business logic will not get intermixed with the technical details of manual thread management.
Intended to be used in simple and straightforward concurrent scenarios that cover most use cases, i.e., when you have parallel work with no dependencies between branches. More complex scenarios will require a dedicated approach.
Internally, it uses structured concurrency and virtual threads. It therefore treats all given tasks and their related sub-tasks as a single unit of work (parent-child hierarchy). All threads are cleaned up when the whole hierarchy is completed. Any ScopedValue bound to the parent thread will automatically be inherited.
You can configure timeout and a custom error function in case an exception is thrown. The default configuration is to rethrow any exception that might occur and use a 10-second timeout.
Example without custom configuration
Looking at the example above for Result.all, then all you have to do is replace Result.all with Concurrent.all to make all the input functions be eagerly and concurrently executed.
Error handling is the same as for Result.all. If any of the input functions returns an error, then the error is returned out directly in the order the functions are declared (not which thread completes first).
public final class UserService {
public record UserDetails(String name, Long phoneNumber, Integer creditCard) {
}
private UserService() {
}
public static Result<ErrMsg, UserDetails> getUserDetails() {
return Concurrent.all(
() -> getName(),
() -> getPhoneNumber(),
() -> getCreditCard()
)
.map((name, phoneNumber, creditCard) ->
new UserDetails(name, phoneNumber, creditCard)
);
}
private static Result<ErrMsg, String> getName() {
return Result.ok("Name");
}
private static Result<ErrMsg, Long> getPhoneNumber() {
return Result.ok(123456L);
}
private static Result<ErrMsg, Integer> getCreditCard() {
return Result.ok(987654);
}
}
Example custom configuration
Concurrent allows you to set custom configuration for timeout, thread factory and an error function that runs in case an exception is thrown.
public final class UserService {
public record UserDetails(String name, Long phoneNumber, Integer creditCard) {
}
private UserService() {
}
// Concurrent config with custom timeout and an error function that runs in case an exception is thrown.
private static final Concurrent.Config<ErrMsg> config = new Concurrent.Config<>(Duration.ofMillis(30_000), ex -> {
if (ex instanceof TimeoutException)
return new ErrMsg("Timed out getting user details");
System.out.println("Logging error: " + ex.getMessage());
return new ErrMsg("An unforeseen error occurred.");
});
// Another config demonstrating only an error function.
private static final Concurrent.Config<ErrMsg> anotherConfig = new Concurrent.Config<>((Exception ex) -> new ErrMsg(ex.getMessage()));
public static Result<ErrMsg, UserDetails> getUserDetails() {
return Concurrent.all(config,
() -> getName(),
() -> getPhoneNumber(),
() -> getCreditCard()
)
.map((name, phoneNumber, creditCard) ->
new UserDetails(name, phoneNumber, creditCard)
);
}
private static Result<ErrMsg, String> getName() {
return Result.ok("Name");
}
private static Result<ErrMsg, Long> getPhoneNumber() {
return Result.ok(123456L);
}
private static Result<ErrMsg, Integer> getCreditCard() {
return Result.ok(987654);
}
}
Concurrent.run
Starts independent fire-and-forget tasks (virtual threads) and returns immediately. Null input values are ignored.
Useful for side effects where the caller should not wait for completion.
The threads may still be running after Concurrent.run(...) returns.
This function takes no responsibility for handling the lifecycle of the threads started. It’s completely up to the caller to make sure every thread handles timeouts, shutdown and exceptions gracefully.
The function without Config uses the default thread factory.
Functions with Config use the configured thread factory.
Exceptions thrown by a task do not propagate to the calling thread.
Config#errorFunction and Config#timeout are not used.
If using a custom thread factory always set an uncaughtExceptionHandler, or handle exceptions inside each task.
public final class SomeFeature {
public SomeFeature() {
// It's optional to override the default config.
// Should typically be called only once on application startup.
Concurrent.setDefaultConfig(
new Concurrent.Config<>((thread, throwable) -> {
try {
log.error(SomeFeature.class, throwable, "An error occurred");
} catch (Throwable th) {
System.out.println("Something went wrong logging the error: " + th.getMessage());
}
})
);
}
public Result<ServiceError, Consent> updateResource(
FhirResource fhirResource,
ApiVersion apiVersion,
AuthData authData
) {
return parseFhirResource(Consent.class, fhirResource)
.flatMap(consent -> validateUpdateRules(consent, apiVersion, authData))
.onOk(consent -> CriticalInfoDb.update(toDTO(consent, apiVersion, authData, getValidUntilDate(consent))))
.map(consent -> CorrectDefectsConsent.correct(consent))
.onOk(consent -> {
// Run side effects concurrently in three separate fire-and-forget threads.
Concurrent.run(
() -> AuditLogger.produceKafkaMessage(UPDATE, authData, consent, ConsentStatus::hasDeletedStatus),
() -> ProduceKafkaStatusMessage.produceKafkaMessage(authData),
() -> LogDefectsConsent.log(consent, authData)
);
});
}
}
License
Copyright 2026 Norsk Helsenett SF.
The framework is licensed under the Apache License, Version 2.0. See LICENSE and NOTICE for details.