The Java Realization / JBCT course / Part III — Patterns

Advanced Patterns

Lesson 2 of 4 · Part III — Patterns

In this lesson

Sequencer alone structures most business logic you’ll write. Fork-Join and Aspects are for the cases that need more.

What You’ll Learn

Prerequisites: Basic Patterns & Structure


Discovery Questions for Advanced Patterns

Basic Patterns & Structure introduced gap detection and the pattern catalog. Here are the discovery questions for advanced patterns — they emerge from the structure of each pattern:

Pattern Key Questions
Sequencer What does step 1 produce that step 2 needs? Can step 3 happen if step 2 fails? Is order fixed?
Fork-Join Do these depend on each other? Can we fetch X while fetching Y? What if one succeeds and another fails?
Aspects Retry on failure? How many times? Timeout duration? What needs logging?

These questions emerge from the patterns themselves — the structure demands specific information.


Pattern: Sequencer

Definition: A Sequencer chains dependent steps linearly using map and flatMap. Each step’s output feeds the next step’s input. This is the primary pattern for use case implementation.

Rationale (by criteria):

The 2-5 Rule

A Sequencer should have 2 to 5 steps. Fewer than 2, and it’s probably just a Leaf. More than 5, and it needs decomposition—extract sub-sequencers or group steps.

The rule is intended to limit local complexity. It is derived from the average size of short-term memory - 7 +/- 2 elements.

Domain requirements take precedence: Some functions inherently require more steps because the domain demands it. Value object factories may need multiple validation and normalization steps to ensure invariants. Fork-Join patterns may need to aggregate 6+ independent results because that’s what the domain requires. Don’t artificially fit domain logic into numeric rules. The 2-5 guideline helps you recognize when to consider refactoring, but domain semantics always win.

Sync Example

public interface ProcessOrder {
    record Request(String orderId, String paymentToken) {}
    record Response(OrderConfirmation confirmation) {}

    Result<Response> execute(Request request);

    interface ValidateInput {
        Result<ValidRequest> apply(Request raw);
    }
    interface ReserveInventory {
        Result<Reservation> apply(ValidRequest req);
    }
    interface ProcessPayment {
        Result<Payment> apply(Reservation reservation);
    }
    interface ConfirmOrder {
        Result<Response> apply(Payment payment);
    }

    static ProcessOrder processOrder(ValidateInput validate,
                                     ReserveInventory reserve,
                                     ProcessPayment processPayment,
                                     ConfirmOrder confirm) {
        return request -> validate.apply(request)                 // Step 1
                                  .flatMap(reserve::apply)        // Step 2
                                  .flatMap(processPayment::apply) // Step 3
                                  .flatMap(confirm::apply);       // Step 4
    }
}

Four steps, each a single-method interface. The execute() body reads top-to-bottom: validate -> reserve -> process payment -> confirm. Each step returns Result<T>, so we chain with flatMap. If any step fails, the chain short-circuits and returns the failure.

Why interface + factory? Every component — use case, step, adapter — is defined as an interface with a static factory method. This is not arbitrary convention:

Async Example

Same structure, different types:

public Promise<Response> execute(Request request) {
    return ValidateInput.validate(request)              // returns Result<ValidInput>
                        .async()                        // lift to Promise<ValidInput>
                        .flatMap(reserve::apply)        // returns Promise<Reservation>
                        .flatMap(processPayment::apply) // returns Promise<Payment>
                        .flatMap(confirm::apply);       // returns Promise<Response>
}

Validation is synchronous (returns Result), so we lift it to Promise using .async(). The rest of the chain is async.

When to Extract Sub-Sequencers

If a step grows complex internally, extract it to its own interface with a nested structure. Suppose processPayment actually needs to: authorize card -> capture funds -> record transaction. That’s three dependent steps - a Sequencer. Extract:

// Original step interface
interface ProcessPayment {
    Promise<Payment> apply(Reservation reservation);
}

