Inspiration
Error handling in Java is powerful — but it is also architectural.
In small programs, exceptions are straightforward. In larger systems, they quietly influence structure: where logic lives, how layers depend on each other, and how responsibilities are divided.
While refactoring some database initialization code, I noticed something uncomfortable: most of my try/catch blocks weren’t protecting against rare failures. They were handling completely expected outcomes — missing configuration, invalid values, connection problems.
Those aren’t exceptional situations. They are part of the domain.
That observation led me to experiment with a minimal Either<L, R> container — not only as a functional programming exercise, but also as a design exploration:
What happens when failure becomes data instead of control flow?
This article walks through:
- The architectural problem with nested exception handling
- Designing a minimal
Either<L, R> - Refactoring real code to use it
- The trade-offs this introduces
An obvious question would be, "Why not use Vavr?"
Short answer, We can! But then I also wanted to learn how to make composable containers.
Problem: Exceptions as Hidden Control Flow
Consider a simplified database setup workflow:
- Read configuration from file
- Parse configuration
- Connect to database
- Execute schema
- Insert data
public class DbTestImperative {
public static void main(String[] args) {
performDatabaseOperation();
}
public static void performDatabaseOperation() {
try {
Properties props = readDatabaseConfig("db_config.properties");
String url = props.getProperty("db.url");
String user = props.getProperty("db.user");
String password = props.getProperty("db.password");
try (
Connection conn = DriverManager.getConnection(url, user, password);
Statement stmt = conn.createStatement()
) {
String schema = readTableSchema("schema.sql");
stmt.execute(schema);
stmt.executeUpdate("INSERT INTO users (name, email) VALUES ('John', 'john@example.com')");
}
} catch (IOException e) {
System.err.println("Configuration error: " + e.getMessage());
} catch (SQLException e) {
System.err.println("Database error: " + e.getMessage());
}
}
public static String readTableSchema(String fileName) throws IOException {
try (
InputStream in = DbTestImperative.class
.getClassLoader()
.getResourceAsStream("schema.sql")
) {
if (in == null) {
throw new IOException("schema.sql not found");
}
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
public static Properties readDatabaseConfig(String fileName) throws IOException {
Properties props = new Properties();
try (InputStream in = DbTestImperative.class.getClassLoader().getResourceAsStream(fileName)) {
props.load(in);
System.out.println("Configuration file read successfully.");
}
return props;
}
}
This code works. But architecturally, several things are happening:
- Error propagation is implicit.
- Business logic and error handling are interleaved.
- Each layer must decide whether to catch, wrap, or rethrow.
- Method signatures do not communicate failure modes clearly.
In this flow, an IOException is not an anomaly. It is part of the normal behavior of the system. The type system, however, does not reflect that.
The result is control flow that is partially hidden in exception mechanics.
Modeling Failures Explicitly
Instead of throwing exceptions, what if methods returned:
Either<AppError, DbConfig>
That type says:
- Left → Something went wrong (AppError)
- Right → Success (DbConfig)
Failure is no longer an implicit side effect. It is a first-class value.
The caller cannot ignore it. The method signature forces acknowledgment.
Designing a Minimal Either<L, R>
The goal was not to build a full functional library, but to design the smallest useful container that supports:
- Mapping over success
- Chaining fallible operations
- Explicit boundary handling
This is not meant to compete with libraries such as Vavr. The purpose here is architectural clarity — understanding the mechanics by building the abstraction ourselves.
public interface Either<L, R> {
boolean isLeft();
boolean isRight();
L getLeft();
R getRight();
Optional<L> left();
Optional<R> right();
static <L, R> Either<L, R> left(L value) {
return new Left<>(value);
}
static <L, R> Either<L, R> right(R value) {
return new Right<>(value);
}
@SuppressWarnings("unchecked")
default <L2> Either<L2, R> mapLeft(Function<L, L2> mapper) {
Objects.requireNonNull(mapper);
return isLeft() ? left(mapper.apply(getLeft())) : (Either<L2, R>) this;
}
@SuppressWarnings("unchecked")
default <R2> Either<L, R2> mapRight(Function<R, R2> mapper) {
Objects.requireNonNull(mapper);
return isRight() ? right(mapper.apply(getRight())) : (Either<L, R2>) this;
}
@SuppressWarnings("unchecked")
default <L2> Either<L2, R> flatMapLeft(Function<L, Either<L2, R>> mapper) {
return isLeft() ? mapper.apply(getLeft()) : (Either<L2, R>) this;
}
@SuppressWarnings("unchecked")
default <R2> Either<L, R2> flatMapRight(Function<R, Either<L, R2>> mapper) {
return isRight() ? mapper.apply(getRight()) : (Either<L, R2>) this;
}
default <L2, R2> Either<L2, R2> biMap(Function<L, L2> leftMapper, Function<R, R2> rightMapper) {
Objects.requireNonNull(leftMapper);
Objects.requireNonNull(rightMapper);
return isRight() ?
right(rightMapper.apply(getRight())) :
left(leftMapper.apply(getLeft()));
}
default <T> T fold(Function<L, T> leftFold, Function<R, T> rightFold) {
Objects.requireNonNull(leftFold);
Objects.requireNonNull(rightFold);
return isRight() ?
rightFold.apply(getRight()) :
leftFold.apply(getLeft());
}
default void fold(Consumer<L> leftFold, Consumer<R> rightFold) {
Objects.requireNonNull(leftFold);
Objects.requireNonNull(rightFold);
if (isRight()) rightFold.accept(getRight());
else leftFold.accept(getLeft());
}
final class Left<L, R> implements Either<L, R> {
private final L value;
public Left(L value) { this.value = value; }
public boolean isLeft() { return true; }
public boolean isRight() { return false; }
public L getLeft() { return value; }
public R getRight() { throw new NoSuchElementException("getRight() on Left<L, R>"); }
public Optional<L> left() { return Optional.ofNullable(value); }
public Optional<R> right() { return Optional.empty(); }
}
final class Right<L, R> implements Either<L, R> {
private final R value;
public Right(R value) { this.value = value;}
public boolean isLeft() { return false; }
public boolean isRight() { return true; }
public L getLeft() { throw new NoSuchElementException("getLeft() on Right<L, R>"); }
public R getRight() { return value; }
public Optional<L> left() { return Optional.empty(); }
public Optional<R> right() { return Optional.ofNullable(value); }
}
}
Three operations carry most of the weight:
- mapRight → transform success
- flatMapRight → chain fallible operations
- fold → exit the abstraction
The rest supports ergonomics and symmetry.
Refactoring the First Layer: Configuration
Instead of throwing IOException, we return Either.
public static Either<AppError, Properties> readDatabaseConfig(String fileName) {
Properties props = new Properties();
try (InputStream in = DbTestEither.class
.getClassLoader()
.getResourceAsStream(fileName)) {
if (in == null)
return Either.left(new AppError(fileName + " not found"));
props.load(in);
return Either.right(props);
} catch (IOException e) {
return Either.left(new AppError(e.getMessage()));
}
}
The method now communicates explicitly:
- It may fail.
- Failure is part of its contract.
- Callers must handle it.
No checked exceptions. No hidden propagation.
Adding Validation as Composition
Parsing configuration is another fallible step.
public static Either<AppError, DbConfig> parseConfig(Properties properties) {
String url = properties.getProperty("db.url");
String user = properties.getProperty("db.user");
String password = properties.getProperty("db.password");
if (url == null || url.isBlank())
return Either.left(new AppError("db.url is not provided"));
if (user == null || user.isBlank())
return Either.left(new AppError("db.user is not provided"));
if (password == null || password.isBlank())
return Either.left(new AppError("db.password is not provided"));
return Either.right(new DbConfig(url, user, password));
}
Composition becomes straightforward:
public static Either<AppError, DbConfig> loadConfig(String fileName) {
return readDatabaseConfig(fileName)
.flatMapRight(DbTestEither::parseConfig);
}
This is where the architectural shift happens.
- If reading fails → the error propagates.
- If parsing fails → the error propagates.
- If both succeed → we get DbConfig.
No nested conditionals.
No rethrowing.
No duplicated catch logic.
There is no branching logic here. The control flow is encoded in the type.
Handling at the System Boundary
Eventually, you must handle the result. That’s what fold is for:
public static void performDatabaseOperation() {
loadConfig("db_config.properties").fold(
err -> System.err.println("Error Occurred: " + err.errorMsg),
DbTestEither::insertRecord
);
}
Architecturally, this separation matters.
Inside the system, logic remains composable and side-effect free.
At the boundary, you translate values into effects: logging, retries, termination.
That distinction scales well.
Architectural Implications
Using Either changes design in subtle but important ways:
- Failure is part of the type system. Method signatures now document failure explicitly.
- Control flow becomes declarative. `flatMapRight` encodes propagation rules structurally.
- Boundaries become clear, fold marks the edge between pure logic and side effects.
- Exceptions become truly Exceptional, you reserve exceptions for unrecoverable system failures.
Trade-offs
This approach is not universally superior.
You lose:
- Automatic stack trace propagation unless you explicitly model it
- Simplicity for very small programs
- Immediate familiarity for some teams
You gain:
- Explicit failure modeling
- Better composability
- Clearer architectural boundaries
- Improved reasoning about control flow
For business logic, validation, parsing, and external calls, this model scales better than nested exceptions.
Closing Thoughts
Designing this minimal Either wasn’t about importing functional programming into Java.
It was about making failure visible.
When failure becomes data instead of control flow:
- Pipelines become natural.
- Types tell the story.
- Architecture becomes clearer.
And that changes how you structure systems.
You can find the entire code for this article in this Github Gist
Top comments (0)