DEV Community

Sanjeet Singh Jagdev
Sanjeet Singh Jagdev

Posted on

Building an Executable Chain to Understand Composable Systems

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
  • Hardcoded 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.

Order Flow Chain

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

It starts with Execution.java

@FunctionalInterface
public interface Executable {
    ChainAction execute(ChainContext context) throws ChainException;
}
Enter fullscreen mode Exit fullscreen mode

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.

Determining flow with ChainAction.java

public enum ChainAction {
    CONTINUE, STOP
}
Enter fullscreen mode Exit fullscreen mode

This is a self-explanatory enum that declares two possible actions after a step completes:

  • Go to next step
  • Stop the current chain as is

A custom wrapper ChainException.java

public class ChainException extends RuntimeException {
    // Other fields can be added for metadata
}
Enter fullscreen mode Exit fullscreen mode

Remembering everything with ChainContext.java

public class ChainContext {
    private final Map<String, Object> values;

    public ChainContext() {
        this.values = new HashMap<>();
    }

    public <T> void setValue(String key, T value) {
        values.put(key, value);
    }

    public Object getValue(String key) {
        return values.get(key);
    }

    @SuppressWarnings("unchecked")
    public <T> T getValue(String key, Class<T> clazz) {
        Objects.requireNonNull(clazz);
        return (T) values.get(key);
    }
}
Enter fullscreen mode Exit fullscreen mode

This class is a crucial core abstraction that 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 Map<String, Object> trades 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 of the chain, we can define a class that orchestrates the entire execution.

public class Chain {
    private final String name;
    private final List<Executable> 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's break it down:

  • We have a name for the chain and a List<Executable> to store all the executables in a sequence.
  • We define a Fluent API for creating a chain using the static of(String name) and next(Executable executable) and context(ChainContext context) methods
  • We can then run this chain using the run() method, which iterates over all the executables and executes them
  • We also have a helper method to visualise the chain with all its steps

Notice that each Executable knows nothing about what comes before or after it. The chain is responsible for orchestration; executables focus purely on their logic.

And that's it! That's all it takes to implement the most barebones Chain Execution Engine.

Putting it Together

Defining a constants class

public class GenericConstants {
    public static final String ORDER_ID = "orderId";
    public static final String CURRENT_STATE = "currentState";
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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(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
Enter fullscreen mode Exit fullscreen mode

Notice how the ChainContext is being used to transfer data between Executables within the chain.

What this implementation does NOT handle

  • Other chain actions like SKIP, ROLLBACK, RETRY. These would offer more functionality
  • Lifecycle hooks for chain and executables, e.g. onFailure(), before(), after()
  • Parallel execution of steps
  • A Chain Registry which 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 made 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.

Top comments (0)