Building an Executable Chain to Understand Composable Systems
Source: Dev.to
Inspiration
While exploring frameworks like LangChain, I noticed a recurring idea: instead of writing monolithic logic, execution is broken into composable steps that can be chained, reused, and rearranged.
This article is not about LangChain itself, but about understanding that underlying idea by building a minimal executable chain abstraction from scratch.
Why do this?
When working without a mental model for execution chains, one often runs into the pain of:
- Deeply nested conditionals
- Hard‑coded execution order
- Monolithic “do‑everything” methods
As a result, the entire chain of operations becomes brittle, hard to test, and difficult to rearrange.
If we instead think of each step as a unit that does exactly one thing, we arrive at the basic building block of a chain.
With that mental model in place, let’s design the smallest possible abstraction to support it.
What is an Executable Chain?
As the name suggests, an executable chain is a sequence of small, focused units of logic that can be arranged—or rearranged—to achieve a larger unit of work.
Take a look at a simple Order Processing chain below.
Each step handles one clear process and the chain is executed sequentially.
An Executable is something that:
- Takes input
- Performs work
- Returns output
and it is:
- Composable
- Testable
- Predictable
Designing the Core Abstraction
Executable.java
@FunctionalInterface
public interface Executable {
ChainAction execute(ChainContext context) throws ChainException;
}
This is the building block of a chain. We wrap our logic inside the execute method and use the ChainContext to track the current state of the chain. The method returns a ChainAction, which determines whether the chain should proceed to the next step or stop execution.
Returning a ChainAction makes control flow explicit and keeps individual executables unaware of the larger chain structure.
ChainAction.java
public enum ChainAction {
CONTINUE,
STOP
}
A self‑explanatory enum that declares two possible actions after a step completes:
- CONTINUE – go to the next step
- STOP – halt the current chain as is
ChainException.java
public class ChainException extends RuntimeException {
// Other fields can be added for metadata
}
A custom unchecked exception that can be enriched with additional metadata if needed.
ChainContext.java
public class ChainContext {
private final Map values;
public ChainContext() {
this.values = new HashMap<>();
}
public void setValue(String key, T value) {
values.put(key, value);
}
public Object getValue(String key) {
return values.get(key);
}
@SuppressWarnings("unchecked")
public T getValue(String key, Class clazz) {
Objects.requireNonNull(clazz);
return (T) values.get(key);
}
}
This class generalises what enters an Executable and what leaves it.
We pass inputs by setting them as key‑value pairs and similarly handle outputs.
NOTE: Using a
Maptrades type safety for flexibility. This is a deliberate choice to keep the abstraction simple and generic.
Controlling it all with Chain.java
Now that we have defined the building blocks, we can create a class that orchestrates the entire execution.
public class Chain {
private final String name;
private final List executables;
private ChainContext context;
private Chain(String name) {
this.name = name;
this.executables = new ArrayList<>();
this.context = new ChainContext();
}
public static Chain of(String name) {
Objects.requireNonNull(name);
return new Chain(name);
}
public Chain next(Executable executable) {
executables.add(executable);
return this;
}
public Chain context(ChainContext context) {
this.context = context;
return this;
}
public void run() throws ChainException {
System.out.println("Executing Chain : " + name);
for (var e : executables) {
ChainAction state = e.execute(context);
if (state == ChainAction.STOP) break;
}
}
public void printChain() {
var s = executables.stream()
.map(e -> e.getClass().getSimpleName())
.collect(Collectors.joining(" --> "));
System.out.println(s);
}
}
Breakdown
name– identifier for the chain.executables– ordered list of steps.- Fluent API –
of(String),next(Executable), andcontext(ChainContext)let you build a chain in a readable way. run()– iterates over the executables, respecting theChainActioneach step returns.printChain()– visualises the chain asStepA --> StepB --> StepC.
Notice that each Executable knows nothing about what comes before or after it. This loose coupling is what makes the chain composable, testable, and easy to rearrange.
Putting it Together
Defining a constants class
public class GenericConstants {
public static final String ORDER_ID = "orderId";
public static final String CURRENT_STATE = "currentState";
}
Defining the steps
public class ValidateOrder implements Executable {
@Override
public ChainAction execute(ChainContext context) throws ChainException {
System.out.println("Executing ValidateOrder");
context.setValue(GenericConstants.CURRENT_STATE, "ValidateOrder Done!");
return ChainAction.CONTINUE;
}
}
public class CheckInventory implements Executable {
@Override
public ChainAction execute(ChainContext context) throws ChainException {
System.out.println("Executing CheckInventory");
System.out.println(" Previous State : " + context.getValue(GenericConstants.CURRENT_STATE));
context.setValue(GenericConstants.CURRENT_STATE, "CheckInventory Done!");
return ChainAction.CONTINUE;
}
}
public class ReserveStock implements Executable {
@Override
public ChainAction execute(ChainContext context) throws ChainException {
System.out.println("Executing ReserveStock");
System.out.println(" Previous State : " + context.getValue(GenericConstants.CURRENT_STATE));
context.setValue(GenericConstants.CURRENT_STATE, "ReserveStock Done!");
return ChainAction.CONTINUE;
}
}
public class TakePayment implements Executable {
@Override
public ChainAction execute(ChainContext context) throws ChainException {
System.out.println("Executing TakePayment");
System.out.println(" Previous State : " + context.getValue(GenericConstants.CURRENT_STATE));
context.setValue(GenericConstants.CURRENT_STATE, "TakePayment Done!");
return ChainAction.CONTINUE;
}
}
public class SendNotification implements Executable {
@Override
public ChainAction execute(ChainContext context) throws ChainException {
System.out.println("Executing SendNotification");
System.out.println(" Previous State : " + context.getValue(GenericConstants.CURRENT_STATE));
System.out.println(" Order with Id: " + context.getValue(GenericConstants.ORDER_ID) + " processed!");
context.setValue(GenericConstants.CURRENT_STATE, "SendNotification Done!");
return ChainAction.CONTINUE;
}
}
Running the Chain
public class Main {
public static void main(String[] args) {
Chain chain = Chain.of("order-process-chain")
.next(new ValidateOrder())
.next(new CheckInventory())
.next(new ReserveStock())
.next(new TakePayment())
.next(new SendNotification());
ChainContext context = new ChainContext();
context.setValue(GenericConstants.ORDER_ID, "ORD-123");
chain.context(context);
chain.run();
System.out.println();
chain.printChain();
}
}
/* Output:
Executing Chain : order-process-chain
Executing ValidateOrder
Executing CheckInventory
Previous State : ValidateOrder Done!
Executing ReserveStock
Previous State : CheckInventory Done!
Executing TakePayment
Previous State : ReserveStock Done!
Executing SendNotification
Previous State : TakePayment Done!
Order with Id: ORD-123 processed!
ValidateOrder --> CheckInventory --> ReserveStock --> TakePayment --> SendNotification
*/
Note: The
ChainContextis used to transfer data between Executables within the chain.
What this implementation does NOT handle
- Other chain actions such as
SKIP,ROLLBACK,RETRY(which would add more functionality). - Lifecycle hooks for chains and executables, e.g.,
onFailure(),before(),after(). - Parallel execution of steps.
- A Chain Registry that stores chains based on their names.
Final Words
Creating simple abstractions is both fun and educational. They force you to confront trade‑offs that are often hidden behind polished frameworks.
Building this executable chain makes it clear that many modern systems — from workflow engines to AI pipelines — rely on the same underlying ideas. Once you understand the abstraction, the framework feels far less magical.
