Modeling Failure as Data
SummaryJava 21 features like Records and Sealed Interfaces...
Java 21 features like Records and Sealed Interfaces...
Java 21 features like Records and Sealed Interfaces enable modeling failure as data, enhancing robustness and maintainability in distributed systems.
Modeling Failure as Data
In distributed systems, failures are inevitable. However, by treating failures as first-class values rather than side-effects, developers can create more robust and maintainable systems. This approach is known as Railway Oriented Programming (ROP). In this section, we will explore how to implement specific Failure records, such as StaleStateFailure and PartitionFailure, using Java 21 features like Records and Sealed Interfaces.
Sealed Interfaces for Failure Hierarchy
Sealed interfaces in Java 21 provide a way to restrict which classes or interfaces can extend or implement them. This feature is particularly useful for creating a failure hierarchy, where each failure type is a subclass of a sealed interface. For example:
public sealed interface Failure permits StaleStateFailure, PartitionFailure, ValidationFailure, DependencyFailure {
String message();
}
By using a sealed interface, we can ensure that all possible failure types are accounted for at compile-time, reducing the risk of unhandled exceptions at runtime.
Implementing Failure Records
Java Records are a natural fit for representing failure data points. They are shallowly immutable by default, which makes them ideal for storing failure information. For instance, a StaleStateFailure record could be implemented as follows:
public record StaleStateFailure(String entityId, long expectedVersion, long actualVersion) implements Failure {
@Override public String message() { return "Entity " + entityId + " version mismatch."; }
}
Similarly, a PartitionFailure record could be defined as:
public record PartitionFailure(String region, String detail) implements Failure {
@Override public String message() { return "Network partition in " + region + ": " + detail; }
}
These records provide a concise and expressive way to represent failure data, making it easier to handle and propagate failures throughout the system.
Mapping Exceptions to Failure Records
In a distributed system, exceptions can occur due to various reasons such as network partitions, timeouts, or constraint violations. To handle these exceptions in a functional way, we need to map them to failure records. This mapping can be done at the boundary of the system, where technical exceptions are caught and wrapped in domain-specific failure types. For example:
public Result<Integer, Failure> applyUpdate(Intent intent) {
try {
int rows = repository.update(intent);
if (rows == 0) {
return Result.failure(new StaleStateFailure(intent.id(), intent.version(), -1));
}
return Result.success(rows);
} catch (TimeoutException | ConnectionException e) {
return Result.failure(new PartitionFailure("primary-cluster", e.getMessage()));
} catch (Exception e) {
return Result.failure(new DependencyFailure(e.getMessage()));
}
}
By mapping exceptions to failure records, we can create a thematic “two-track” pipeline, where successes and failures are handled separately, making the code more readable and maintainable.
Conclusion
In this section, we have seen how to model failure as data using Java 21 features like Records and Sealed Interfaces. By treating failures as first-class values, we can create more robust and maintainable distributed systems. The use of sealed interfaces and records provides a concise and expressive way to represent failure data, making it easier to handle and propagate failures throughout the system.
Sources
[1] Java 21 Documentation: Sealed Interfaces [2] Java 21 Documentation: Records