DEV Community

Sergiy Yevtushenko
Sergiy Yevtushenko

Posted on • Edited on

We Should Write Java Code Differently: Let's Get Practical

We Should Write Java Code Differently: Let's Get Practical

A few years ago I wrote about why we should write Java code differently. The core argument: most of what slows us down is not the amount of code — it's the amount of context we lose while writing it. Nullable variables, business exceptions, framework magic — each one eats information that should have been explicit.

That article diagnosed the problem. This one delivers the tools.

Three types — Option, Result, and Promise — cover virtually every return value in a Java backend. They compose with the same map/flatMap vocabulary you already know from Streams. Once you internalize them, the code you write becomes shorter, safer, and — this is the part that surprised me — significantly easier to read months later.


Option: A Value That Might Not Be There

If you've used Streams, you already understand the core mechanic — transform the value inside, chain transformations, extract at the end. Option applies the same thinking to absent values.

findUser(id).flatMap(user -> findAddress(user.addressId())
            .map(address -> new Profile(user, address)));
Enter fullscreen mode Exit fullscreen mode

If any step returns Option.none(), the whole chain short-circuits. No null checks, no early returns, no branching. The absent-value case propagates automatically.

Where does Option come from? At adapter boundaries — wrapping nullable external APIs:

Option<String> name = Option.option(request.getParameter("name"));
Enter fullscreen mode Exit fullscreen mode

Inside business logic, Option is the container for everything genuinely optional. The type makes the absence visible in the signature, not hidden in a javadoc comment.

A few things that make Option practical:

// Pattern matching with sealed types
switch (option) {
    case Option.Some(var value) -> process(value);
    case Option.None() -> handleAbsence();
}

// Convert to Result when absence is an error
option.toResult(NOT_FOUND);

// Combine multiple Options — all must be present
Option.all(firstName, lastName, email)
      .map(Contact::new);
Enter fullscreen mode Exit fullscreen mode

The conversion methods matter. Option is not an island — it flows into Result and Promise when the context demands it.


Result: Errors Without Exceptions

This is where things change fundamentally.

Consider typical Java error handling:

public User registerUser(RegistrationRequest request) {
    if (request.email() == null || request.email().isBlank()) {
        throw new ValidationException("Email is required");
    }
    if (userRepository.existsByEmail(request.email())) {
        throw new DuplicateEmailException(request.email());
    }
    try {
        String hash = passwordHasher.hash(request.password());
        return userRepository.save(new User(request.email(), hash));
    } catch (HashingException e) {
        throw new RegistrationFailedException("Password hashing failed", e);
    }
}
Enter fullscreen mode Exit fullscreen mode

The method signature says it returns User. It lies. It can throw three different exceptions, and the compiler won't tell you about any of them. The caller has no idea what to catch. The next developer reading this code has to trace every path to understand what can go wrong.

With Result:

public Result<User> registerUser(RegistrationRequest request) {
    return Email.email(request.email())
                .flatMap(this::ensureUnique)
                .flatMap(email -> hashAndCreateUser(email, request.password()))
                .flatMap(userRepository::save);
}

private Result<Email> ensureUnique(Email email) {
    return userRepository.existsByEmail(email)
           ? EMAIL_ALREADY_EXISTS.result()
           : Result.success(email);
}

private Result<User> hashAndCreateUser(Email email, String password) {
    return passwordHasher.hash(password)
                         .map(hash -> new User(email, hash));
}
Enter fullscreen mode Exit fullscreen mode

The return type tells the truth — this operation can fail. Every failure path is visible in the chain. No exceptions thrown, no exceptions caught. The compiler enforces that the caller handles the Result.

How Errors Work

Errors are values, not exceptions. They implement the Cause interface:

public sealed interface RegistrationError extends Cause {
    enum General implements RegistrationError {
        EMAIL_ALREADY_EXISTS("Email already registered"),
        TOKEN_GENERATION_FAILED("Token generation failed");

        private final String message;