// Implementation delegates to a sub-sequencer
interface CreditCardPaymentProcessor extends ProcessPayment {
    interface AuthorizeCard {
        Promise<Reservation> apply(Reservation reservation);
    }
    interface CaptureFunds {
        Promise<Reservation> apply(Reservation reservation);
    }
    interface RecordTransaction {
        Promise<Payment> apply(Reservation reservation);
    }

    static CreditCardPaymentProcessor creditCardPaymentProcessor(
            AuthorizeCard authorizeCard,
            CaptureFunds captureFunds,
            RecordTransaction recordTransaction) {
        return (reservation) -> authorizeCard.apply(reservation)
                                             .flatMap(captureFunds::apply)
                                             .flatMap(recordTransaction::apply);
    }
}

Now CreditCardPaymentProcessor is itself a Sequencer with three steps. The top-level use case remains a clean 4-step chain.

Thread Safety

Sequencer pattern is thread-safe through sequential execution - steps execute one after another, never in parallel. Each step is isolated and thread-confined. Mutable local state within individual steps is safe because steps don’t overlap. Data passed between steps must be immutable.

Common Mistakes

DON’T nest logic inside flatMap:

// DON'T: Business logic buried in lambda
return validate.apply(request)
    .flatMap(valid -> {
        if (valid.isPremiumUser()) {
            return applyDiscount(valid)
                .flatMap(reserve::apply);
        } else {
            return reserve.apply(valid);
        }
    })
    .flatMap(processPayment::apply);

Extract:

// DO: Extract to named function
return validate.apply(request)
               .flatMap(this::applyDiscountIfEligible)
               .flatMap(reserve::apply)
               .flatMap(processPayment::apply);

private Result<ValidRequest> applyDiscountIfEligible(ValidRequest request) {
    return request.isPremiumUser()
        ? applyDiscount(request)
        : Result.success(request);
}

DON’T mix Fork-Join inside a Sequencer without extraction:

// DON'T: Suddenly doing Fork-Join mid-sequence
return validate.apply(request)
    .flatMap(valid -> {
        var userPromise = fetchUser(valid.userId());
        var productPromise = fetchProduct(valid.productId());
        return Promise.all(userPromise, productPromise)
            .flatMap((user, product) -> reserve.apply(user, product));
    })
    .flatMap(processPayment::apply);

Extract:

// DO: Extract Fork-Join to its own step
return validate.apply(request)
               .flatMap(this::fetchUserAndProduct)  // Fork-Join inside
               .flatMap(reserve::apply)
               .flatMap(processPayment::apply);

private Promise<ReservationInput> fetchUserAndProduct(ValidRequest request) {
    return Promise.all(fetchUser(request.userId()),
                       fetchProduct(request.productId()))
                  .map(ReservationInput::new);
}

Pattern: Fork-Join

Definition: Fork-Join (also known as Fan-Out-Fan-In) executes independent operations concurrently and combines their results. Use it when you have parallel work with no dependencies between branches.

Rationale (by criteria):

Two Primary Flavors

1. Result.all(…) - Synchronous aggregation:

Not concurrent, just collects multiple Results:

// Validating multiple independent fields
Result<ValidRequest> validated = Result.all(Email.email(raw.email()),
                                            Password.password(raw.password()),
                                            AccountId.accountId(raw.accountId()))
                                       .map(ValidRequest::new);

If all succeed, you get a tuple of values to pass to the combiner. If any fail, you get a CompositeCause containing all failures (not just the first).

2. Promise.all(…) - Parallel async execution:

// Running independent I/O operations in parallel
Promise<Dashboard> buildDashboard(UserId userId) {
    return Promise.all(userService.fetchProfile(userId),
                       orderService.fetchRecentOrders(userId),
                       notificationService.fetchUnread(userId))
                  .map(this::createDashboard);
}

private Dashboard createDashboard(Profile profile,
                                  List<Order> orders,
                                  List<Notification> notifications) {
    return new Dashboard(profile, orders, notifications);
}

