Order Workflow: Concurrency and Scale
This page covers patterns for scaling the order workflow: context propagation with ScopedValue, racing warehouses with VResultPath.firstSuccess, outcome-aware compensation with VResultPath.bracketOutcome, and virtual thread execution on the typed async railway VResultPath.
- Propagating cross-cutting concerns (trace IDs, tenant isolation, deadlines) with
Context - Racing parallel operations with typed errors using
VResultPath.firstSuccess - Deciding confirm-versus-release compensation from the outcome with
bracketOutcome - Scaling to millions of concurrent orders with the typed async railway
VResultPath - Adapting these patterns to your own domain
- EnhancedOrderWorkflow.java - Full implementation with Context, VResultPath, firstSuccess, and bracketOutcome
- OrderContext.java - Scoped value keys
- EnhancedOrderWorkflowDemo.java - Demo runner
Context Propagation with ScopedValue
Cross-cutting concerns like trace IDs, tenant isolation, and deadlines can be propagated automatically using Context:
// Define scoped values for order context
public final class OrderContext {
public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
public static final ScopedValue<String> TENANT_ID = ScopedValue.newInstance();
public static final ScopedValue<Instant> DEADLINE = ScopedValue.newInstance();
}
// Set context at workflow entry
ScopedValue
.where(OrderContext.TRACE_ID, traceId)
.where(OrderContext.TENANT_ID, tenantId)
.where(OrderContext.DEADLINE, deadline)
.run(() -> workflow.process(request));
// Access context in any step (including parallel operations)
String traceId = OrderContext.TRACE_ID.get();
The key benefit: context values automatically propagate to child virtual threads when using structured concurrency. No more passing trace IDs through every method signature.
Deadline Enforcement
Operations can check remaining time and fail fast when the deadline is exceeded:
public static Duration remainingTime() {
if (!DEADLINE.isBound()) {
return Duration.ofDays(365); // No deadline, effectively infinite
}
Duration remaining = Duration.between(Instant.now(), DEADLINE.get());
return remaining.isNegative() ? Duration.ZERO : remaining;
}
public static boolean isDeadlineExceeded() {
return DEADLINE.isBound() && Instant.now().isAfter(DEADLINE.get());
}
On the typed railway, "fail fast" means a typed Left, not a thrown exception. The workflow checks the deadline as an ordinary step, and a Left here short-circuits everything after it:
private VResultPath<OrderError, Unit> checkDeadline(String operation) {
return Path.vresultDefer(() -> {
if (OrderContext.isDeadlineExceeded()) {
return Either.left(SystemError.timeout(operation));
}
return Either.right(Unit.INSTANCE);
});
}
Structured Concurrency with firstSuccess
Parallel operations with proper cancellation, timeout handling, and typed failures kept in the value channel:
// Race inventory reservations across multiple warehouses
VResultPath<OrderError, InventoryReservation> result =
VResultPath.firstSuccess(List.of(warehouse1, warehouse2, warehouse3))
.mapError(NonEmptyList::head)
.withTimeout(Duration.ofSeconds(10), () -> SystemError.timeout("inventory race"));
| Combinator | Use Case |
|---|---|
firstSuccess(candidates) | First Right wins and cancels the rest; typed failures are collected, and only when every candidate fails does the race fail with all of them |
allSucceed(tasks) | Wait for all tasks; the first typed failure cancels the rest (fail-fast) |
allSucceedAccumulating(tasks) | Run everything to completion; collect every typed failure at once |
withTimeout(duration, onTimeout) | Overrunning the deadline becomes the designated typed error, on the railway |
A warehouse failing with a typed Left does not abort the race; errors stay in the value channel rather than being thrown and caught. Under the hood these combinators run on the same Scope/ScopeJoiner substrate documented in Structured Concurrency; reach for raw Scope directly when your tasks have no typed error channel.
Context values propagate to all forked tasks automatically. Each racing candidate inherits the trace ID, tenant ID, and deadline without explicit passing.
Example: Parallel Inventory Check
public VResultPath<OrderError, InventoryReservation> reserveInventoryParallel(
OrderId orderId, List<ValidatedOrderLine> lines) {
// Each candidate automatically inherits the scoped values (traceId, tenantId, etc.).
var candidates =
List.of(
warehouseReservation(1, Duration.ofMillis(50), orderId, lines),
warehouseReservation(2, Duration.ofMillis(75), orderId, lines),
warehouseReservation(3, Duration.ofMillis(100), orderId, lines));
// Race all warehouses - first Right wins; all-Left surfaces the first warehouse's error.
return VResultPath.firstSuccess(candidates)
.peekLeft(errors -> logSync("All warehouses failed: " + errors.toJavaList()))
.mapError(NonEmptyList::head)
.withTimeout(
getRemainingTimeout(), () -> SystemError.timeout("parallel inventory reservation"));
}
private VResultPath<OrderError, InventoryReservation> warehouseReservation(
int warehouse, Duration latency, OrderId orderId, List<ValidatedOrderLine> lines) {
return Path.vresult(
VTask.of(() -> {
logSync("Checking warehouse " + warehouse
+ " [trace=" + OrderContext.shortTraceId() + "]");
Thread.sleep(latency.toMillis()); // Simulate network latency
return inventoryService.reserve(orderId, lines);
}));
}
Three moves on the error channel do all the work:
peekLeftobserves the collected failures (aNonEmptyList<OrderError>, in candidate order) without changing tracks (here, logging that every warehouse failed)mapError(NonEmptyList::head)collapses the collected errors back to the workflow's singleOrderError, surfacing the firstwithTimeoutbounds the whole race by the remaining deadline; overrunning becomes a typedSystemError.timeout, not aTimeoutException
Outcome-Aware Compensation with bracketOutcome
The bracket pattern ensures cleanup even when operations fail. VResultPath.bracketOutcome goes further: the release action sees the Either outcome of the use phase, so confirm-versus-release is decided from the result rather than a mutable flag:
private VResultPath<OrderError, OrderResult> processWithReservation(
ValidatedOrder order, Customer customer) {
return VResultPath.bracketOutcome(
// Acquire: reserve inventory; a Left skips use and release entirely.
Path.vresultDefer(() -> inventoryService.reserve(order.orderId(), order.lines())),
// Use: discount -> payment -> shipment -> notification.
reservation -> processAfterReservation(order, customer, reservation),
// Release: decide confirm-versus-release from the outcome.
(reservation, outcome) ->
outcome.fold(
_ -> VTask.exec(() -> {
logSync("Releasing reservation " + reservation.reservationId());
inventoryService.releaseReservation(reservation.reservationId());
}),
_ -> VTask.exec(
() -> inventoryService.confirmReservation(reservation.reservationId()))),
defect -> SystemError.fromException("Order processing failed", defect));
}
Release always runs and receives the Either outcome: a Right confirms the reservation, a Left releases it. There is no mutable "confirmed" flag. A defect (a thrown exception) inside the use phase is first typed through the final onDefect argument (here as a SystemError), so the release always observes a real outcome, and the reservation is released.
General-Purpose Resource
For AutoCloseable-style acquisition where the release does not depend on the outcome, the generic Resource bracket remains available, and multiple resources combine with guaranteed cleanup ordering:
Resource<Connection> dbResource = Resource.fromAutoCloseable(
() -> connectionPool.getConnection()
);
Resource<PreparedStatement> stmtResource = dbResource.flatMap(conn ->
Resource.fromAutoCloseable(() -> conn.prepareStatement(sql))
);
// Both connection and statement are cleaned up
VTask<List<Order>> orders = stmtResource.use(stmt ->
VTask.of(() -> executeQuery(stmt))
);
Resources are released in reverse order of acquisition (LIFO), and cleanup runs even if the computation fails or is cancelled.
The Typed Async Railway with VResultPath
Scale to millions of concurrent orders using virtual threads. The workflow's shape is VTask<Either<OrderError, A>> (async work that can fail with a typed domain error), and VResultPath is that stack as a first-class railway, with no Kind widening, transformer, or hand-carried Either:
// VResultPath operations are lazy - they describe computation
VResultPath<OrderError, OrderResult> workflow =
validateShippingAddress(request.shippingAddress())
.via(address ->
lookupAndValidateCustomer(customerId)
.via(customer ->
buildValidatedOrder(orderId, request, customer, address)
.via(order -> processWithReservation(order, customer))));
// Execute on a virtual thread at the boundary
Try<Either<OrderError, OrderResult>> result = workflow.run().runSafe();
via chains dependent steps on the success channel; a Left from any step short-circuits the rest. Steps nest only where a later step still needs an earlier binding (the address and customer both feed the order build). Virtual threads are managed by the JVM and can handle blocking operations (database calls, HTTP requests) without consuming platform threads.
Converting Between Effect Types
VResultPath integrates with the rest of the Effect Path API:
// Defer a computation that decides the Either itself
VResultPath<OrderError, ValidatedShippingAddress> step =
Path.vresultDefer(() -> shippingService.validateAddress(address));
// Lift an existing carrier or a decided Either
VResultPath<OrderError, Data> lifted = Path.vresult(vtaskOfEither);
VResultPath<OrderError, Data> decided = Path.vresultEither(either);
// Pure values on either channel
VResultPath<OrderError, Unit> ok = Path.vresultRight(Unit.INSTANCE);
VResultPath<OrderError, Unit> failed = Path.vresultLeft(SystemError.timeout("order.start"));
VTask-native resilience composes on the VTaskPath layer and lifts into the typed railway:
private VResultPath<OrderError, Customer> lookupAndValidateCustomer(CustomerId customerId) {
return Path.vresult(
Path.vtask(() ->
customerService.findById(customerId)
.flatMap(customerService::validateEligibility))
.withCircuitBreaker(customerLookupBreaker)
.withRetry(customerLookupRetry)
.run());
}
Retry engages only when the lookup throws (a transient infrastructure failure); a business Left such as customer-not-found is returned as-is and never retried.
Adapting These Patterns to Your Domain
Step 1: Define Your Error Hierarchy
Start with a sealed interface for your domain errors:
public sealed interface MyDomainError
permits ValidationError, NotFoundError, ConflictError, SystemError {
String code();
String message();
}
Step 2: Wrap Your Services
Convert existing services to return Either:
// Before
public User findUser(String id) throws UserNotFoundException { ... }
// After
public Either<MyDomainError, User> findUser(String id) {
try {
return Either.right(legacyService.findUser(id));
} catch (UserNotFoundException e) {
return Either.left(NotFoundError.user(id));
}
}
Step 3: Compose with EitherPath
Build your workflows using via():
public EitherPath<MyDomainError, Result> process(Request request) {
return Path.either(validateRequest(request))
.via(valid -> Path.either(findUser(valid.userId())))
.via(user -> Path.either(performAction(user, valid)))
.map(this::buildResult);
}
When the steps are asynchronous, the same railway is VResultPath: swap Path.either(...) for Path.vresultDefer(...) and the shape of the code does not change.
Step 4: Add Resilience Gradually
Start simple, add resilience as needed. EitherPath is eager, so its resilience combinators are static and take the step as a Supplier: resilience wraps the computation, not the finished result.
// Start with basic composition
var result = workflow.process(request);
// Add a typed timeout when integrating external services: the timeout
// arrives as a Left, not a thrown TimeoutException
var withTimeout = EitherPath.withTimeout(
() -> workflow.process(request),
Duration.ofSeconds(30),
() -> SystemError.timeout("process"));
// Add railway-aware retry for transient failures. A business Left is never
// retried; the predicate opts selected transient errors in. Only wrap steps
// that are safe to re-run.
var resilient = EitherPath.withRetry(
() -> EitherPath.withTimeout(
() -> workflow.process(request),
Duration.ofSeconds(30),
() -> SystemError.timeout("process")),
error -> error instanceof SystemError,
RetryPolicy.exponentialBackoffWithJitter(3, Duration.ofMillis(200)));
On the async railway the same vocabulary chains as instance methods: VResultPath carries withRetry(retryOn, policy), withTimeout(duration, onTimeout), withCircuitBreaker, and withBulkhead directly.
Step 5: Add Concurrency for Scale
When you need to scale, add context propagation and typed structured concurrency:
// Wrap entry point with context
ScopedValue
.where(OrderContext.TRACE_ID, traceId)
.where(OrderContext.DEADLINE, deadline)
.run(() -> workflow.process(request));
// Race alternatives - typed failures stay in the value channel
VResultPath<NonEmptyList<MyDomainError>, Result> fastest =
VResultPath.firstSuccess(List.of(primary, replica, cache));
// Or require every operation to succeed (fail-fast)
VResultPath<MyDomainError, List<Result>> all =
VResultPath.allSucceed(List.of(operation1, operation2));
- Context propagation with
ScopedValueenables implicit trace IDs, tenant isolation, and deadlines - Typed racing with
VResultPath.firstSuccesskeeps failures in the value channel: the firstRightwins, and only an all-fail surfaces every error - Outcome-aware compensation with
bracketOutcomedecides confirm-versus-release from theEitherresult, not a mutable flag - Virtual threads with
VResultPathenable scaling to millions of concurrent operations without giving up typed errors - Gradual adoption allows you to start simple and add concurrency patterns as needs grow
- VResultPath - Async work with a typed domain error
- VTaskPath - Virtual thread effect type
- Structured Concurrency - The Scope substrate beneath
firstSuccess - Resource Management - Resource patterns
- Effect Path Overview - The railway model
Previous: Production Patterns Next: Draughts Game