Patterns and Recipes

"When the going gets weird, the weird turn professional."

— Hunter S. Thompson, Fear and Loathing on the Campaign Trail '72

Every codebase eventually gets weird. Edge cases multiply. Requirements contradict. Legacy systems refuse to behave. The patterns in this chapter are for those moments: tested approaches from production code where the weird was met professionally.

These aren't academic exercises. They're recipes that survived contact with reality.

What You'll Learn

  • Validation pipeline patterns for single fields and combined validations
  • Service layer patterns for repositories and chained service calls
  • IO effect patterns for resource management and composing effects
  • Error handling strategies: enrichment, recovery with logging, circuit breakers
  • Testing patterns for Path-returning methods
  • Integration patterns with existing code

Validation Pipelines

User input arrives untrustworthy. Every field might be missing, malformed, or actively hostile. Traditional validation scatters null checks and conditionals throughout your code. Path types let you build validation as composable pipelines where each rule is small, testable, and reusable.

Single Field Validation

Each field gets its own validation function returning a Path:

private EitherPath<String, String> validateEmail(String email) {
    if (email == null || email.isBlank()) {
        return Path.left("Email is required");
    }
    if (!email.contains("@")) {
        return Path.left("Email must contain @");
    }
    if (!email.contains(".")) {
        return Path.left("Email must contain a domain");
    }
    return Path.right(email.toLowerCase().trim());
}

Or with modern pattern matching:

private EitherPath<String, String> validateEmail(String email) {
    return switch (email) {
        case null -> Path.left("Email is required");
        case String e when e.isBlank() -> Path.left("Email is required");
        case String e when !e.contains("@") -> Path.left("Email must contain @");
        case String e when !e.contains(".") -> Path.left("Email must have a domain");
        case String e -> Path.right(e.toLowerCase().trim());
    };
}

The validated value may be transformed (lowercase, trimmed); the Path carries the clean version forward.

Combining Validations (Fail-Fast)

When all fields must be valid to proceed:

record User(String name, String email, int age) {}

EitherPath<String, User> validateUser(UserInput input) {
    return validateName(input.name())
        .zipWith3(
            validateEmail(input.email()),
            validateAge(input.age()),
            User::new
        );
}

First failure stops processing. For user-facing forms, this is often too abrupt; see the next pattern.

Combining Validations (Accumulating)

When users deserve to see all problems at once:

ValidationPath<List<String>, User> validateUser(UserInput input) {
    return validateNameV(input.name())
        .zipWith3Accum(
            validateEmailV(input.email()),
            validateAgeV(input.age()),
            User::new
        );
}

// Usage
validateUser(input).run().fold(
    errors -> showAllErrors(errors),  // ["Name too short", "Invalid email"]
    user -> proceed(user)
);

The difference is respect for the user's time.

Nested Validation

Complex objects with nested structures:

record Registration(User user, Address address, List<Preference> prefs) {}

EitherPath<String, Registration> validateRegistration(RegistrationInput input) {
    EitherPath<String, User> user = validateUser(input.user());

    EitherPath<String, Address> address = validateStreet(input.street())
        .zipWith3(
            validateCity(input.city()),
            validatePostcode(input.postcode()),
            Address::new
        );

    EitherPath<String, List<Preference>> prefs =
        validatePreferences(input.preferences());

    return user.zipWith3(address, prefs, Registration::new);
}

Each sub-validation is independent; they combine at the end.


Service Layer Patterns

Services orchestrate multiple operations: fetching data, applying business rules, calling external systems. Each step might fail differently. Path types make the orchestration explicit.

Repository Pattern

Repositories return Maybe (absence is expected). Services convert to Either (absence becomes an error in context):

public class UserRepository {
    public Maybe<User> findById(String id) {
        return Maybe.fromOptional(
            jdbcTemplate.queryForOptional("SELECT...", id)
        );
    }

    public Maybe<User> findByEmail(String email) {
        return Maybe.fromOptional(
            jdbcTemplate.queryForOptional("SELECT...", email)
        );
    }
}

public class UserService {
    private final UserRepository repository;

    public EitherPath<UserError, User> getById(String id) {
        return Path.maybe(repository.findById(id))
            .toEitherPath(() -> new UserError.NotFound(id));
    }

    public EitherPath<UserError, User> getByEmail(String email) {
        return Path.maybe(repository.findByEmail(email))
            .toEitherPath(() -> new UserError.NotFound(email));
    }
}

The conversion happens at the layer boundary. Repository callers get Maybe; service callers get Either with meaningful errors.

Chained Service Calls

When each step depends on the previous:

public class OrderService {
    private final UserService users;
    private final InventoryService inventory;
    private final PaymentService payments;

