This chapter consolidates common issues, debugging techniques, and frequently asked questions about JBCT.
Debugging Monadic Chains
Problem: “I can’t see what’s happening inside flatMap”
Symptom: You have a chain like this and can’t figure out where it fails:
return validateRequest(request) .flatMap(this::checkPermissions) .flatMap(this::processData) .flatMap(this::saveResult);
Solution 1: Add intermediate logging
return validateRequest(request) .onSuccess(r -> log.debug("Validated: {}", r)) .onFailure(c -> log.debug("Validation failed: {}", c.message())) .flatMap(this::checkPermissions) .onSuccess(r -> log.debug("Permissions OK: {}", r)) .onFailure(c -> log.debug("Permission denied: {}", c.message())) .flatMap(this::processData) // ...
Solution 2: Use IDE breakpoints
Set breakpoints inside the lambda:
.flatMap(data -> {
return processData(data); // Breakpoint on this line
})
Solution 3: Extract to named methods
return validateRequest(request) .flatMap(this::checkPermissionsStep) // Breakpoint in method .flatMap(this::processDataStep) .flatMap(this::saveResultStep); private Result<Permissions> checkPermissionsStep(ValidRequest request) { // Breakpoint here, full visibility return checkPermissions(request); }
Problem: “My Promise never resolves”
Symptom: Test hangs on .await() or production code times out.
Checklist:
- Is the Promise created but never resolved?
Promise<User> promise = Promise.promise(); // Unresolved! // Missing: promise.succeed(user) or promise.fail(cause)
- Is there a deadlock in async code?
// WRONG: Blocking inside async chain .flatMap(data -> { return otherPromise.await().async(); // Blocks the thread! }) // CORRECT: Chain without blocking .flatMap(data -> otherPromise)
- Is Promise.lift wrapping blocking code?
// This runs async - make sure the thread pool isn't exhausted Promise.lift(() -> { Thread.sleep(10000); // Blocks a thread return result; });
Debug technique: Add timeout to isolate:
promise
.timeout(TimeSpan.timeSpan(5).seconds())
.await()
.onFailure(cause -> log.error("Timed out or failed: {}", cause));
Problem: “I’m getting CompositeCause but don’t know which validations failed”
Symptom: Result.all() fails but you only see “Multiple failures”.
Solution: Inspect the CompositeCause:
result.onFailure(cause -> {
if (cause instanceof CompositeCause composite) {
composite.causes().forEach(c ->
log.error("Validation failure: {}", c.message())
);
} else {
log.error("Single failure: {}", cause.message());
}
});
Better solution: Create specific error messages in value objects:
public record Email(String value) { private static final Fn1<Cause, String> INVALID = Causes.forOneValue("Invalid email format: %s"); public static Result<Email> email(String raw) { return Verify.ensure(raw, Verify.Is::notBlank) .filter(INVALID, PATTERN.asMatchPredicate()) .map(Email::new); } }
Now CompositeCause contains “Invalid email format: xyz” instead of generic message.
Common Mistakes and Fixes
Mistake: Nested Result/Promise
// WRONG: Returns Result<Result<User>> public Result<Result<User>> findUser(String id) { return UserId.userId(id) .map(this::lookupUser); // lookupUser returns Result<User> }
Fix: Use flatMap instead of map:
// CORRECT: Returns Result<User> public Result<User> findUser(String id) { return UserId.userId(id) .flatMap(this::lookupUser); }
Mistake: Ignoring the Result
// WRONG: Result is created but never used public void processOrder(Order order) { validateOrder(order); // Returns Result<ValidOrder> but ignored! saveOrder(order); }
Fix: Always handle the Result:
// CORRECT: Result flows through public Result<OrderId> processOrder(Order order) { return validateOrder(order) .flatMap(this::saveOrder); }
Mistake: Throwing inside monadic chain
// WRONG: Exception breaks the chain .flatMap(data -> { if (data.isEmpty()) { throw new IllegalStateException("Empty data"); // Escapes! } return process(data); })
Fix: Return failure instead:
// CORRECT: Error stays in the chain .flatMap(data -> { if (data.isEmpty()) { return EMPTY_DATA_ERROR.result(); } return process(data); }) // BETTER: Use filter .filter(EMPTY_DATA_ERROR, data -> !data.isEmpty()) .flatMap(this::process)
Mistake: Multi-statement lambda
// WRONG: Violates single level of abstraction .map(user -> { log.info("Processing user: {}", user.id()); String normalized = user.email().toLowerCase(); cache.put(user.id(), user); return new ProcessedUser(user.id(), normalized); })
Fix: Extract to named method:
// CORRECT: Simple lambda, logic in method .map(this::processUser) private ProcessedUser processUser(User user) { log.info("Processing user: {}", user.id()); String normalized = user.email().toLowerCase(); cache.put(user.id(), user); return new ProcessedUser(user.id(), normalized); }
Mistake: Using Optional instead of Option
// WRONG: Mixing Java Optional with Pragmatica types public Result<User> findUser(UserId id) { Optional<User> optional = repository.findById(id); if (optional.isPresent()) { return Result.success(optional.get()); } return USER_NOT_FOUND.result(); }
Fix: Convert at the boundary:
// CORRECT: Wrap immediately public Result<User> findUser(UserId id) { return Option.option(repository.findById(id).orElse(null)) .toResult(USER_NOT_FOUND); } // OR: Use Option.from() public Result<User> findUser(UserId id) { return Option.from(repository.findById(id)) .toResult(USER_NOT_FOUND); }
Mistake: Bypassing factory method
// WRONG: Direct constructor call Email email = new Email("[email protected]"); // Skips validation! // CORRECT: Use factory method Result<Email> email = Email.email("[email protected]");
IDE Setup
IntelliJ IDEA
Recommended settings:
-
Enable parameter hints for lambdas:
- Settings → Editor → Inlay Hints → Java → Parameter hints
- Enable “Show hints for lambdas”
-
Fold anonymous classes:
- Settings → Editor → General → Code Folding
- Enable “Lambda expressions”
-
Live templates for JBCT patterns:
// Template: jbctvo (Value Object)
public record $NAME$($TYPE$ value) {
private static final Cause INVALID = Causes.cause("Invalid $NAME$");
public static Result<$NAME$> $FACTORY$(String raw) {
return Verify.ensure(raw, Verify.Is::notBlank)
.map($NAME$::new);
}
}
// Template: jbctuc (Use Case)
public interface $NAME$ {
Promise<Response> execute(Request request);
record Request($PARAMS$) {}
record Response($RESULT$) {}
static $NAME$ $FACTORY$($DEPS$) {
return request -> ValidRequest.validRequest(request)
.async()
.flatMap($STEPS$);
}
}
VS Code
Recommended extensions:
- Extension Pack for Java
- Java Test Runner
settings.json:
{
"java.completion.chain.enabled": true,
"java.completion.filteredTypes": [
"java.util.Optional" // Suggest Option instead
]
}
Testing Troubleshooting
Problem: “Test passes but shouldn’t”
Symptom: You expect failure but test passes silently.
@Test void shouldFail() { Email.email("invalid"); // Returns Result, but test doesn't check it! }
Fix: Always assert:
@Test void email_fails_forInvalidInput() { Email.email("invalid") .onSuccess(Assertions::fail); // Fail if unexpectedly succeeds }
Problem: “Async test is flaky”
Symptom: Test sometimes passes, sometimes fails.
Checklist:
- Are you awaiting the Promise?
// WRONG: Test completes before Promise resolves @Test void asyncTest() { useCase.execute(request); // Returns Promise, not awaited! } // CORRECT: Await before asserting @Test void asyncTest() { useCase.execute(request) .await() .onFailure(Assertions::fail); }
- Is there shared mutable state between tests?
// WRONG: Shared state private static List<String> logs = new ArrayList<>(); // CORRECT: Per-test state private List<String> logs; @BeforeEach void setup() { logs = new ArrayList<>(); }
- Are Promise timeouts too short?
// Increase timeout for CI environments
.await(TimeSpan.timeSpan(30).seconds())
Problem: “Can’t create stub for step interface”
Symptom: Compilation error when creating lambda stub.
// ERROR: Target type must be a functional interface var stub = req -> Promise.success(req); // What type is this?
Fix: Declare the type explicitly:
// CORRECT: Type declaration
CheckEmail checkEmail = req -> Promise.success(req);
FAQ
General
Q: Is JBCT only for microservices?
A: No. JBCT works for any backend Java application - monoliths, microservices, serverless functions. The patterns are about code organization, not deployment topology.
Q: Does JBCT require Spring Boot?
A: No. JBCT is framework-agnostic. The examples use Spring Boot because it’s common, but the patterns work with Micronaut, Quarkus, plain Java, or any other framework.
Q: Can I use JBCT with Kotlin?
A: Yes, but consider Arrow-kt instead. Kotlin’s coroutines and extension functions provide more idiomatic alternatives. The concepts transfer directly.
Q: What about reactive streams (Project Reactor, RxJava)?
A: JBCT’s Promise is simpler than reactive streams. Use reactive streams when you need backpressure or complex stream operations. Use Promise for simple async request/response patterns.
Error Handling
Q: When should I use exceptions instead of Result?
A: Use exceptions for:
- Programming errors (IllegalArgumentException, NullPointerException)
- Unrecoverable system failures
- Framework requirements (Spring’s @Transactional rollback)
Use Result for:
- Business failures (validation, not found, conflict)
- Expected error conditions
- Anything the caller might want to handle
Q: How do I handle errors from third-party libraries that throw?
A: Wrap at the adapter boundary:
public Promise<User> findUser(UserId id) { return Promise.lift( DatabaseError::new, () -> jdbcTemplate.queryForObject(sql, mapper, id.value()) ); }
Q: Should every method return Result?
A: No. Pure functions that cannot fail should return T directly:
// Returns T - pure computation, cannot fail public Money calculateTotal(List<LineItem> items) { return items.stream() .map(LineItem::subtotal) .reduce(Money.ZERO, Money::add); } // Returns Result<T> - validation can fail public Result<Money> parseAmount(String raw) { return Money.money(raw); }
Performance
Q: Is there overhead from wrapping everything in Result/Promise?
A: Minimal. The types are thin wrappers. Overhead is comparable to Optional. For typical web applications, this is negligible compared to I/O latency.
Q: Are lambdas slower than methods?
A: No. The JVM inlines simple lambdas. There’s no significant performance difference for typical use cases.
Q: Does creating many small objects (value objects) hurt GC?
A: Modern JVMs handle short-lived objects efficiently. Value objects are typically small and short-lived. If profiling shows GC pressure, the issue is usually elsewhere.
Testing
Q: Should I test value object validation separately from use cases?
A: Yes. Value objects get unit tests for each validation rule. Use cases get integration tests with stubbed steps.
Q: How many tests should a use case have?
A: At minimum:
- 1 happy path test
- 1 test per step failure
- 1 test per validation rule in ValidRequest
Q: Should I use mocks or stubs?
A: Prefer stubs (simple lambda implementations). Mocks add complexity and verification overhead. JBCT’s functional style makes stubbing easy.
Architecture
Q: Where do DTOs belong?
A: At adapter boundaries:
- Request DTOs in controller (convert to domain types immediately)
- Response DTOs from use case Response records
- No DTOs in domain layer
Q: Can use cases call other use cases?
A: Generally no. If use case A needs functionality from use case B, extract that functionality to a shared step interface.
Q: How do I handle transactions?
A: At the adapter layer:
@Repository public class JooqOrderRepository implements SaveOrder { @Transactional @Override public Promise<OrderId> apply(ValidOrder order) { return Promise.lift( DatabaseError::new, () -> saveInTransaction(order) ); } }
Error Message Reference
“Cannot infer functional interface type”
Cause: Lambda target type is ambiguous.
Fix: Add explicit type declaration:
CheckEmail checkEmail = req -> Promise.success(req);
“Incompatible types: Result<X> cannot be converted to Result<Y>”
Cause: Using map when flatMap is needed.
Fix: Check if the mapping function returns a Result. If so, use flatMap:
// WRONG: validate returns Result<ValidEmail> .map(this::validate) // Results in Result<Result<ValidEmail>> // CORRECT .flatMap(this::validate) // Results in Result<ValidEmail>
“No instances of type variable(s) T exist”
Cause: Type inference failure, often with Cause.result() or Cause.promise().
Fix: Add type witness or assign to typed variable:
// Option 1: Type witness return ERROR.<User>result(); // Option 2: Assign to variable Result<User> failure = ERROR.result(); return failure;
“Promise.await() blocks forever”
Cause: Promise never resolved.
Fix: Ensure all code paths resolve the Promise:
Promise<User> promise = Promise.promise(); // Ensure both success and failure paths resolve if (condition) { promise.succeed(user); } else { promise.fail(cause); // Don't forget this! }
Summary
When debugging JBCT code:
- Add logging at chain points - Use onSuccess/onFailure for visibility
- Extract to named methods - Easier breakpoints and stack traces
- Check for common mistakes - Nested results, ignored returns, thrown exceptions
- Use explicit types - Helps IDE and compiler catch errors
- Test both success and failure - Always assert on Result/Promise
The functional style may feel unfamiliar initially, but debugging becomes easier once you internalize the patterns. The explicit error handling means fewer surprises at runtime.