Workflow, Step, WorkflowExecutor, and WorkflowContext as the core classes, sequential execution with output-based branching, and the direct conceptual link to the saga pattern orchestrator.
Published September 23, 2026
interface Step { StepResult execute(WorkflowContext context); }
class StepResult { boolean success; String nextStepId; /* null means "use default next step" */ }
class WorkflowContext { Map<String, Object> data = new HashMap<>(); } // shared state passed between steps
class Workflow {
String id;
Map<String, Step> steps; // stepId -> Step
String startStepId;
}
class WorkflowExecutor {
void run(Workflow workflow, WorkflowContext context) {
String currentStepId = workflow.startStepId;
while (currentStepId != null) {
Step step = workflow.steps.get(currentStepId);
StepResult result = step.execute(context);
if (!result.success) { handleFailure(workflow, currentStepId, context); break; }
currentStepId = result.nextStepId;
}
}
}
Step as an interface is what lets a workflow be composed of independently-testable, independently-reusable units — each step only knows how to do ITS job and decide the next step ID; it has no awareness of the workflow's overall shape.
class CheckInventoryStep implements Step {
public StepResult execute(WorkflowContext ctx) {
boolean inStock = inventoryService.checkStock((String) ctx.data.get("productId"));
ctx.data.put("inStock", inStock);
return new StepResult(true, inStock ? "reserveInventory" : "notifyOutOfStock"); // branches based on OUTPUT
}
}
A step's nextStepId being determined by ITS OWN execution result (not a fixed, hardcoded sequence baked into the WorkflowExecutor) is what enables genuine BRANCHING — the same workflow definition can take different paths depending on what happens at each step, without the executor itself needing any workflow-specific branching logic. The executor stays generic; all the domain-specific branching logic lives inside individual steps.
This workflow engine's shape — a sequence of steps, each with a defined outcome, executed by a central coordinator tracking progress — is STRUCTURALLY the same shape as the saga pattern's orchestrator (Data Ownership Model's cross-service-write coordination) — a saga IS a workflow, specifically one where each step is a call to a DIFFERENT SERVICE'S local transaction, and "failure handling" means running COMPENSATING actions for already-completed steps rather than simply stopping. Building this generic workflow engine here is what makes a saga orchestrator's design legible as "a workflow engine, specialized for cross-service transactions with compensation" rather than an entirely separate concept to learn from scratch.
Q: How would you add compensating-action support to turn this into a genuine saga orchestrator?
A: Each Step would additionally expose a compensate(WorkflowContext) method; WorkflowExecutor would need to track which steps have ALREADY SUCCEEDED, and on a later step's failure, walk backward through the completed steps calling their compensate() methods in reverse order — directly implementing the Payment System / E-Commerce Checkout saga examples discussed earlier in this course.
Q: Does this design support PARALLEL steps (two independent steps running at once), or only sequential?
A: As written, purely sequential — supporting parallelism would require StepResult to be able to specify MULTIPLE next steps, and WorkflowExecutor to track multiple concurrently-in-progress branches (and typically a JOIN point where they must all complete before proceeding) — a meaningfully more complex executor, though the same Step/WorkflowContext building blocks still apply.
Q: How would a long-running workflow (spanning hours or days, e.g. waiting for a human approval step) be handled, given this in-memory implementation?
A: The WorkflowContext and current-step-position would need to be PERSISTED between steps (not held purely in memory across the while loop), letting execution PAUSE and RESUME later — a durable workflow engine (like Temporal or AWS Step Functions in practice) is built around exactly this persistence requirement, which this simplified in-memory version deliberately sets aside to focus on the core step/branching logic.
Q: Should WorkflowContext's data map be typed, rather than Map<String, Object>? A: For a real production system, yes — a loosely-typed context risks a step reading a key that was never set, or casting to the wrong type, both only discovered at runtime; a more robust design would use a typed context object per workflow (or at minimum, well-documented, validated keys), trading some genericity for real compile-time safety.