    public EitherPath<OrderError, Order> placeOrder(
            String userId, List<Item> items) {
        return users.getById(userId)
            .mapError(OrderError::fromUserError)
            .via(user -> inventory.reserve(items)
                .mapError(OrderError::fromInventoryError))
            .via(reservation -> payments.charge(user, reservation.total())
                .mapError(OrderError::fromPaymentError))
            .via(payment -> Path.right(
                createOrder(user, items, payment)));
    }
}

Each mapError translates the sub-service error into the order domain. The final Order is created only if all steps succeed.

Service with Fallbacks

When multiple sources can satisfy a request:

public class ConfigService {
    public EitherPath<ConfigError, Config> loadConfig() {
        return Path.either(loadFromFile())
            .recoverWith(e -> {
                log.warn("File config unavailable: {}", e.getMessage());
                return Path.either(loadFromEnvironment());
            })
            .recoverWith(e -> {
                log.warn("Env config unavailable: {}", e.getMessage());
                return Path.right(Config.defaults());
            });
    }
}

The logs record what was tried; the caller gets a working config or a clear failure.


IO Effect Patterns

Resource Management

Acquire, use, release, regardless of success or failure:

public class FileProcessor {
    public IOPath<ProcessResult> process(Path path) {
        return Path.io(() -> new BufferedReader(new FileReader(path.toFile())))
            .via(reader -> Path.io(() -> processContent(reader)))
            .ensuring(() -> {
                // Cleanup runs regardless of outcome
                log.debug("Processing complete: {}", path);
            });
    }
}

For true resource safety with acquisition and release:

public IOPath<Result> withConnection(Function<Connection, Result> action) {
    return Path.io(() -> dataSource.getConnection())
        .via(conn -> Path.io(() -> action.apply(conn))
            .ensuring(() -> {
                try { conn.close(); }
                catch (SQLException e) { log.warn("Close failed", e); }
            }));
}

Composing Effect Pipelines

Build complex pipelines that execute later:

public class DataPipeline {
    public IOPath<Report> generateReport(ReportRequest request) {
        return Path.io(() -> log.info("Starting report: {}", request.id()))
            .then(() -> Path.io(() -> fetchData(request)))
            .via(data -> Path.io(() -> transform(data)))
            .via(transformed -> Path.io(() -> aggregate(transformed)))
            .via(aggregated -> Path.io(() -> format(aggregated)))
            .peek(report -> log.info("Report ready: {} rows", report.rowCount()));
    }
}

// Nothing happens until:
Report report = pipeline.generateReport(request).unsafeRun();

Expressing Parallel Intent

While IOPath doesn't parallelise automatically, zipWith expresses independence:

IOPath<CombinedData> fetchAll() {
    IOPath<UserData> users = Path.io(() -> fetchUsers());
    IOPath<ProductData> products = Path.io(() -> fetchProducts());
    IOPath<OrderData> orders = Path.io(() -> fetchOrders());

    return users.zipWith3(products, orders, CombinedData::new);
}

A more sophisticated runtime could parallelise these. For now, they execute in sequence, but the structure is clear.


Error Handling Strategies

Error Enrichment

Add context as errors propagate through layers:

public <A> EitherPath<DetailedError, A> withContext(
        EitherPath<Error, A> path,
        String operation,
        Map<String, Object> context) {
    return path.mapError(error -> new DetailedError(
        error,
        operation,
        context,
        Instant.now()
    ));
}

// Usage
return withContext(
    userService.getUser(id),
    "getUser",
    Map.of("userId", id, "requestId", requestId)
);

When the error surfaces, you know what was happening and with what parameters.

Recovery with Logging

Log the failure, provide a fallback:

public <A> EitherPath<Error, A> withRecoveryLogging(
        EitherPath<Error, A> path,
        A fallback,
        String operation) {
    return path.recover(error -> {
        log.warn("Operation '{}' failed: {}. Using fallback.",
            operation, error);
        return fallback;
    });
}

Circuit Breaker

Stop calling a failing service:

public class CircuitBreaker<E, A> {
    private final AtomicInteger failures = new AtomicInteger(0);
    private final int threshold;
    private final Supplier<EitherPath<E, A>> fallback;

    public EitherPath<E, A> execute(Supplier<EitherPath<E, A>> operation) {
        if (failures.get() >= threshold) {
            log.debug("Circuit open, using fallback");
            return fallback.get();
        }

        return operation.get()
            .peek(success -> failures.set(0))
            .recoverWith(error -> {
                int count = failures.incrementAndGet();
                log.warn("Failure {} of {}", count, threshold);
                return Path.left(error);
            });
    }
}

A proper implementation would include timeouts and half-open states. This shows the pattern.


Resilience: Retry with Backoff

"The protocol specified exponential backoff: wait one second, try again; wait two seconds, try again; wait four seconds..."

— Neal Stephenson, Cryptonomicon

