The Reply pattern provides a streamlined, 3-tier API for the ask pattern in Cajun, making it easy to work with asynchronous responses from actors.
The Reply<T> interface wraps a CompletableFuture and provides three tiers of API:
- Tier 1 (Simple):
get()- Just blocks and returns the value - Tier 2 (Safe):
await()- ReturnsResultfor pattern matching - Tier 3 (Advanced):
future()- Access underlyingCompletableFuture
The simplest way to use the ask pattern - just block and get the value:
// Clean and simple - let exceptions propagate
String name = userActor.ask(new GetName(), Duration.ofSeconds(5)).get();
int balance = accountActor.ask(new GetBalance(), Duration.ofSeconds(5)).get();
// With timeout
String result = actor.ask(msg, Duration.ofSeconds(5)).get(Duration.ofSeconds(2));When to use: Quick scripts, tests, or when you're confident the operation will succeed.
Exception handling: Throws ReplyException (unchecked) if the ask fails, or TimeoutException if timeout expires.
Use Result for explicit error handling with Java's pattern matching:
switch (userActor.ask(new GetProfile(), Duration.ofSeconds(5)).await()) {
case Result.Success(var profile) -> {
System.out.println("Got profile: " + profile);
}
case Result.Failure(var error) -> {
log.error("Failed to get profile", error);
}
}With getOrElse:
String name = userActor.ask(new GetName(), Duration.ofSeconds(5))
.await()
.getOrElse("Anonymous");Chaining with Result:
Result<String> result = userActor.ask(new GetEmail(), Duration.ofSeconds(5))
.await()
.map(String::toUpperCase)
.recover(ex -> "no-email@example.com");When to use: Production code where you need explicit error handling without exceptions.
Access the underlying CompletableFuture for complex async composition:
Reply<User> userReply = userActor.ask(new GetUser(userId), Duration.ofSeconds(5));
Reply<Orders> ordersReply = orderActor.ask(new GetOrders(userId), Duration.ofSeconds(5));
// Combine multiple asks
CompletableFuture<UserWithOrders> combined = userReply.future()
.thenCombine(ordersReply.future(),
(user, orders) -> new UserWithOrders(user, orders));
Reply<UserWithOrders> result = Reply.from(combined);When to use: Complex async workflows, parallel operations, or when you need full CompletableFuture power.
Reply<String> result = userActor.ask(new GetUserId(), Duration.ofSeconds(5))
.map(userId -> "User: " + userId);
String displayName = result.get();Reply<String> result = userActor.ask(new GetUserId(), Duration.ofSeconds(5))
.flatMap(userId -> profileActor.ask(new GetProfile(userId), Duration.ofSeconds(5)))
.map(profile -> profile.displayName())
.recover(ex -> "Unknown User");
String displayName = result.get();Reply<String> result = actor.ask(new GetData(), Duration.ofSeconds(5))
.recover(ex -> "Default Value");
String data = result.get(); // Never throwsReply<String> result = primaryActor.ask(new GetData(), Duration.ofSeconds(5))
.recoverWith(ex -> backupActor.ask(new GetData(), Duration.ofSeconds(5)));
String data = result.get();actor.ask(new ProcessData(), Duration.ofSeconds(5))
.onComplete(
data -> log.info("Success: {}", data),
error -> log.error("Failed", error)
);actor.ask(new GetStats(), Duration.ofSeconds(5))
.onSuccess(stats -> updateDashboard(stats));actor.ask(new RiskyOperation(), Duration.ofSeconds(5))
.onFailure(error -> alertOps(error));Reply<String> reply = Reply.completed("immediate value");
String value = reply.get(); // Returns immediatelyReply<String> reply = Reply.failed(new RuntimeException("error"));
// Use with recover to provide defaults
String value = reply.recover(ex -> "default").get();CompletableFuture<String> future = someAsyncOperation();
Reply<String> reply = Reply.from(future);The Result<T> type provides its own monadic operations:
Result<String> result = Result.success("hello");
Result<String> upper = result.map(String::toUpperCase);Result<String> result = Result.success("5");
Result<Integer> number = result.flatMap(s -> Result.success(Integer.parseInt(s)));Result<String> failure = Result.failure(new RuntimeException("error"));
Result<String> recovered = failure.recover(ex -> "recovered");result.ifSuccess(value -> log.info("Got: {}", value));
result.ifFailure(error -> log.error("Failed", error));Result<Integer> result = Result.attempt(() -> {
return Integer.parseInt(input);
});ActorSystem system = new ActorSystem();
Pid userActor = system.actorOf(UserHandler.class).spawn();
// Simple - just get the value
String name = userActor.ask(new GetName(), Duration.ofSeconds(5)).get();
System.out.println("Name: " + name);Reply<User> reply = userActor.ask(new GetUser(userId), Duration.ofSeconds(5));
switch (reply.await()) {
case Result.Success(var user) -> {
processUser(user);
}
case Result.Failure(var error) -> {
if (error instanceof TimeoutException) {
log.warn("User service timeout");
} else {
log.error("Failed to get user", error);
}
}
}String result = userActor.ask(new GetUserId(), Duration.ofSeconds(5))
.map(String::toUpperCase)
.flatMap(userId -> profileActor.ask(new GetProfile(userId), Duration.ofSeconds(5)))
.map(profile -> profile.displayName())
.recover(ex -> "Unknown User")
.get();Reply<User> userReply = userActor.ask(new GetUser(id), Duration.ofSeconds(5));
Reply<Orders> ordersReply = orderActor.ask(new GetOrders(id), Duration.ofSeconds(5));
Reply<Preferences> prefsReply = prefsActor.ask(new GetPrefs(id), Duration.ofSeconds(5));
CompletableFuture<Dashboard> dashboard = userReply.future()
.thenCombine(ordersReply.future(), UserWithOrders::new)
.thenCombine(prefsReply.future(),
(userOrders, prefs) -> new Dashboard(userOrders, prefs));
Dashboard result = Reply.from(dashboard).get();actor.ask(new ProcessLargeDataset(), Duration.ofMinutes(5))
.onSuccess(result -> {
log.info("Processing complete: {}", result);
notifyUser(result);
})
.onFailure(error -> {
log.error("Processing failed", error);
alertOps(error);
});
// Continue with other work - callbacks will fire when complete-
Choose the right tier for your use case:
- Use Tier 1 (get) for simple cases and tests
- Use Tier 2 (await) for production code with explicit error handling
- Use Tier 3 (future) for complex async composition
-
Set appropriate timeouts:
// Too short - might timeout unnecessarily reply.get(Duration.ofMillis(10)); // Better - reasonable timeout for the operation reply.get(Duration.ofSeconds(5));
-
Use pattern matching for clear error handling:
switch (reply.await()) { case Result.Success(var value) -> handleSuccess(value); case Result.Failure(var error) -> handleError(error); }
-
Chain operations for cleaner code:
// Instead of nested asks String result = actor1.ask(msg1, timeout).get(); String result2 = actor2.ask(new Msg(result), timeout).get(); // Use flatMap String result = actor1.ask(msg1, timeout) .flatMap(r -> actor2.ask(new Msg(r), timeout)) .get();
-
Use callbacks for fire-and-forget operations:
actor.ask(msg, timeout) .onSuccess(result -> log.info("Done: {}", result)) .onFailure(error -> log.error("Failed", error));
If you're currently using ActorSystem.ask() which returns CompletableFuture, you can easily migrate:
// Old way
CompletableFuture<String> future = system.ask(actor, msg, timeout);
String result = future.get();
// New way - Tier 1
String result = actor.ask(msg, timeout).get();
// New way - Tier 2 (safer)
Result<String> result = actor.ask(msg, timeout).await();
// New way - Tier 3 (if you need CompletableFuture)
CompletableFuture<String> future = actor.ask(msg, timeout).future();The Reply pattern gives you maximum flexibility:
- Start simple with
.get()for straightforward cases - Use pattern matching with
.await()when you need explicit error handling - Drop down to CompletableFuture with
.future()for complex async composition
All three tiers work together seamlessly, allowing you to choose the right level of abstraction for each use case.