All three fetches run concurrently. The Promise completes when all inputs complete successfully or fails immediately if any input fails.

Special Fork-Join Cases

1. Promise.allOf(Collection<Promise<T>>) - Resilient collection:

// Fetching data from dynamic number of sources
Promise<Report> generateSystemReport(List<ServiceId> services) {
    var healthChecks = services.stream()
                               .map(healthCheckService::check)
                               .toList();

    return Promise.allOf(healthChecks)
                  .map(this::createReport);
}

private Report createReport(List<Result<HealthStatus>> results) {
    var successes = results.stream()
                           .filter(Result::isSuccess)
                           .map(Result::value)
                           .toList();
    var failures = results.stream()
                          .filter(Result::isFailure)
                          .map(Result::cause)
                          .toList();
    return new Report(successes, failures);
}

Returns Promise<List<Result<T>>> - unlike Promise.all() which fails fast, allOf() waits for all promises to complete and collects both successes and failures.

2. Promise.any(Promise<T>...) - First-success wins:

// Racing multiple data sources
Promise<ExchangeRate> fetchRate(Currency from, Currency to) {
    return Promise.any(primaryRateProvider.getRate(from, to),
                       secondaryRateProvider.getRate(from, to),
                       fallbackRateProvider.getRate(from, to));
}

Returns the first successfully completed Promise, canceling remaining operations.

Independence and Thread Safety

Fork-Join has a crucial constraint: all branches must be truly independent with immutable inputs.

Caution: Type-level independence is not the same as infrastructure-level independence. Operations that appear independent at the code level may conflict at the infrastructure level:

  • Database locks: Two queries may deadlock on shared rows
  • Rate limits: Parallel API calls may exhaust quotas
  • Connection pools: Parallel operations may compete for connections
  • Transactions: Operations in the same transaction may have ordering requirements

Validate infrastructure constraints, not just type signatures.

Design Independence - When you try to write a Fork-Join and discover hidden dependencies, it reveals design issues:

Thread Safety - Parallel execution requires immutable inputs:

Example design issue:

// DON'T: Logical dependency
Promise.all(fetchUserProfile(userId),      // Returns User
            fetchUserPreferences(userId))  // Needs User.timezone from profile!

Example thread safety violation:

// DON'T: Shared mutable state
private final DiscountContext context = new DiscountContext();  // Mutable

Promise<Result> calculate() {
    return Promise.all(applyBogo(cart, context),       // DATA RACE
                       applyPercentOff(cart, context))  // Both mutate context
                  .map(this::merge);
}

// DO: Immutable inputs
Promise<Result> calculate(Cart cart) {
    return Promise.all(applyBogo(cart),        // Immutable cart
                       applyPercentOff(cart))  // Immutable cart
                  .map(this::mergeDiscounts);
}

Common Mistakes

DON’T use Fork-Join when there are hidden dependencies:

// DON'T: These aren't actually independent
Promise.all(allocateInventory(orderId),   // Might lock inventory
            chargePayment(paymentToken))  // Should only charge if inventory succeeds

If inventory allocation fails, we’ve already charged the customer. Use a Sequencer.

DON’T ignore errors in Fork-Join branches:

// DON'T: Silently swallowing failures
Promise.all(fetchPrimary(id).recover(err -> Option.none()),  // Hides failure
            fetchSecondary(id).recover(err -> Option.none()))

Model the “best-effort” case explicitly with proper types.


Pattern: Aspects (Decorators)

Definition: Aspects are higher-order functions that wrap steps or use cases to add cross-cutting concerns - retry, timeout, logging, metrics - without changing business semantics.

Rationale (by criteria):

Placement

Local concerns: Wrap individual steps when the aspect applies to just that step. Example: retry only on external API calls.

Cross-cutting concerns: Wrap the entire execute() method. Example: metrics for the whole use case.

Example: Retry Aspect on a Step