Transient failures—network blips, brief overloads—often succeed on retry. But retrying immediately can worsen the problem. The RetryPolicy API provides configurable backoff strategies.

Creating Retry Policies

// Fixed delay: 100ms between each of 3 attempts
RetryPolicy fixed = RetryPolicy.fixed(3, Duration.ofMillis(100));

// Exponential backoff: 1s, 2s, 4s, 8s...
RetryPolicy exponential = RetryPolicy.exponentialBackoff(5, Duration.ofSeconds(1));

// With jitter to prevent thundering herd
RetryPolicy jittered = RetryPolicy.exponentialBackoffWithJitter(5, Duration.ofSeconds(1));

Applying Retry to Paths

IOPath<Response> resilient = IOPath.delay(() -> httpClient.get(url))
    .withRetry(RetryPolicy.exponentialBackoff(3, Duration.ofSeconds(1)));

// Convenience method for simple cases
IOPath<Response> simple = IOPath.delay(() -> httpClient.get(url))
    .retry(3);  // Uses default exponential backoff

Configuring Retry Behaviour

Policies are immutable but configurable:

RetryPolicy policy = RetryPolicy.exponentialBackoff(5, Duration.ofMillis(100))
    .withMaxDelay(Duration.ofSeconds(30))   // Cap maximum wait
    .retryOn(IOException.class);             // Only retry I/O errors

Selective Retry

Not all errors should trigger retry:

RetryPolicy selective = RetryPolicy.fixed(3, Duration.ofMillis(100))
    .retryIf(ex ->
        ex instanceof IOException ||
        ex instanceof TimeoutException ||
        (ex instanceof HttpException http && http.statusCode() >= 500));

Combining Retry with Fallback

IOPath<Data> robust = fetchFromPrimary()
    .withRetry(RetryPolicy.exponentialBackoff(3, Duration.ofSeconds(1)))
    .handleErrorWith(e -> {
        log.warn("Primary exhausted, trying backup");
        return fetchFromBackup()
            .withRetry(RetryPolicy.fixed(2, Duration.ofMillis(100)));
    })
    .recover(e -> {
        log.error("All sources failed", e);
        return Data.empty();
    });

Handling Exhausted Retries

When all attempts fail, RetryExhaustedException is thrown:

try {
    resilient.unsafeRun();
} catch (RetryExhaustedException e) {
    log.error("All retries failed: {}", e.getMessage());
    Throwable lastFailure = e.getCause();
    return fallbackValue;
}

Retry Pattern Quick Reference

StrategyUse Case
fixed(n, delay)Known recovery time
exponentialBackoff(n, initial)Unknown recovery time
exponentialBackoffWithJitter(n, initial)Multiple clients retrying
.retryOn(ExceptionType.class)Selective retry
.withMaxDelay(duration)Cap exponential growth

See Also

See Advanced Effect Topics for comprehensive coverage of resilience patterns including resource management with bracket and guarantee.


Testing Patterns

Path-returning methods are straightforward to test. The explicit success/failure encoding means you can verify both paths without exception handling gymnastics.

Testing Success and Failure

@Test
void shouldReturnUserWhenFound() {
    when(repository.findById("123")).thenReturn(Maybe.just(testUser));

    EitherPath<UserError, User> result = service.getUser("123");

    assertThat(result.run().isRight()).isTrue();
    assertThat(result.run().getRight()).isEqualTo(testUser);
}

@Test
void shouldReturnErrorWhenNotFound() {
    when(repository.findById("123")).thenReturn(Maybe.nothing());

    EitherPath<UserError, User> result = service.getUser("123");

    assertThat(result.run().isLeft()).isTrue();
    assertThat(result.run().getLeft()).isInstanceOf(UserError.NotFound.class);
}

Testing Error Propagation

Verify that errors from nested operations surface correctly:

@Test
void shouldPropagatePaymentError() {
    when(userService.getUser(any())).thenReturn(Path.right(testUser));
    when(inventory.check(any())).thenReturn(Path.right(availability));
    when(payments.charge(any(), any()))
        .thenReturn(Path.left(new PaymentError("Declined")));

    EitherPath<OrderError, Order> result = orderService.placeOrder(request);

    assertThat(result.run().isLeft()).isTrue();
    assertThat(result.run().getLeft())
        .isInstanceOf(OrderError.PaymentFailed.class);

    // Order creation should never be called
    verify(orderRepository, never()).save(any());
}

Property-Based Testing

Use jqwik or similar to verify laws across many inputs:

@Property
void functorIdentityLaw(@ForAll @StringLength(min = 1, max = 100) String value) {
    MaybePath<String> path = Path.just(value);
    MaybePath<String> mapped = path.map(Function.identity());

    assertThat(mapped.run()).isEqualTo(path.run());
}

