Part 4: Error Handling & Composition
Series: Java Backend Coding Technology | Part: 4 of 9
Previous: Part 3: Parse, Donât Validate | Next: Part 5: Basic Patterns & Structure
Overview
This part completes the core principles by teaching error handling as values, null policy, error recovery, and monadic composition rules.
By the end of this part, youâll understand:
- Why business logic never throws exceptions
- When null is acceptable (and when it isnât)
- How to recover from errors with fallback values
- Basic testing patterns for Result/Promise
- Monadic composition rules and lambda guidelines
- Common mistakes to avoid
Prerequisites: Part 2A and Part 2B
No Business Exceptions
Business failures are not exceptional - theyâre expected outcomes of business rules. An invalid email isnât an exception; itâs a normal case of bad input. An account being locked isnât an exception; itâs a business state.
The rule: Business logic never throws exceptions for business failures. All failures flow through Result or Promise as typed Cause objects.
Why by criteria:
- Mental Overhead: Checked exceptions pollute signatures (+1 for Result). Unchecked are invisible - must read implementation (+2 for Result).
- Business/Technical Ratio: Stack traces are technical noise; typed Causes are domain concepts (+2 for Result).
- Reliability: Exceptions bypass type checker; Result makes all failures explicit and compiler-verified (+3 for Result).
- Complexity: Exception hierarchies create cross-package coupling (+1 for Result).
The Traditional (Wrong) Approach
// DON'T: Exceptions for business logic
public User loginUser(String email, String password) throws
InvalidEmailException,
InvalidPasswordException,
AccountLockedException,
CredentialMismatchException {
if (!isValidEmail(email)) {
throw new InvalidEmailException(email);
}
if (!isValidPassword(password)) {
throw new InvalidPasswordException();
}
User user = userRepo.findByEmail(email)
.orElseThrow(() -> new CredentialMismatchException());
if (user.isLocked()) {
throw new AccountLockedException(user.getId());
}
if (!passwordMatches(user, password)) {
throw new CredentialMismatchException();
}
return user;
}
Problems:
- Checked exceptions pollute signatures and force callers to handle or rethrow
- Unchecked exceptions are invisible in signatures - you canât tell what might fail without reading implementation
- Exception hierarchies create coupling
- Stack traces are expensive and often irrelevant for business failures
- Testing requires catching exceptions and inspecting types
The Result-Based Approach
// DO: Failures as typed values
public Result<User> loginUser(String emailRaw, String passwordRaw) {
return Result.all(Email.email(emailRaw),
Password.password(passwordRaw))
.flatMap(this::validateAndCheckStatus);
}
private Result<User> validateAndCheckStatus(Email email, Password password) {
return checkCredentials(email, password)
.flatMap(this::checkAccountStatus);
}
private Result<User> checkCredentials(Email email, Password password) {
return userRepo.findByEmail(email)
.flatMap(user -> validatePassword(user, password));
}
private Result<User> validatePassword(User user, Password password) {
return passwordMatches(user, password)
? Result.success(user)
: LoginError.InvalidCredentials.INSTANCE.result();
}
private Result<User> checkAccountStatus(User user) {
return user.isLocked()
? new LoginError.AccountLocked(user.id()).result()
: Result.success(user);
}
Every failure is a Cause. The LoginError is a sealed interface defining the failure modes:
public sealed interface LoginError extends Cause {
record AccountLocked(UserId userId) implements LoginError {
@Override
public String message() {
return "Account is locked: " + userId;
}
}
enum InvalidCredentials implements LoginError {
INSTANCE;
@Override
public String message() {
return "Invalid email or password";
}
}
}
Composite Failures: Collecting All Errors
Failures compose: Result.all(Email.email(...), Password.password(...)) collects validation failures into a CompositeCause automatically. If both email and password are invalid, the caller gets both errors, not just the first one encountered.
Note: This demonstrates the Fork-Join pattern for parallel validationâcovered in detail in Part 4. For now, understand it as validating multiple fields simultaneously and collecting all errors.
// If both fail:
Result.all(Email.email("not-an-email"),
Password.password("weak"))
.flatMap(this::processLogin);
// Returns: CompositeCause([
// "Invalid email format: not-an-email",
// "Password must be at least 8 characters"
// ])
This is far superior to traditional exception-based approaches where you only learn about one error at a time, forcing users to fix-and-retry repeatedly.
When Exceptions Are Still OK
The âno business exceptionsâ rule is specifically about business failures. There are legitimate uses of exceptions in JBCT code:
Programming errors (bugs) - Use unchecked exceptions:
// IllegalArgumentException for programmer mistakes
public record UserId(UUID value) {
public UserId {
if (value == null) {
throw new IllegalArgumentException("UserId cannot be null");
}
}
}
// IllegalStateException for invariant violations
public class UserSession {
public void logout() {
if (!isAuthenticated()) {
throw new IllegalStateException("Cannot logout - not authenticated");
}
// ... logout logic
}
}
These are assertions about code correctness, not business scenarios. If they throw, itâs a bug that should crash loudly during development.
Framework exceptions at boundaries - Catch and convert in adapters:
// Adapter layer: catch framework exceptions, convert to domain Result/Promise
public class JdbcUserRepository implements FindUser {
@Override
public Promise<User> findById(UserId id) {
return Promise.lift(RepositoryError.DatabaseFailure::new, // Convert SQLException â domain Cause
() -> jdbcTemplate.queryForObject("SELECT * FROM users WHERE id = ?",
new Object[]{id.value()},
this::mapUser));
}
}
Framework exceptions (SQLException, IOException, etc.) are technical failures. They never escape adapters. The adapter catches them and converts to Result or Promise with domain-appropriate Cause objects.
Unrecoverable errors - Let them propagate:
// OutOfMemoryError, StackOverflowError, AssertionError
// These indicate fatal JVM problems - don't catch them
The key distinction:
| Scenario | Use |
|---|---|
| Business failure (invalid input, not found, unauthorized) | Result<T> or Promise<T> with typed Cause |
| Programming error (null where shouldnât be, invalid state) | Unchecked exception (IllegalArgumentException, IllegalStateException) |
| Framework/library exception at boundary | Catch in adapter, convert to Result/Promise |
| Unrecoverable JVM error | Let it propagate, donât catch |
Rule of thumb: If a user action can trigger it, itâs not an exceptionâitâs a business failure that belongs in Result/Promise.
Adapter Exceptions: The Boundary
Foreign code (libraries, frameworks, databases) throws exceptions. Adapter leaves catch these and convert them to Cause objects. Business logic never sees foreign exceptions.
The Pragmatica library provides lift() methods for each monad type to handle exception-to-Cause conversion:
public interface UserRepository {
Promise<Option<User>> findByEmail(Email email);
}
// Implementation (adapter leaf)
class JpaUserRepository implements UserRepository {
public Promise<Option<User>> findByEmail(Email email) {
return Promise.lift(RepositoryError.DatabaseFailure::new,
() -> entityManager.createQuery("SELECT u FROM User u WHERE u.email = :email", UserEntity.class)
.setParameter("email", email.value())
.getResultList()
.stream()
.findFirst()
.map(this::toDomain)
.orElse(Option.none()));
}
}
The lift() methods handle try-catch boilerplate and exception-to-Cause conversion automatically via the provided exception-to-cause mapping function. Each monad type provides its own lift() method: Option.lift(), Result.lift(), and Promise.lift().
The adapter wraps checked PersistenceException in a domain Cause (RepositoryError.DatabaseFailure). Business logic never sees PersistenceException - only domain errors.
Note: This exception handling pattern is applied to database adapters in Part 3 and shown in a complete JOOQ example in Part 6.
Benefits
Errors are just data: You compose them with map, flatMap, and all() like any other value.
Testing is easy: Assert on Cause types without catching exceptions:
@Test
void loginUser_fails_forInvalidEmail() {
loginUser("not-an-email", "Valid1234")
.onSuccess(Assertions::fail); // Should not succeed
}
AI generation is mechanical: The pattern is always the same:
SomeCause.INSTANCE.result()for ResultSomeCause.INSTANCE.promise()for Promise
No decisions about checked vs unchecked, when to catch, how to wrap.
Null Policy
In traditional Java, null has two meanings: âvalue not foundâ and âerror occurred.â This ambiguity forces defensive null checks throughout your codebase. JBCT eliminates this confusion with a simple rule: business logic never returns null.
Never Return Null
Core Rule: JBCT code NEVER returns null. Use Option<T> for optional values.
Traditional Java uses null for semantically different casesââvalue not foundâ and âerror occurred.â This creates hidden failure modes:
// â WRONG - Traditional Java with null
public User findUser(UserId id) {
return repository.findById(id.value()); // May return null - but why?
}
// Caller must defend:
User user = findUser(id);
if (user == null) { // Not found? Error? Database down? Unknown!
// What happened? We don't know.
}
The caller has no idea whether null means âuser doesnât existâ or âdatabase connection failed.â This ambiguity spreads defensive null checks everywhere.
JBCT eliminates this:
// â
CORRECT - Using Option
public Option<User> findUser(UserId id) {
return Option.option(repository.findById(id.value()));
}
// Caller gets explicit semantics:
findUser(id)
.onPresent(user -> process(user))
.onEmpty(() -> handleNotFound());
Now the type signature tells us exactly whatâs happening: the user might not be present, but the operation itself cannot fail. If the operation can fail (database error), use Result<Option<User>> or Promise<Option<User>>.
When Null IS Acceptable
Null appears only at adapter boundaries when interfacing with external code that uses null:
1. Wrapping External APIs
When calling external libraries that may return null, wrap immediately at the adapter boundary:
// Adapter layer - wrap nullable external API
public Option<User> findUser(UserId id) {
User user = repository.findById(id.value()); // External API may return null
return Option.option(user); // Wrap immediately: null â none(), value â some(value)
}
Spring Data JPA example:
public Option<User> findByEmail(Email email) {
return Option.option(userRepository.findByEmail(email.value())); // JPA returns null if not found
}
JDBC ResultSet example:
public Promise<Option<User>> loadUser(UserId id) {
return Promise.lift(RepositoryError.DatabaseFailure::new,
() -> {
ResultSet rs = executeQuery(id);
User user = rs.next() ? mapUser(rs) : null; // null if not found
return Option.option(user); // Wrap before returning
});
}
Pattern: Option.option(nullable) immediately converts external null to Option.none().
2. Writing to Nullable Database Columns
When persisting to databases with nullable columns, convert Option<T> to null for the column:
// Adapter layer - JOOQ insert with optional field
public Promise<Unit> saveUser(User user) {
return Promise.lift(
RepositoryError.DatabaseFailure::new,
() -> {
dsl.insertInto(USERS)
.set(USERS.ID, user.id().value())
.set(USERS.EMAIL, user.email().value())
.set(USERS.REFERRAL_CODE,
user.refCode().map(ReferralCode::value).orElse(null)) // Option â nullable column
.execute();
return Unit.unit();
});
}
JDBC PreparedStatement example:
PreparedStatement stmt = connection.prepareStatement("INSERT INTO users (id, email, referral_code) VALUES (?, ?, ?)");
stmt.setString(1, user.id().value());
stmt.setString(2, user.email().value());
stmt.setString(3, user.refCode().map(ReferralCode::value).orElse(null)); // Option â null
Pattern: .orElse(null) ONLY when mapping Option<T> to nullable database column.
3. Testing Validation
Use null in test inputs to verify that validation correctly rejects null:
@Test
void email_fails_forNull() {
Email.email(null) // Test null input
.onSuccess(Assertions::fail);
}
@Test
void validRequest_fails_whenFieldNull() {
var request = new Request("[email protected]", null); // Test null password
ValidRequest.validRequest(request)
.onSuccess(Assertions::fail);
}
Pattern: Use null in test inputs to verify validation behavior.
When Null is NOT Acceptable
Never Pass Null Between JBCT Components
Business logic components communicate using domain types, never null:
// â WRONG - Defensive null checking in business logic
public Result<Order> processOrder(User user, Cart cart) {
if (user == null || cart == null) { // DON'T do this
return OrderError.InvalidInput.INSTANCE.result();
}
// ...
}
// â
CORRECT - Parameters guaranteed non-null by convention
public Result<Order> processOrder(User user, Cart cart) {
// If cart might be absent, parameter should be Option<Cart>
// If user might be absent, operation shouldn't be called
// ...
}
// â
CORRECT - Explicit optionality when needed
public Result<Order> processOrder(User user, Option<Cart> cart) {
return cart.toResult(OrderError.EmptyCart.INSTANCE)
.flatMap(c -> validateAndProcess(user, c));
}
Rule: If a value might be absent, use Option<T> parameter, never null.
Never Use Null for âUnknownâ vs âAbsentâ
Null conflates two meanings: âvalue not setâ and âvalue unknown/errorâ. Use types to distinguish:
// â WRONG - Null means "unknown"
public String getUserTheme(UserId id) {
Theme theme = findTheme(id);
return theme != null ? theme.name() : null; // What does null mean?
}
// â
CORRECT - Option distinguishes "not set" from "error"
public Option<Theme> getUserTheme(UserId id) {
return findTheme(id); // none() = not set, some(theme) = set
}
// â
CORRECT - Result distinguishes "not found" from "error"
public Result<Theme> getRequiredTheme(UserId id) {
return findTheme(id)
.toResult(ThemeError.NotFound.INSTANCE);
}
Never Return Null from Business Logic
Business logic always uses typed returns:
// â WRONG - Returning null from business logic
public User enrichUser(User user) {
Profile profile = loadProfile(user.id());
if (profile == null) {
return null; // Don't return null!
}
return user.withProfile(profile);
}
// â
CORRECT - Using Option
public Option<User> enrichUser(User user) {
return loadProfile(user.id()) // Returns Option<Profile>
.map(profile -> user.withProfile(profile));
}
// â
CORRECT - Using Result if enrichment can fail
public Result<User> enrichUser(User user) {
return loadProfile(user.id())
.toResult(ProfileError.NotFound.INSTANCE)
.map(profile -> user.withProfile(profile));
}
Summary
| Context | Null Usage | Correct Approach |
|---|---|---|
| Return values from JBCT code | â Never | Use Option<T> |
| Parameters between JBCT components | â Never | Use Option<T> or required types |
| Wrapping external API returns | â Allowed | Option.option(nullable) immediately |
| Writing to nullable DB columns | â Allowed | .orElse(null) at write boundary |
| Test inputs for validation | â Allowed | Test null rejection |
| âUnknownâ or âabsentâ semantics | â Never | Use Option<T> or Result<T> |
Core Principle: Null exists only at system boundaries (adapters). Inside JBCT code, absence is represented by Option.none(), never null.
Why This Matters:
- Mental Overhead: No defensive null checks in business logic
- Reliability: Compiler enforces null handling at boundaries
- Complexity: Clear semantics -
Option.none()vs null confusion eliminated
Error Recovery Patterns
So far weâve focused on error handling - how to represent and propagate failures through Result and Promise. But real systems need recovery - providing fallback values, retrying operations, or gracefully degrading when errors occur.
Providing Fallback Values
When an operation fails, you can substitute a default value using .or():
// Provide a literal fallback value
public Theme getUserTheme(UserId userId) {
return loadTheme(userId) // Returns Option<Theme>
.or(Theme.DEFAULT); // Use DEFAULT if none
}
// Lazy evaluation with supplier
public Config getConfig(String key) {
return loadFromCache(key) // Returns Option<Config>
.or(() -> loadFromDefaults(key)) // Only evaluated if cache miss
.or(Config.EMPTY); // Final fallback
}
The .or() method works with Option, Result, and Promise:
// Option<T> - provide value if empty
option.or(defaultValue)
option.or(() -> computeDefault())
// Result<T> - provide value if failure
result.or(defaultValue)
result.or(() -> computeDefault())
// Promise<T> - provide value if failure (async)
promise.or(defaultValue)
promise.or(() -> computeDefault())
Fallback to Alternative Operations
Use .orElse() when you want to try an alternative operation that returns the same monadic type:
// Try cache, then database, then in-memory
public Promise<User> findUser(UserId id) {
return cacheRepository.find(id) // Promise<User>
.orElse(databaseRepository.find(id)) // Try DB if cache fails
.orElse(Promise.success(User.GUEST)); // Final fallback
}
// Result version
public Result<Config> loadConfig(String key) {
return loadFromFile(key) // Result<Config>
.orElse(loadFromEnv(key)) // Try environment if file fails
.orElse(Result.success(Config.DEFAULT));
}
The difference:
.or(value)- substitute a plain value.orElse(M<T>)- substitute with anotherResult/Promise/Option
Recovering from Specific Failures
Use .recover() when you want to transform failures into successes based on the error:
public Result<User> findUserOrGuest(UserId id) {
return userRepository.find(id)
.recover(cause -> switch (cause) {
case UserError.NotFound _ -> User.GUEST;
case UserError.DatabaseError _ -> User.OFFLINE;
default -> throw new IllegalStateException("Unexpected error: " + cause);
});
}
Promise version with async recovery:
public Promise<Order> processOrder(OrderRequest request) {
return paymentService.charge(request)
.recover(cause -> switch (cause) {
case PaymentError.InsufficientFunds _ -> splitPaymentService.process(request); // Async alternative: split payment
case PaymentError.TemporaryFailure _ -> Promise.success(request).flatMap(paymentService::charge); // Retry once
default -> Promise.failure(cause); // Can't recover
});
}
Real-World Example: Configuration with Fallbacks
public interface LoadConfig {
record Config(String apiUrl, int timeout, boolean retryEnabled) {}
Promise<Config> load();
static LoadConfig loadConfig(LoadFromFile loadFile,
LoadFromEnv loadEnv) {
return () -> loadFile.apply()
.orElse(loadEnv.apply())
.or(() -> new Config("https://api.default.com", 30, true));
}
}
This tries file configuration, falls back to environment variables, and finally uses hardcoded defaults. Each layer adds resiliency.
Graceful Degradation Pattern
Combine recovery with feature detection:
public Promise<Dashboard> loadDashboard(UserId userId) {
return Promise.all(loadUserProfile(userId),
loadRecentOrders(userId).or(List.of()),
loadRecommendations(userId).or(List.of()))
.map(Dashboard::new);
}
Here, profile is required (must succeed), but orders and recommendations gracefully degrade to empty lists if unavailable. The dashboard still loads with reduced functionality.
Common Patterns Summary
| Pattern | Method | Use When |
|---|---|---|
| Default value | .or(value) |
Fixed fallback available |
| Lazy default | .or(() -> compute()) |
Fallback is expensive to create |
| Alternative operation | .orElse(M<T>) |
Try another source/service |
| Conditional recovery | .recover(cause -> ...) |
Transform specific errors to success |
| Graceful degradation | .or(emptyValue) |
Feature optional, show partial data |
When NOT to Recover
Donât use recovery to hide genuine errors:
// â BAD - Swallowing errors silently
loadCriticalData(id)
.or(Data.EMPTY) // User never knows load failed!
// â
GOOD - Let critical errors propagate
loadCriticalData(id) // Caller handles the error
Recovery is for expected failures where degradation makes sense (cache miss, optional feature unavailable). Critical errors should propagate so callers can handle them appropriately.
Why This Matters:
- Reliability: Systems stay operational despite partial failures
- User Experience: Graceful degradation better than total failure
- Complexity: Clear recovery strategy, no hidden try-catch blocks
Next Steps: Now that you can handle and recover from errors, letâs see how to test this code (next section).
Testing Your Code
Now that you understand the four return types and core principles, you need to know how to test them. This section covers the basic functional assertion patternâPart 5 will cover advanced testing strategies (stubs, integration tests, test organization).
Testing Results: The Functional Pattern
When testing functions that return Result<T>, use .onSuccess(Assertions::fail) and .onFailure(Assertions::fail) to make test intent explicit.
Testing failures:
@Test
void email_rejectsInvalidFormat() {
Email.email("not-an-email")
.onSuccess(Assertions::fail); // Fail test if unexpectedly succeeds
}
Testing successes:
@Test
void email_acceptsValidFormat() {
Email.email("[email protected]")
.onFailure(Assertions::fail) // Fail test if unexpectedly fails
.onSuccess(email -> {
assertEquals("[email protected]", email.value());
// More assertions on the success value
});
}
Why this pattern:
- Clear intent:
.onSuccess(Assertions::fail)reads as âthis should failâ - Better failures: You see âExpected failure but got success with value Xâ
- Functional style: Matches the Smart Wrapper (monadic) composition you use in production code
Testing Promises: Await Then Assert
For async code that returns Promise<T>, call .await() to block the test thread, then use the same pattern:
@Test
void execute_succeeds_forValidInput() {
var useCase = UseCase.create(repositoryStub, emailServiceStub);
var request = new Request("valid-data");
useCase.execute(request)
.await() // Block until promise resolves
.onFailure(Assertions::fail)
.onSuccess(response -> {
assertEquals("expected", response.value());
});
}
Testing Options
Same pattern works for Option<T>:
@Test
void findUser_returnsEmpty_whenUserNotFound() {
repository.findUser(unknownId)
.onPresent(Assertions::fail); // Should be empty
}
@Test
void findUser_returnsUser_whenUserExists() {
repository.findUser(knownId)
.onEmpty(Assertions::fail) // Should be present
.onPresent(user -> {
assertEquals("[email protected]", user.email());
});
}
Test Naming Convention
Follow the pattern methodName_outcome_condition:
email_rejectsInvalidFormat- method name, what happens, under what conditionemail_normalizesToLowercase- method name, outcome, implicit condition (always)execute_succeeds_forValidInput- clear, readable, searchable
This is the foundation. Part 5 covers:
- Stub creation and dependency injection
- Integration testing with real I/O
- Test organization (nested classes, builders, parameterized tests)
- Testing complex scenarios
For now, this functional assertion pattern is all you need to verify your value objects and simple use cases.
Monadic Composition Rules
The four return kinds compose via map, flatMap, filter, and aggregation combinators (all, any). Understanding when to lift and how to avoid nesting is essential.
Lifting: Moving Between Types
You can lift a âlowerâ type into a âhigherâ one at call sites:
TâOption<T>(viaOption.option(value))TâResult<T>(viaResult.success(value))TâPromise<T>(viaPromise.success(value))Option<T>âResult<T>(viaoption.toResult(cause)oroption.await(cause))Option<T>âPromise<T>(viaoption.async(cause)oroption.async())Result<T>âPromise<T>(viaresult.async())
You lift when composing functions that return different types:
// Sync validation (Result) lifted into async flow (Promise)
public Promise<Response> execute(Request request) {
return ValidRequest.validRequest(request)
.async() // Result has dedicated async() method to convert to Promise
.flatMap(step1::apply) // step1 returns Promise
.flatMap(step2::apply); // step2 returns Promise
}
Forbidden Nesting: Donât Nest the Same Concern Twice
Promise<Result<T>> is forbidden. Promise<T> already carries failures - nesting Result inside creates two error channels and forces callers to unwrap twice. If a function is async and can fail, it returns Promise<T>, period.
Wrong:
// DON'T: Nested error channels
Promise<Result<User>> loadUser(UserId id) { /* ... */ }
// Caller must unwrap twice:
loadUser(id)
.flatMap(resultUser -> resultUser.fold(user -> Promise.success(user), Cause::promise)); // Absurd ceremony
Right:
// DO: One error channel
Promise<User> loadUser(UserId id) { /* ... */ }
// Caller just chains:
return loadUser(id).flatMap(nextStep);
Allowed Nesting: Result<Option>
Result<Option<T>> is permitted sparingly for âoptional value that can fail validation.â This represents: âIf present, must be valid. If absent, thatâs fine.â
Example: optional referral code that must match a pattern when provided.
Result<Option<ReferralCode>> refCode = ReferralCode.referralCode(input);
// Success(None) = not provided, valid
// Success(Some(code)) = provided and valid
// Failure(cause) = provided but invalid
Avoid Option<Result<T>> - it means âmaybe thereâs a result, and that result might have failed,â which is backwards. Just use Result<Option<T>>.
Aggregation: Combining Independent Operations
Use Result.all(...) or Promise.all(...) to combine multiple independent operations:
// Validation: collect multiple field validations
Result<ValidRequest> validated = Result.all(Email.email(raw.email()),
Password.password(raw.password()),
ReferralCode.referralCode(raw.referralCode()))
.map(ValidRequest::new);
// Async: run independent queries in parallel
Promise<Report> report = Promise.all(userRepo.findById(userId),
orderRepo.findByUser(userId),
inventoryService.getAvailableItems())
.map(this::generateReport);
If any input fails, all() fails immediately (fail-fast for Promise) or collects failures (CompositeCause for Result).
Composition Cheat Sheet
| You have | You need | Use |
|---|---|---|
T |
Option<T> |
Option.option(value) |
T |
Result<T> |
Result.success(value) |
T |
Promise<T> |
Promise.success(value) |
Option<T> |
Result<T> |
.toResult(cause) or .await(cause) |
Option<T> |
Promise<T> |
.async(cause) |
Result<T> |
Promise<T> |
.async() |
Multiple Result<T> |
Single Result |
Result.all(...) |
Multiple Promise<T> |
Single Promise |
Promise.all(...) |
Collection<Promise<T>> |
Promise<List<Result<T>>> |
Promise.allOf(collection) |
Lambda Rules: Keep Composition Clean
Lambdas passed to map, flatMap, recover, filter, and similar combinators must be minimal. Complex logic belongs in named methods.
Allowed in lambdas:
- Method references:
Email::new,this::processUser,User::id - Simple parameter forwarding:
user -> validate(requiredRole, user) - Constructor references for error mapping:
RepositoryError.DatabaseFailure::new
Forbidden in lambdas:
- Conditionals (
if, ternary,switch) - Try-catch blocks
- Multi-statement blocks
- Object construction beyond simple factory calls
Why? Lambdas are composition points, not implementation locations. Burying logic inside lambdas hides abstraction levels and makes code harder to read and test.
Use switch expressions for type matching:
When recovering from errors, use pattern matching switch expressions in named methods instead of if (instanceof) chains:
// DON'T: instanceof chain in lambda
.recover(cause -> {
if (cause instanceof NotFound) {
return useDefault();
}
if (cause instanceof Timeout) {
return useDefault();
}
return cause.promise();
})
// DO: Extract to named method with switch expression
.recover(this::recoverExpectedErrors)
private Promise<Data> recoverExpectedErrors(Cause cause) {
return switch (cause) {
case NotFound ignored, Timeout ignored -> useDefault();
default -> cause.promise();
};
}
Multi-case pattern matching: When multiple error types require the same recovery strategy, use comma-separated cases:
private Promise<Theme> recoverWithDefault(Cause cause) {
return switch (cause) {
case NotFound ignored, Timeout ignored, ServiceUnavailable ignored -> Promise.success(Theme.DEFAULT);
default -> cause.promise();
};
}
Extract error constants:
Donât construct Cause instances inline with fixed messages. Define them as static final constants:
// DON'T: Inline construction with fixed strings
private Promise<User> recoverNetworkError(Cause cause) {
return switch (cause) {
case NetworkError.Timeout ignored -> new ServiceUnavailable("User service timed out").promise();
default -> cause.promise();
};
}
// DO: Extract as constants
private static final Cause TIMEOUT = new ServiceUnavailable("User service timed out");
private Promise<User> recoverNetworkError(Cause cause) {
return switch (cause) {
case NetworkError.Timeout ignored -> TIMEOUT.promise();
default -> cause.promise();
};
}
Additional anti-patterns:
â Ternaries in lambdas (violates Single Pattern per Function):
// DON'T: Ternary in lambda
.flatMap(user -> user.isPremium()
? applyPremiumDiscount(user)
: applyStandardDiscount(user))
// DO: Extract to named function
.flatMap(this::applyApplicableDiscount)
private Result<Discount> applyApplicableDiscount(User user) {
return user.isPremium()
? applyPremiumDiscount(user)
: applyStandardDiscount(user);
}
â Nested maps/flatMaps:
// DON'T: Nested composition in lambda
.flatMap(user -> loadProfile(user)
.map(profile -> enrichWithPreferences(profile))
.flatMap(enriched -> save(enriched)))
// DO: Extract to named method
.flatMap(this::loadAndEnrichProfile)
private Promise<Profile> loadAndEnrichProfile(User user) {
return loadProfile(user)
.map(this::enrichWithPreferences)
.flatMap(this::save);
}
â Complex object construction:
// DON'T: Complex construction in lambda
.map(dashboard -> {
var urgentAlerts = dashboard.alerts()
.stream()
.filter(Alert::isUrgent)
.toList();
return new Summary(dashboard.metrics(), urgentAlerts);
})
// DO: Extract to named method
.map(this::buildSummary)
private Summary buildSummary(Dashboard dashboard) {
var urgentAlerts = filterUrgentAlerts(dashboard.alerts());
return new Summary(dashboard.metrics(), urgentAlerts);
}
Prefer constructor references:
// DO: Constructor reference
.map(Email::new)
// DON'T: Lambda wrapping constructor
.map(value -> new Email(value))
This keeps composition chains flat and readable. Named functions document intent; anonymous lambdas hide it.
Zone framework reference: Part 3 introduces the zone-based naming framework that helps maintain abstraction levels across your codebase. Lambdas should only forward to methods at the appropriate abstraction level.
Why These Rules?
They prevent complexity explosion. With exactly four return types and clear composition rules, you can always tell how to combine two functions by looking at their signatures. AI code generation becomes mechanical - given input and output types, thereâs one obvious way to compose.
Pragmatica Lite API Reference
This section consolidates the essential Pragmatica Lite Core 0.8.3 APIs youâll use daily. For complete API documentation, see CODING_GUIDE.md: Pragmatica Lite Core 0.8.3 API Reference.
Type Conversions
Moving between the four return types:
// Option â Result/Promise
option.toResult(cause) // or .await(cause) - alias
option.async(cause) // Option â Promise
option.async() // Uses CoreError.emptyOption as cause
// Result â Promise
result.async() // Lift sync Result to async Promise
// Promise â Result (blocking - use with caution)
promise.await() // Blocks current thread
promise.await(timeout) // With timeout
// Cause â Result/Promise (prefer over constructors)
cause.result() // Cause â Result (recommended)
cause.promise() // Cause â Promise (recommended)
// Instead of: Result.failure(cause) or Promise.failure(cause)
Aggregation Operations
Combining multiple operations:
// Result.all - Accumulates ALL failures (CompositeCause)
Result.all(result1, result2, result3)
.map((v1, v2, v3) -> combine(v1, v2, v3));
Result.allOf(List.of(result1, result2, result3)) // From collection
.map(list -> process(list));
// Promise.all - Accumulates ALL failures (CompositeCause)
Promise.all(promise1, promise2, promise3)
.map((v1, v2, v3) -> combine(v1, v2, v3));
Promise.allOf(collection) // Collection<Promise<T>> â Promise<List<Result<T>>>
.map(results -> process(results));
// Option.all - Fail-fast on FIRST empty
Option.all(opt1, opt2, opt3)
.map((v1, v2, v3) -> combine(v1, v2, v3));
// any - First success
Result.any(result1, result2) // First success or all failures
Promise.any(promise1, promise2) // First success, cancels others
Option.any(opt1, opt2) // First present
Exception Handling (lift methods)
Wrapping throwing code:
// Result.lift - Sync exceptions â Result
Result.lift(Integer::parseInt, raw)
Result.lift(RepositoryError.DatabaseFailure::new, () -> riskyOperation())
Result.lift(ThrowingRunnable) // â Result<Unit>
// Promise.lift - Async exceptions â Promise
Promise.lift(RepositoryError.DatabaseFailure::new, () -> jdbcQuery())
Promise.lift(() -> riskyAsyncOperation())
Promise.lift(ThrowingRunnable) // â Promise<Unit>
// Function factories (returns wrapped function)
Fn1<Result<Integer>, String> parser = Result.liftFn1(Integer::parseInt);
Fn1<Promise<Data>, String> loader = Promise.liftFn1(this::loadFromDisk);
Verify.Is Predicates (Validation)
Standard validation predicates:
// Null and emptiness
Verify.Is::notNull
Verify.Is::notBlank // Has non-whitespace characters
Verify.Is::notEmpty // Non-empty string/collection
Verify.Is::empty
Verify.Is::blank
// String checks
Verify.Is::lenBetween // Verify.ensure(str, Verify.Is::lenBetween, min, max)
Verify.Is::contains // Contains substring
Verify.Is::notContains
Verify.Is::matches // Regex (String or Pattern)
// Numeric comparisons
Verify.Is::positive // > 0
Verify.Is::negative // < 0
Verify.Is::nonNegative // >= 0
Verify.Is::nonPositive // <= 0
Verify.Is::greaterThan
Verify.Is::greaterThanOrEqualTo
Verify.Is::lessThan
Verify.Is::lessThanOrEqualTo
Verify.Is::equalTo // Via compareTo
Verify.Is::notEqualTo
Verify.Is::between // >= min && <= max (inclusive)
// Option checks
Verify.Is::some // Option.isPresent()
Verify.Is::none // Option.isEmpty()
Usage:
Verify.ensure(password, Verify.Is::lenBetween, 8, 128)
Verify.ensure(age, Verify.Is::between, 0, 150)
Verify.ensure(username, Verify.Is::notBlank)
// Combining multiple checks
Verify.combine(Verify.ensureFn(TOO_SHORT, Verify.Is::lenBetween, 8, 128),
Verify.ensureFn(BLANK, Verify.Is::notBlank))
Parse Utilities (Exception-Safe JDK Wrappers)
Instead of Result.lift(Integer::parseInt, raw), use dedicated parse utilities:
import org.pragmatica.lang.parse.Number;
import org.pragmatica.lang.parse.DateTime;
import org.pragmatica.lang.parse.Network;
import org.pragmatica.lang.parse.I18n;
import org.pragmatica.lang.parse.Text;
// Numbers
Number.parseInt(raw) // Result<Integer>
Number.parseLong(raw) // Result<Long>
Number.parseDouble(raw) // Result<Double>
Number.parseBigDecimal(raw) // Result<BigDecimal>
Number.parseBigInteger(raw) // Result<BigInteger>
// Date/Time
DateTime.parseLocalDate(raw) // Result<LocalDate>
DateTime.parseLocalDateTime(raw) // Result<LocalDateTime>
DateTime.parseZonedDateTime(raw) // Result<ZonedDateTime>
DateTime.parseInstant(raw) // Result<Instant>
// Network
Network.parseUUID(raw) // Result<UUID>
Network.parseURL(raw) // Result<URL>
Network.parseURI(raw) // Result<URI>
Network.parseInetAddress(raw) // Result<InetAddress>
// Internationalization
I18n.parseLocale(raw) // Result<Locale>
I18n.parseCurrency(raw) // Result<Currency>
// Text
Text.parseBoolean(raw) // Result<Boolean>
Example value object using parse utilities:
public record Age(int value) {
public static Result<Age> age(String raw) {
return Number.parseInt(raw)
.flatMap(Verify.ensureFn(Causes.cause("Age must be 0-150"), Verify.Is::between, 0, 150))
.map(Age::new);
}
}
Common Patterns
Factory Methods - Always named after type (camelCase):
Email.email(raw) // â Result<Email>
Password.password(raw) // â Result<Password>
UserId.userId(raw) // â Result<UserId>
Validated Input - Use Valid prefix (not Validated):
record ValidRequest(Email email, Password password) {
static Result<ValidRequest> validRequest(Request raw) {
return Result.all(Email.email(raw.email()),
Password.password(raw.password()))
.map(ValidRequest::new);
}
}
Result
public Result<Unit> saveUser(User user) {
// ... save logic
return Result.unitResult(); // Success with no meaningful value
}
Promise
public Promise<Unit> sendEmail(EmailAddress to, String body) {
return Promise.lift(() -> {
mailService.send(to, body);
return Unit.unit();
});
}
This covers the core Pragmatica Lite APIs youâll use in daily development. For advanced features (retry policies, timeout handling, Promise cancellation), see CODING_GUIDE.md.
Summary: The Foundation is Set
You now understand the core principles that make everything else possible:
Four return types:
T: Sync, canât fail, always presentOption<T>: Sync, canât fail, may be absentResult<T>: Sync, can fail, always present if successPromise<T>: Async, can fail
Three principles:
- Parse, donât validate: Make invalid states unrepresentable via factory methods
- No business exceptions: Errors are typed Cause values, not thrown
- Composition rules: Lift when needed, never nest the same concern twice
These arenât preferences or guidelines. Theyâre mechanical rules that make structural decisions for you. When you follow them, code becomes predictable for humans and AI alike.
Common Mistakes to Avoid
Now that you understand the core principles, here are the most common mistakes beginners make when first adopting this approach:
Mistake 1: Nesting Promise<Result<T>>
â Wrong:
// DON'T: Double-wrapping failures
public Promise<Result<User>> loadUser(UserId id) {
return Promise.lift(() -> userRepository.findById(id));
}
â Correct:
// DO: Promise already handles failures
public Promise<User> loadUser(UserId id) {
return Promise.lift(RepositoryError.DatabaseFailure::new,
() -> userRepository.findById(id));
}
Why wrong: Promise<T> already carries failure - wrapping Result inside creates two error channels.
Mistake 2: Using .flatMap() for Constructors
â Wrong:
// DON'T: flatMap expects monadic return
Result.all(Email.email(emailRaw),
Password.password(passwordRaw))
.flatMap(ValidRequest::new); // Constructor returns ValidRequest, not Result<ValidRequest>
â Correct:
// DO: Use .map() for constructors
Result.all(Email.email(emailRaw),
Password.password(passwordRaw))
.map(ValidRequest::new);
Why wrong: Constructors return T, not Result<T>. Use .map() for plain values, .flatMap() for monadic values.
Mistake 3: Defensive Null Checks in Business Logic
â Wrong:
// DON'T: Defensive programming in business logic
public Result<Order> processOrder(User user, Cart cart) {
if (user == null || cart == null) {
return OrderError.InvalidInput.INSTANCE.result();
}
// ... process order
}
â Correct:
// DO: Parameters guaranteed non-null by convention
public Result<Order> processOrder(User user, Cart cart) {
// If cart might be absent, parameter should be Option<Cart>
// ... process order directly
}
Why wrong: Parse-donât-validate guarantees: if a value exists, itâs valid. Null checks violate this principle.
Mistake 4: Using Optional Instead of Option
â Wrong:
// DON'T: Java Optional doesn't compose with Result/Promise
public Result<Optional<Theme>> findTheme(UserId id) {
return Result.success(repository.findTheme(id)); // Optional inside Result
}
â Correct:
// DO: Use Option for monadic composition
public Result<Option<Theme>> findTheme(UserId id) {
return Result.lift(RepositoryError.DatabaseFailure::new,
() -> Option.option(repository.findTheme(id)));
}
Why wrong: Optional doesnât have .async(), .toResult(), or composition methods. Use Option<T> instead.
Mistake 5: Throwing Business Exceptions
â Wrong:
// DON'T: Throwing exceptions for business failures
public User findUser(Email email) throws UserNotFoundException {
User user = repository.find(email);
if (user == null) {
throw new UserNotFoundException(email);
}
return user;
}
â Correct:
// DO: Return errors as values
public Promise<User> findUser(Email email) {
return repository.find(email) // Returns Promise<Option<User>>
.flatMap(opt -> opt.async(UserNotFound.cause(email)));
}
Why wrong: Exceptions are invisible in signatures and force try-catch boilerplate. Use Result<T> or Promise<T>.
Mistake 6: Validation Separate from Construction
â Wrong:
// DON'T: Separate validation method
public class Email {
private String value;
public Email(String value) {
this.value = value; // Constructor doesn't validate
}
public static boolean isValid(String raw) { // Separate validation
return raw != null && raw.matches(PATTERN);
}
}
â Correct:
// DO: Validation = construction
public record Email(String value) {
public static Result<Email> email(String raw) {
return Verify.ensure(raw, Verify.Is::notNull)
.flatMap(Verify.ensureFn(INVALID_EMAIL, Verify.Is::matches, PATTERN))
.map(Email::new);
}
}
Why wrong: Parse-donât-validate: if instance exists, itâs valid. No separate validation methods needed.
Mistake 7: Using .await() in Business Logic
â Wrong:
// DON'T: Blocking in business logic
public Result<Response> execute(Request request) {
var user = loadUser(request.userId()).await(); // Blocks!
var profile = loadProfile(user.id()).await(); // Blocks!
return Result.success(new Response(user, profile));
}
â Correct:
// DO: Compose promises without blocking
public Promise<Response> execute(Request request) {
return loadUser(request.userId())
.flatMap(user -> loadProfile(user.id())
.map(profile -> new Response(user, profile)));
}
Why wrong: .await() blocks threads, killing scalability. Use .flatMap() to compose async operations.
Mistake 8: Not Using Parse Utilities
â Wrong:
// DON'T: Manual exception handling
public static Result<UserId> userId(String raw) {
try {
UUID uuid = UUID.fromString(raw);
return Result.success(new UserId(uuid));
} catch (IllegalArgumentException e) {
return InvalidUserId.cause(raw).result();
}
}
â Correct:
// DO: Use Pragmatica Lite parse utilities
import org.pragmatica.lang.parse.Network;
public static Result<UserId> userId(String raw) {
return Network.parseUUID(raw).map(UserId::new);
}
Why wrong: Parse utilities handle exceptions cleanly. Donât write try-catch boilerplate manually.
Key insight: Most mistakes come from not trusting the type system. If you validate at construction, you donât need defensive checks. If you compose with .flatMap(), you donât need try-catch. Let the types guide you.
Whatâs Next?
In Part 5: Basic Patterns & Structure, weâll learn the structural rules and basic patterns that handle 80% of daily coding:
- Single Pattern Per Function: one responsibility, mechanical refactoring
- Single Level of Abstraction: no complex logic in lambdas
- Leaf: the atomic unit of processing
- Condition: branching as values
- Iteration: functional collection processing
These patterns apply the core principles you just learned. Once you master them, youâll have a complete toolkit for writing clear, testable business logic.
Summary: The Complete Foundation
You now have all three core principles:
1. Four Return Types (Part 2A):
T: Sync, canât fail, always presentOption<T>: Sync, canât fail, may be absentResult<T>: Sync, can failPromise<T>: Async, can fail
2. Parse, Donât Validate (Part 2B):
- Validation IS construction
- Factory methods return
Result<T> - Invalid states unrepresentable
3. Errors as Values (Part 2C):
- No business exceptions
- Typed
Causeobjects - Error recovery with
.or(),.orElse(),.recover() - Null only at adapter boundaries
- Monadic composition with clean lambda rules
These arenât preferences - theyâre mechanical rules that eliminate entire classes of bugs and make code predictable for humans and AI.
Whatâs Next?
In Part 5: Basic Patterns & Structure, youâll learn the structural rules and basic patterns (Leaf, Condition, Iteration) that handle 80% of your daily coding.
Series Navigation
â Part 3: Parse, Donât Validate | Index | Part 5: Basic Patterns & Structure â
Version: 2.0.0 (2025-11-13) | Part of: Java Backend Coding Technology Series