public interface FetchUserProfile {
    Promise<Profile> apply(UserId userId);
}

// Step implementation
class UserServiceClient implements FetchUserProfile {
    public Promise<Profile> apply(UserId userId) {
        return httpClient.get("/users/" + userId.value())
                         .map(this::parseProfile);
    }
}

// Applying a retry aspect at construction:
static ProcessUserData processUserData(..., UserServiceClient userServiceClient, ...) {
    var retryPolicy = RetryPolicy.builder()
                                 .maxAttempts(3)
                                 .backoff(exponential(100, 2.0))
                                 .build();

    return new processUserData(validateInput,
                               withRetry(retryPolicy, userServiceClient),  // Decorated
                               processData);
}

Example: Metrics Aspect on Use Case

public interface LoginUser {
    Promise<LoginResponse> execute(LoginRequest request);

    static LoginUser loginUser(...) {
        var rawUseCase = new loginUser(...);
        var metricsPolicy = MetricsPolicy.metricsPolicy("user_login");
        return withMetrics(metricsPolicy, rawUseCase);
    }
}

Composing Multiple Aspects

Order matters. Typical ordering (outermost to innermost):

  1. Metrics/Logging (outermost - observe everything)
  2. Timeout (global deadline)
  3. CircuitBreaker (fail-fast if system is degraded)
  4. Retry (per-attempt)
  5. RateLimit (throttle requests)
  6. Business logic (innermost)
var decoratedStep = withMetrics(metricsPolicy,
    withTimeout(timeoutPolicy,
        withCircuitBreaker(breakerPolicy,
            withRetry(retryPolicy, rawStep))));

Operational Semantics

Timeout Behavior:

// Timeout resolves promise but underlying HTTP call may complete
promise.timeout(TimeSpan.seconds(5))  // Returns TimeoutError after 5s
       .onFailure(this::logTimeout);   // Operation may still finish

Retry Semantics:

// Safe: idempotent read
withRetry(policy, () -> fetchUser(id))

// Unsafe without idempotency key: creates duplicate
withRetry(policy, () -> createOrder(request))  // DON'T

// Safe: idempotent write with key
withRetry(policy, () -> createOrder(request, idempotencyKey))  // DO

Composition Order Semantics:

Order Meaning
Metrics → Timeout → Retry → Operation Metrics count total time; timeout applies to all retries
Timeout → Metrics → Retry → Operation Timeout per attempt; metrics count each retry
Retry → Timeout → Operation Each attempt has own timeout

Rule: Define composition order based on what you want to observe and control. Document your choice.

Testing Aspects

Test aspects in isolation with synthetic steps. Use case tests remain aspect-agnostic:

// Aspect test (isolated)
@Test
void retryAspect_retriesOnFailure() {
    var failingStep = new FlakyStep(2);  // Fail times
    var retryPolicy = RetryPolicy.maxAttempts(3);
    var decorated = withRetry(retryPolicy, failingStep);

    var result = decorated.apply(input).await();

    assertTrue(result.isSuccess());
    assertEquals(3, failingStep.invocationCount());
}

// Use case test (aspect-agnostic)
@Test
void loginUser_success() {
    var useCase = LoginUser.loginUser(mockValidate, mockCheckCreds, mockGenerateToken);

    var result = useCase.execute(validRequest).await();

    assertTrue(result.isSuccess());
    // No assertions about retries, timeouts, etc.
}

Common Mistakes

DON’T mix aspect logic into business logic:

// DON'T: Retry logic inside the step
Promise<Profile> fetchProfile(UserId id) {
    return retryWithBackoff(() -> httpClient.get("/users/" + id.value()))
            .map(this::parseProfile);
}

Extract to an aspect decorator.

DO keep aspects composable and reusable:

static <I, O> Fn1<I, Promise<O>> withTimeout(TimeSpan timeout, Fn1<I, Promise<O>> step) {
    return input -> step.apply(input).timeout(timeout);
}

