A lightweight Java 11+ library designed to orchestrate complex algorithms as a sequence of asynchronous steps. The core philosophy is that each step (Command) is responsible for its own completion, signaling the progression to the next phase via manual flow control.
Unlike traditional linear execution, command-chain allows you to model algorithms where each step might involve asynchronous operations (I/O, timers, external events). A step is considered "finished" only when it explicitly calls next() on the chain controller.
- Manual Flow Control: Total control over algorithm progression using
chain.next()andchain.fail(). - Active Command Protection: Commands can only affect the chain (
next(),fail(),add()) while they are the currently active command; late or duplicate calls are safely ignored. - Asynchronous by Design: Ideal for simulating complex state machines or multi-step processes where steps finish at different times.
- Fluent Algorithm Builder: Compose your logic using a clean, readable builder API.
- Dynamic Command Addition: Add new commands to the chain even during execution, allowing for adaptive workflows.
- Native Loop Support: Built-in support for repetitive tasks (
ForLoop,TimedLoop) that integrate seamlessly with the asynchronous flow. - Robust Error Propagation: Centralized failure handling that catches both synchronous exceptions and manual failure signals.
- Thread Efficiency: Optimized for non-blocking execution, utilizing threads only when necessary.
Add the following dependency to your pom.xml:
<dependency>
<groupId>com.jaewa</groupId>
<artifactId>command-chain</artifactId>
<version>1.1.0</version>
</dependency>The CommandExecutor is the heart of the system. It manages a collection of commands and coordinates their execution. There are two primary modes of operation based on the CommandSource used:
- CommandPipeline (Default): Commands remain in the collection after being executed. This is ideal for re-running the same sequence of steps multiple times.
- CommandQueue: Commands are removed from the collection once they are executed. This is perfect for producer-consumer scenarios or background workers where tasks are processed and then discarded.
The library provides two fundamental ways to define steps in your workflow: AsyncCommand and Command.
AsyncCommand is the foundational building block of the library:
@FunctionalInterfacepublicinterfaceAsyncCommand {
voidexecute(Contextctx, CommandChainchain) throwsException;
}An AsyncCommand is not asynchronous by itself—it simply receives the execution context (Context) and the flow controller (CommandChain). However, it is what enables the algorithm execution to become asynchronous. The chain execution stops at this step until the command explicitly signals completion by calling chain.next() or reports an error via chain.fail(throwable).
An AsyncCommand does not require background threads or CompletableFuture. It can simply perform a direct action and then manually advance the chain:
// Simple AsyncCommand: performs an action and explicitly calls next()AsyncCommandsimpleStep = (ctx, chain) -> {
System.out.println("Executing simple step...");
ctx.set("status", "in_progress");
chain.next(); // Explicitly advance to the next command
};For standard, synchronous operations where you don't need manual flow control, you can use Command:
@FunctionalInterfacepublicinterfaceCommand {
voidexecute(Contextctx) throwsException;
}Command can be used directly without dealing with the CommandChain:
// Synchronous Command: no need to call chain.next()CommandsyncStep = ctx -> {
System.out.println("Executing synchronous step...");
ctx.set("key", "value");
};When you pass a Command to exec() (or use Commands.async(cmd)), it is automatically converted into an AsyncCommand under the hood:
- When the
execute(ctx)method returns normally,chain.next()is called automatically. - If the method throws an exception, it is caught and
chain.fail(exception)is called automatically.
Conceptually, the adaptation works as follows:
// Behind the scenes conversion (Commands.async(cmd))
(ctx, chain) -> {
try {
cmd.execute(ctx);
chain.next(); // Automatically advances on success
} catch (Exceptione) {
chain.fail(e); // Automatically fails on exception
}
}The true power of AsyncCommand becomes evident when performing asynchronous or non-blocking tasks (such as HTTP calls, database queries, timers, or background processing). Because the chain only progresses when chain.next() is invoked, you can easily delegate chain.next() or chain.fail() to asynchronous callbacks:
// Using an AsyncCommand with an asynchronous service
(ctx, chain) -> {
externalService.callAsync(data)
.thenAccept(result -> {
ctx.set("result", result);
chain.next(); // Continue to next step ONLY when API returns
})
.exceptionally(ex -> {
chain.fail(ex); // Signal failure if API failsreturnnull;
});
}Or using handle:
(ctx, chain) -> {
CompletableFuture<String> future = someService.fetchData();
future.handle((res, ex) -> {
if (ex != null) {
chain.fail(ex); // Propagate error to the chain
} else {
ctx.set("data", res);
chain.next(); // Proceed with the result
}
returnnull;
});
}Advantages:
- No Blocked Threads: The system does not use any thread while waiting for the external API to complete. No thread is put in
wait()state. - Resource Efficiency: You can handle thousands of concurrent chains with a very small thread pool.
If you already have a CompletableFuture, you don't need to manually write callback boilerplate with handle or thenAccept. You can pass it directly to exec() using Commands.async(future) (or with static import async(future)):
importstaticcom.jaewa.commandchain.Commands.async;
CompletableFuture<String> future = someService.fetchData();
CommandExecutor.pipelineBuilder()
// Automatically calls chain.next() on completion, or chain.fail(e) on failure
.exec(async(future))
.build();Under the hood, Commands.async(future) automatically registers completion handlers on the future:
(ctx, chain) -> future.whenComplete((res, ex) -> {
if (ex != null) {
chain.fail(ex);
} else {
chain.next();
}
});To protect against race conditions, duplicate progression, and stray or delayed asynchronous callbacks, the CommandExecutor enforces strict rules on the CommandChain:
- Active Command Only: A command can only interact with the
CommandChain(callingnext(),fail(), oradd()) while it is the currently executing (active) command. If a command attempts to invokenext(),fail(), oradd()when it is no longer the active command (e.g. after the chain has already progressed or completed), the call is safely ignored. - Single-Use
next()andfail(): Each command execution can invokechain.next()orchain.fail()at most once. Subsequent or duplicate calls by the same command are ignored.
// Example: Delayed callbacks or duplicate calls are safely ignored
(ctx, chain) -> {
chain.next(); // Advances the chain; this command is no longer active// Any subsequent call or late callback from this command is ignored:chain.next(); // Ignoredchain.fail(newRuntimeException("Late error")); // Ignored
};The library provides a fluent builder to compose complex algorithm chains.
You can build chains using synchronous commands, asynchronous commands, or wrapped CompletableFuture instances.
importstaticcom.jaewa.commandchain.Commands.async;
CompletableFuture<String> externalFuture = someService.fetchData();
CommandExecutor.pipelineBuilder()
// 1. Synchronous command (Command - auto-next and auto-fail on exception)
.exec(ctx -> System.out.println("Step 1: Sync"))
// 2. Simple Asynchronous command (AsyncCommand - manual next)
.exec((ctx, chain) -> {
System.out.println("Step 2: Simple Async");
ctx.set("step", 2);
chain.next();
})
// 3. Asynchronous command with background work
.exec((ctx, chain) -> {
System.out.println("Step 3: Async start");
CompletableFuture.runAsync(() -> {
try { Thread.sleep(1000); } catch (InterruptedExceptione) {}
System.out.println("Step 3: Async end");
chain.next();
});
})
// 4. Elaborate AsyncCommand with CompletableFuture handling
.exec((ctx, chain) -> {
CompletableFuture<String> future = someService.fetchData();
future.handle((res, ex) -> {
if (ex != null) {
chain.fail(ex);
} else {
ctx.set("data", res);
chain.next();
}
returnnull;
});
})
// 5. Directly passing a CompletableFuture via Commands.async
.exec(async(externalFuture))
.build()
.start(newDefaultContext());The exec() method (available on the builder) can receive:
Command(ctx -> ...): Executed synchronously. The builder automatically adapts it into anAsyncCommand(viaCommands.async(cmd)) which callschain.next()upon completion andchain.fail(e)if an exception occurs.AsyncCommand((ctx, chain) -> ...): Gives explicit flow control. Progression requires callingchain.next(), while errors are reported viachain.fail(e).CompletableFuture<?>(viaCommands.async(future)/async(future)): Wraps the future into anAsyncCommand. When the future completes normally,chain.next()is called automatically; when it completes exceptionally,chain.fail(e)is called automatically.Runnable(viaCommands.async(runnable)orwiretap(runnable)): Can be adapted into anAsyncCommandor run as an independent side-effect.
The wiretap() method allows you to inject side-effects into the chain without interfering with the main execution flow. It takes a Runnable that is executed in a parallel thread, while the system immediately moves to the next command without waiting. This is perfect for logging, metrics, or monitoring.
CommandExecutor.pipelineBuilder()
.exec(ctx -> ctx.set("status", "processing"))
.wiretap(() -> logger.info("Status set to processing"))
.exec(someAsyncCommand)
.build();The builder creates a CommandExecutor instance. To start the execution, you call the start(Context) method.
CommandExecutorexecutor = CommandExecutor.pipelineBuilder()
.exec(ctx -> System.out.println("Hello"))
.build();
CompletableFuture<Void> future = executor.start(newDefaultContext());
future.thenRun(() -> System.out.println("Chain finished successfully"));
future.exceptionally(ex -> {
// CompletableFuture wraps the original exception in a CompletionExceptionThrowableoriginalCause = ex.getCause() != null ? ex.getCause() : ex;
System.err.println("Chain failed: " + originalCause.getMessage());
returnnull;
});The start() method returns a CompletableFuture that:
- Completes successfully when the entire chain finishes without errors.
- Completes with an exception if
chain.fail(throwable)is called somewhere in the chain and the error is not handled (e.g., viaonFailurewithchain.next()or adoCatchblock).
Note that since CompletableFuture is used, the exception passed to exceptionally or handle is typically a java.util.concurrent.ExecutionException. You can retrieve the original error thrown by your command using ex.getCause().
A CommandExecutor is itself an AsyncCommand. This means you can pass an executor to the exec() method of another builder. This allows you to create "function calls" or reusable sub-blocks of logic.
CommandExecutorsubBlock = CommandExecutor.pipelineBuilder()
.exec(ctx -> System.out.println("Inside sub-block"))
.build();
CommandExecutormain = CommandExecutor.pipelineBuilder()
.exec(ctx -> System.out.println("Main start"))
.exec(subBlock) // subBlock runs as a command
.exec(ctx -> System.out.println("Main end"))
.build();
main.start(newDefaultContext());The Context (and its implementation DefaultContext) is a hierarchical space for variables. It acts like a programming language's scope.
When a CommandExecutor starts, it creates a new context that encapsulates the context passed by the user or the parent executor.
- Read Access: A command can read variables from its own context and all parent contexts.
- Write Isolation: When a command calls
ctx.set(), the variable is stored in the current context. Parent contexts are never modified. - Shadowing: If you set a variable with the same name as one in the parent, you "shadow" it within the current scope.
CommandExecutor.pipelineBuilder()
.exec(ctx -> ctx.set("var", "parent"))
.exec(CommandExecutor.pipelineBuilder()
.exec(ctx -> {
System.out.println(ctx.get("var", String.class)); // Prints "parent"ctx.set("var", "child"); // Shadows parent varSystem.out.println(ctx.get("var", String.class)); // Prints "child"
})
.build())
.exec(ctx -> {
System.out.println(ctx.get("var", String.class)); // Still prints "parent"!
})
.build()
.start(newDefaultContext());You can define an error handler for the executor using onFailure(). The handler can receive the exception and the CommandChain.
chain.next(): Swallows the error and allows execution to continue without errors.chain.fail(ex): Propagates the error or throws a new one.
CommandExecutor.pipelineBuilder()
.exec(ctx -> { thrownewRuntimeException("Oops"); })
.onFailure((ex, chain) -> {
System.out.println("Handling error: " + ex.getMessage());
chain.next(); // Chain finishes cleanly
})
.build();For complex logic, use the try-catch-finally constructs. These catch errors occurring within their block, including those re-thrown by internal onFailure handlers.
CommandExecutor.pipelineBuilder()
.doTry()
.exec(ctx -> { thrownewIOException("Disk Full"); })
.doCatch(IOException.class)
.exec(ctx -> System.out.println("Recovered from IO error"))
.doFinally()
.exec(ctx -> System.out.println("Cleanup successful"))
.end()
.build();The builder supports loop(AbstractLoop). Native implementations include:
ForLoop: Standard iteration (init, condition, update).TimedLoop: Runs for a specific duration (milliseconds).
CommandExecutor.pipelineBuilder()
.loop(newForLoop<>("i", () -> 0, i -> i < 5, i -> i + 1))
.exec(ctx -> {
ForLoop<Integer> loop = ctx.get("i", ForLoop.class);
System.out.println("Iteration: " + loop.getValue());
})
.end()
.build();The choice() construct allows for when() and otherwise() branches.
CommandExecutor.pipelineBuilder()
.choice()
.when(ctx -> ctx.get("val", Integer.class) > 10)
.exec(ctx -> System.out.println("Greater than 10"))
.end()
.otherwise()
.exec(ctx -> System.out.println("Smaller or equal to 10"))
.end()
.end()
.build();The Commands utility class provides static decorators to wrap logic:
async(...): Wraps Runnables, Commands, or CompletableFutures into anAsyncCommand.onEventQueue(...): Forces execution on the AWT Event Dispatch Thread (UI).wireTap(Runnable): Executes a side-effect without blocking the main chain progression.conditional(Predicate, AsyncCommand): Executes the command only if the condition is met.logged(String, AsyncCommand): Assigns a name for logging.withTimeout(long, TimeUnit, ...): Wraps aCommandorAsyncCommandwith a maximum execution timeout, failing the chain withCommandTimeoutExceptionif it does not complete in time.safe(AsyncCommand): Wraps a command to catch exceptions and signal failure automatically.
Example:
importstaticcom.jaewa.commandchain.Commands.*;
builder.exec(onEventQueue(ctx -> label.setText("Updating UI...")))
.exec(wireTap(() -> logger.info("Step reached")))
.exec(logged("FetchData", async(api::call)))
.exec(withTimeout(5, TimeUnit.SECONDS, (ctx, chain) -> {
// Asynchronous task that must call chain.next() or fail() within 5 secondsapi.fetchDataAsync().thenAccept(result -> {
ctx.set("data", result);
chain.next();
}).exceptionally(ex -> {
chain.fail(ex);
returnnull;
});
}));In continuous mode, the executor stays alive and waits for new commands even after finishing the current ones.
CommandExecutorexecutor = newCommandExecutor();
Future<Void> status = executor.startContinuous(newDefaultContext());
// Add commands at runtimeexecutor.add(ctx -> System.out.println("Dynamic command 1"));
// Check statusif (status.isDone()) {
// This happens if someone calls executor.interrupt()
}The startContinuous method returns a Future that allows you to monitor the executor's lifecycle and wait for its eventual termination.
If you prefer not to use the builder, you can configure the CommandExecutor manually.
CommandExecutorexecutor = newCommandExecutor(newCommandPipeline());
executor.add(ctx -> System.out.println("Manual Step 1"));
executor.add((ctx, chain) -> {
CompletableFuture.runAsync(() -> {
System.out.println("Manual Step 2");
chain.next();
});
});
executor.start(newDefaultContext());CommandExecutorexecutor = newCommandExecutor();
ForLoop<Integer> loop = newForLoop<>("i", () -> 0, i -> i < 3, i -> i + 1);
loop.add(ctx -> System.out.println("Manual Loop Iteration"));
executor.add(loop);
executor.start(newDefaultContext());TryCatchCommandtryCatch = newTryCatchCommand();
tryCatch.add(ctx -> { thrownewRuntimeException("Error"); });
tryCatch.doCatch(RuntimeException.class);
tryCatch.add(ctx -> System.out.println("Caught!"));
executor.add(tryCatch);The library uses an internal ExecutorService to manage execution. Each command is executed on the first available thread from the underlying thread pool.
By default, the library uses a cached thread pool. You can change the type of Executor used by the system via ExecutorService.setExecutorSupplier():
importcom.jaewa.commandchain.service.ExecutorService;
importjava.util.concurrent.Executors;
// Use a fixed thread poolExecutorService.setExecutorSupplier(() -> Executors.newFixedThreadPool(4));
// Or use virtual threads (Java 21+)ExecutorService.setExecutorSupplier(Executors::newVirtualThreadPerTaskExecutor);This flexibility allows you to tune the performance based on your environment and the nature of your commands (CPU-bound vs I/O-bound).
Developed with ❤️ by Jaewa.