@Property
void recoverAlwaysSucceeds(
        @ForAll String errorMessage,
        @ForAll String fallback) {
    EitherPath<String, String> failed = Path.left(errorMessage);
    EitherPath<String, String> recovered = failed.recover(e -> fallback);

    assertThat(recovered.run().isRight()).isTrue();
    assertThat(recovered.run().getRight()).isEqualTo(fallback);
}

Testing Deferred Effects

Verify that IOPath defers execution:

@Test
void shouldDeferExecution() {
    AtomicInteger callCount = new AtomicInteger(0);
    IOPath<Integer> io = Path.io(() -> callCount.incrementAndGet());

    assertThat(callCount.get()).isEqualTo(0);  // Not yet

    int result = io.unsafeRun();

    assertThat(callCount.get()).isEqualTo(1);  // Now
    assertThat(result).isEqualTo(1);
}

@Test
void shouldCaptureExceptionInRunSafe() {
    IOPath<String> failing = Path.io(() -> {
        throw new RuntimeException("test error");
    });

    Try<String> result = failing.runSafe();

    assertThat(result.isFailure()).isTrue();
    assertThat(result.getCause().getMessage()).isEqualTo("test error");
}

Integration with Existing Code

Real projects have legacy code that throws exceptions, returns Optional, or uses patterns that predate functional error handling.

Wrapping Exception-Throwing APIs

public class LegacyWrapper {
    private final LegacyService legacy;

    public TryPath<Data> fetchData(String id) {
        return Path.tryOf(() -> legacy.fetchData(id));
    }

    public EitherPath<ServiceError, Data> fetchDataSafe(String id) {
        return Path.tryOf(() -> legacy.fetchData(id))
            .toEitherPath(ex -> new ServiceError("Fetch failed", ex));
    }
}

Wrapping Optional-Returning APIs

public class ModernWrapper {
    private final ModernService modern;

    public MaybePath<User> findUser(String id) {
        return Path.maybe(Maybe.fromOptional(modern.findUser(id)));
    }
}

Exposing to Non-Path Consumers

When callers expect traditional patterns:

public class ServiceAdapter {
    private final PathBasedService service;

    // For consumers expecting Optional
    public Optional<User> findUser(String id) {
        return service.findUser(id).run().toOptional();
    }

    // For consumers expecting exceptions
    public User getUser(String id) throws UserNotFoundException {
        Either<UserError, User> result = service.getUser(id).run();
        if (result.isLeft()) {
            throw new UserNotFoundException(result.getLeft().message());
        }
        return result.getRight();
    }
}

Common Pitfalls

Pitfall 1: Excessive Conversion

// Wasteful
Path.maybe(findUser(id))
    .toEitherPath(() -> error)
    .toMaybePath()
    .toEitherPath(() -> error);

// Clean
Path.maybe(findUser(id))
    .toEitherPath(() -> error);

Pitfall 2: Side Effects in Pure Operations

// Wrong
path.map(user -> {
    auditLog.record(user);  // Side effect in map!
    return user;
});

// Right
path.peek(user -> auditLog.record(user));

Pitfall 3: Ignoring the Result

// Bug: result discarded, nothing happens
void processOrder(OrderRequest request) {
    validateAndProcess(request);  // Returns EitherPath, ignored
}

// Fixed
void processOrder(OrderRequest request) {
    validateAndProcess(request).run();  // Actually execute
}

Pitfall 4: Treating All Errors the Same

// Loses information
.mapError(e -> "An error occurred")

// Preserves structure
.mapError(e -> new DomainError(e.code(), e.message(), e))

Quick Reference

PatternWhen to UseKey Methods
Single validationValidate one fieldPath.right/left
Combined validationMultiple independent fieldszipWith, zipWithAccum
Repository wrappingMaybe → Either at boundarytoEitherPath
Service chainingSequential dependent callsvia, mapError
Fallback chainMultiple sourcesrecoverWith
Resource managementAcquire/use/releasebracket, withResource
Cleanup guaranteeEnsure finalizer runsguarantee
Effect pipelineDeferred compositionvia, then, unsafeRun
Error enrichmentAdd contextmapError
Circuit breakerProtect failing servicerecover, recoverWith
Retry with backoffTransient failureswithRetry, RetryPolicy
Selective retrySpecific exception types.retryOn(), .retryIf()

Summary

The patterns in this chapter share a common theme: making the implicit explicit. Error handling becomes visible in the types. Composition becomes visible in the pipeline. Dependencies become visible in the choice of via vs zipWith.

When the going gets weird (and it will), these patterns are your professional toolkit. They won't make the weirdness go away, but they'll help you handle it with composure.

Continue to Advanced Effects for Reader, State, and Writer patterns.

See Also


Previous: Focus-Effect Integration Next: Advanced Effects