static <I, O> Fn1<I, Promise<O>> withRetry(RetryPolicy policy, Fn1<I, Promise<O>> step) {
    return input -> retryLogic(policy, () -> step.apply(input));
}

Logging Philosophy

Principle: Logging belongs only at leaves and external boundaries. Composition code has zero logging.

Rationale:

Implementation: Wrap leaf implementations in a logging aspect at construction:

public interface UserRepository {
    Promise<User> findById(UserId id);

    static UserRepository create(DataSource ds, Logger log) {
        var impl = new UserRepositoryImpl(ds);
        return withLogging(log, "UserRepository", impl);
    }
}

public static UserRepository withLogging(Logger log, String name, UserRepository impl) {
    return id -> {
        var correlationId = CorrelationContext.current();
        log.debug("[{}] {}.findById input: {}", correlationId, name, id);

        return impl.findById(id)
                   .onSuccess(user -> log.debug("[{}] {}.findById success", correlationId, name))
                   .onFailure(cause -> log.warn("[{}] {}.findById failure: {}", correlationId, name, cause.message()));
    };
}

What gets logged:

Anti-pattern - scattered logging in composition:

// DON'T: Logging in composition code
return validateInput(request)
    .onSuccess(v -> log.info("Validated"))  // Noise
    .flatMap(this::fetchUser)
    .onSuccess(u -> log.info("Fetched user"))  // Noise
    .flatMap(this::processOrder);

// DO: Wrap leaves at construction
static ProcessOrder processOrder(UserRepository users, Logger log) {
    return new ProcessOrderImpl(withLogging(log, "users", users));
}

Instrumentation Completeness

Principle: Because effects live only at leaves, wrapping every leaf instruments the whole request path - completely, by construction.

The Logging Philosophy above is a placement rule; combined with the quarantine of effects at leaves, it becomes a guarantee. I/O, clock reads, outbound calls, message publishing - every effect exists only behind a leaf interface, and composition is pure routing. The set of leaves is therefore the complete set of points where the program touches the outside world. Wrap them all at construction and no path is left uninstrumented, because there is no effect outside a leaf to miss.

Injected dependencies wrap on the same seam. A capability injected from another module is itself a Promise-returning interface, so cross-module calls are observed exactly like local leaves - no extra mechanism, the same decorator.

Error telemetry is structural. Because Promise<T> carries failure as a typed value rather than throwing, one wrapper records both outcomes at a single point: a success becomes a normal span and a latency metric, a typed Cause becomes an error span and an error metric. There is no try/catch to write and none to forget.

The honest boundary: wrapping for observability is old (proxies, decorators, AOP). What the quarantine discipline adds is the pair of guarantees - completeness (no effect escapes a leaf, so none escapes instrumentation) and cleanliness (the business body names none of it).


Key Takeaways

  1. Sequencer - Chain dependent steps (2-5 rule), fail-fast semantics
  2. Fork-Join - Parallel independent operations, strict immutability requirement
  3. Aspects - Cross-cutting concerns as decorators, tested separately
  4. Logging Philosophy - Logging only at leaves and boundaries, composition is transparent
  5. Instrumentation completeness - Wrapping every leaf instruments the whole path by construction; no effect outside a leaf to miss
  6. Independence validation - Prevents Fork-Join mistakes; use Sequencer if unsure
  7. Pattern composition - Sequencers call Fork-Joins, Aspects wrap both

Exercises

See Appendix B for exercises on:


What’s Next

Knowledge-Gathering Pipelines turns from composing behavior to composing data: the records that flow between pipeline steps, and the mapWith family that makes each stage one line. Thread Safety then consolidates the thread-safety rules across JBCT patterns.

Exercise — Add Retry and Timeout ~20 min

Take an existing adapter call in your codebase. Wrap it with a timeout (fail after N seconds) and a retry (up to 3 attempts on transient failure), composing both as aspects around the original step interface. Solution discussion in the book’s Appendix B (Exercise 3.4).