        General(String message) { 
            this.message = message; 
        }
        @Override 
        public String message() { 
            return message; 
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Fixed messages become enum constants. Dynamic messages become records. All are sealed — the compiler knows every possible failure. Pattern matching works:

switch (result) {
    case Result.Success(var user) -> sendWelcome(user);
    case Result.Failure(var cause) -> logAndRespond(cause);
}
Enter fullscreen mode Exit fullscreen mode

Composition

The real power shows in composition. Result.all() collects independent validations:

record ValidRegistration(Email email, Password password, PhoneNumber phoneNumber) {
    public static Result<ValidRegistration> validRegistration(Registration raw) {
        return Result.all(Email.email(raw.email()),
                          Password.password(raw.password()), 
                          PhoneNumber.phoneNumber(raw.phone()))
                     .map(ValidRegistration::new);
    }    
}
Enter fullscreen mode Exit fullscreen mode

All three validations run. All failures are collected — not just the first one. The map only executes if all three succeed. One line replaces the usual cascade of if-checks-and-early-returns.

Interfacing with Legacy Code

The world throws exceptions. lift() catches them at the boundary:

Result.lift(DatabaseError::new, () -> legacyDao.findById(id));
Enter fullscreen mode Exit fullscreen mode

Exception goes in, Result comes out. The boundary is explicit. Business logic stays clean.


Promise: Result, But Async

Promise is Result where the answer hasn't arrived yet. Same map, same flatMap, same mental model — just non-blocking:

public Promise<EnrichedUser> loadEnriched(UserId userId) {
    return findUser(id).flatMap(this::loadUserWithOrders);
}

private Promise<EnrichedUser> loadUserWithOrders(User user) {
    return findOrders(user.id()).map(orders -> new EnrichedUser(user, orders));
}
Enter fullscreen mode Exit fullscreen mode

If findUser fails, the chain short-circuits — just like Result. If loadUserWithOrders fails, same thing. Errors are Cause values, same as in Result. The only difference: the chain executes asynchronously.

Parallel Operations

Independent operations run in parallel with Promise.all():

record Dashboard(Profile profile, List<Order> orders, List<Notification> notifications) {}

public Promise<Dashboard> loadUserDashboard(UserId userId) {
    return Promise.all(fetchProfile(userId), 
                       fetchOrders(userId), 
                       fetchNotifications(userId))
                  .map(Dashboard::new);
}
Enter fullscreen mode Exit fullscreen mode

Three async calls, all independent, all in parallel. Result combined when all complete. If any fails, the whole thing fails — with a meaningful Cause.

For "first success wins" semantics:

Promise<Value> fetch(Key key) {
    return Promise.any(fetchFromPrimaryCache(key), 
                       fetchFromReplicaCache(key), 
                       fetchFromDatabase(key));
}
Enter fullscreen mode Exit fullscreen mode

Side Effects

Promise distinguishes between dependent and independent actions:

findUser(id).flatMap(this::validateAccess)       // dependent — runs in sequence
            .flatMap(this::loadProfile)          // dependent — runs after validation
            .onSuccess(metrics::recordAccess)    // independent — runs async, doesn't block chain
            .onFailure(logger::warn);            // independent — runs async on failure
Enter fullscreen mode Exit fullscreen mode

Dependent actions (map, flatMap) execute in order and can fail the chain. Independent actions (onSuccess, onFailure) run asynchronously and never affect the chain's outcome. This distinction eliminates an entire class of bugs where logging or metrics accidentally break the business flow.
There are also dependent side effects, for the cases when ordering matters:

findUser(id).flatMap(this::validateAccess)       // dependent — runs in sequence
            .flatMap(this::loadProfile)          // dependent — runs after validation
            .withSuccess(metrics::recordAccess)  // dependent — runs on success, in order like map/flatMap
            .withFailure(logger::warn);          // dependent — runs on failure, in order like map/flatMap
Enter fullscreen mode Exit fullscreen mode

Timeouts and Recovery

fetchFromRemoteService(request).timeout(timeSpan(5).seconds())
                               .recover(cause -> cachedFallback(request));
Enter fullscreen mode Exit fullscreen mode

If the remote call doesn't resolve in 5 seconds, it fails. recover converts the failure back to success using a fallback. Clean, composable, no try-catch.


The Three Types Together

The real picture emerges when all three work together. Consider a realistic operation — processing an incoming order:

public Promise<OrderConfirmation> processOrder(RawOrderRequest raw) {
    return ValidOrder.validOrder(raw)              // Result<ValidOrder> — sync validation
                     .async()                      // → Promise<ValidOrder>
                     .flatMap(this::enrichOrder)
                     .flatMap(orderRepository::save)
                     .onSuccess(eventBus::publishOrderCreated);
}

private Promise<EnrichedOrder> enrichOrder(ValidOrder order) {
    return Promise.all(inventoryService.check(order.items()),
                       pricingService.calculate(order.items()),
                       customerService.find(order.customerId()))
                  .map((availability, pricing, customer) ->
                           new EnrichedOrder(order, availability, pricing, customer));
}
Enter fullscreen mode Exit fullscreen mode

What happens here:

  1. ValidationValidOrder.validOrder() returns Result<ValidOrder>. Parse, don't validate. If the input is malformed, a Cause explains why. No exception.
  2. Sync to async.async() lifts the Result into a Promise. From here, everything is non-blocking.
  3. Parallel fetchenrichOrder calls three independent services simultaneously. All must succeed.
  4. Enrichment — results combined into EnrichedOrder. The map only runs if all three calls succeed.
  5. Persistence — saved to repository. Returns Promise<OrderConfirmation>.
  6. Side effect — event published asynchronously. Does not affect the response.

No try-catch. No null checks. No if (result == null) return error. Every failure path handled by the type system. Every step clearly visible.


The Decision Tree

Choosing the right type is mechanical:

Can this operation fail?
├── NO: Can the value be absent?
│   ├── NO → return T
│   └── YES → return Option<T>
└── YES: Is it async/IO?
    ├── NO → return Result<T>
    └── YES → return Promise<T>
Enter fullscreen mode Exit fullscreen mode

Four return kinds. No judgment calls. The decision tree covers every method in a Java backend.

One allowed combination: Result<Option<T>> — when the value is genuinely optional but validation can still fail. Example: an optional referral code that, if provided, must match a specific format.

One forbidden combination: Promise<Result<T>> — double error channel. Promise already carries failure semantics. Nesting Result inside it means two places to check for errors.


What Changes In Practice

After a few weeks of writing code this way, something shifts. You stop thinking about error handling as a separate concern. It's not something you add after the happy path — it's embedded in the types. The compiler catches what used to be runtime surprises.

Code reviews get faster. When every method returns one of four types, the shape of the code becomes predictable. You don't need to trace exception paths through five layers. The return type tells you everything.

Testing simplifies. Each step in a chain is independently testable. Failures are values you can assert on — not exceptions you have to catch. Mock a dependency to return EMAIL_ALREADY_EXISTS.result() and verify the chain handles it correctly.

And the types compose. A Result from validation flows into a Promise for async processing, which fans out into parallel Promise.all(), which combines back into a single response. Each piece connects to the next with flatMap. The vocabulary is always the same.


Naming That Scales

Types solve the "what can happen" question. But there's another source of friction — naming. Every code review has that moment: "should this be fetchUser or loadUser or getUser?" The debate is real, and it wastes time because there's no shared vocabulary.

Zone-based naming eliminates this. The idea, adapted from Derrick Brandt's systematic approach to clean code: verbs belong to abstraction levels. Use the wrong verb at the wrong level, and the name signals something misleading.

Zone 2 — orchestration steps. These coordinate other operations. They don't touch databases or parse bytes. They organize.

Verb When to use
validate Checking rules and constraints
process Transforming or interpreting data
load Retrieving data for use
save Persisting changes
resolve Determining ambiguous cases
build Assembling complex objects
notify Informing others of events

Zone 3 — leaf operations. These do the actual work. One responsibility, specific and concrete.

Verb When to use
fetch Pull from external source
parse Break down structured input
format Build structured output
calculate Perform computation
hash Cryptographic transformation
send Transmit over network
extract Pull piece from larger structure

The pattern:

// Zone 2 — step interface, orchestration verb
interface LoadUserProfile {
    Promise<UserProfile> apply(UserId id);
}

// Zone 3 — leaf, implementation verb
private Promise<User> fetchFromDatabase(UserId id) { ... }
private Option<CachedUser> extractFromCache(UserId id) { ... }
Enter fullscreen mode Exit fullscreen mode

If you see fetch on a step interface — something's wrong. If you see process on a leaf — same thing. The verb tells you the abstraction level before you read the parameters.

This matters for the same reason the four return types matter: it makes code predictable. A developer scanning an unfamiliar codebase can tell from the name alone whether they're looking at orchestration or implementation. No need to open the method body.


Getting Started

If your codebase currently uses exceptions for business errors and null for absent values, you don't need to rewrite everything. Start at the boundaries:

  1. Pick one new feature. Write it with Result returns instead of exceptions.
  2. Wrap legacy calls with Result.lift() and Option.option() at the adapter boundary.
  3. Let the types propagate. Once one method returns Result, its callers naturally follow.

The types are available in Pragmatica Core — a focused library with no transitive dependencies.

This is the foundation that JBCT (Java Backend Coding Technology) builds on. Six structural patterns, four return kinds, mechanical rules that make code deterministic and AI-friendly. But the types come first. Everything else follows from getting the types right.


Previously: Introduction to Pragmatic Functional Java (2019) | We Should Write Java Code Differently (2021)

Top comments (0)