yWorkflow Documentation

1. Introduction

The goal of this tutorial is to create a workflowDefinition using YAML that manages an employee’s leave request from creation to final approval. It ensures that both the employee’s manager and the HR department must approve the request before it is finalized.

1.1. Actors

  • Requester: The employee who creates and submits the leave request.

  • Manager: The employee’s direct supervisor who must approve the request.

  • HR Approver: A representative from the Human Resources department who also must approve the request.

1.2 Key Features

  • Declarative Workflow Define your entire process with simple, readable YAML.

  • Parallel Transitions Execute multiple approvals simultaneously with a powerful fork-join pattern.

  • Role-Based Transitions Present the right controls to the right users at the right time.

  • Input Validation Ensure data integrity by validating all inputs from the start.

  • Automatic State Persistence Persist all workflowDefinition data automatically between transitions.

2. Project Setup

Before we can build our leave request workflowDefinition, we need to lay the groundwork. This section will guide you through the essential first steps, including setting up your environment, installing the necessary libraries, and creating the basic file structure. By the end of this section, you’ll have a clean and ready workspace, prepared for defining our workflowDefinition logic.

2.1. Prerequisites

  • Java 17

  • Gradle 8.x

  • Your favorite IDE (IntelliJ, VS Code, Eclipse, etc.)

2.2. Generate project skeleton

mkdir yworkflow-tutorial
cd yworkflow-tutorial
gradle init

Choose 17 when asked for the Java version, but you can keep the defaults for the other options.

Once the setup is complete, run the build command to ensure the new project compiles correctly.

./gradlew build

You should see a BUILD SUCCESSFUL message.

2.3. Add yWorkflow Dependencies

Now, let’s add the yWorkflow libraries to the project. Open the build.gradle file located in the app folder and add the following lines inside the dependencies block.

val yworkflowVersion = "..."

dependencies {
    // Keep existing dependencies like JUnit
    testImplementation(platform("org.junit:junit-bom:5.10.0"))
    testImplementation("org.junit.jupiter:junit-jupiter")

    // Add the yWorkflow Bill of Materials (BOM) and required modules
    implementation(platform("com.yworkflow:yworkflow-bom:$yworkflowVersion"))
    implementation("com.yworkflow:yworkflow-engine")
    implementation("com.yworkflow:definition-factory-yaml")
}

2.4. Create a Verification Test

The best way to confirm our setup is working is to write a simple unit test that initializes the workflowDefinition engine and runs a minimal workflowDefinition.

First, create the test file and add the following code to your new test file. We’ll build this test piece by piece.

Step02_ProjectSetupTest.java
var workflowEngine =
    WorkflowEngine.builder()
        .withInstanceStore(new InMemoryWorkflowInstanceStore())
        .build(); (1)

var workflowYamlString =
    """
        workflow:
          id: "leave_request"
          initial-transitions:
            - id: "create_request"
              default-result:
                state: "draft_leave_request"
          states:
            - id: "draft_leave_request"
        """; (2)

var definitionSource = WorkflowYaml.fromYamlString(workflowYamlString);
var workflowDefinition = definitionSource.load(); (3)
workflowEngine.definitions().save(workflowDefinition); (4)

var workflowDefinitionId = workflowDefinitionId("leave_request");
var initialTransition = transitionId("create_request");

var workflowInstance =
    workflowEngine.instances().init(workflowDefinitionId, initialTransition); (5)

assertThat(workflowInstance).hasStatus(WorkflowInstanceStatus.COMPLETED); (6)
1 Initialize the main Workflows object. For this tutorial, we use an in-memory store for simplicity.
2 Define a minimal workflowDefinition in a YAML string. The initial-transitions section defines how a workflowDefinition can be started. states are the steps the workflowDefinition can be in.
3 Parse the YAML string into a formal Workflow object.
4 Persist the Workflow object.
5 Create an instance of the workflowDefinition. This simulates a user starting a new leave request.
6 Assert the final state of the instance.

2.5. Understanding the Test Result

You might be surprised that the test passes with a state of COMPLETED. This is the expected behavior!

Our workflowDefinition defines only one state: draft_leave_request. Since this state has no further transitions leading out of it, the workflowDefinition has nowhere else to go. The engine sees that it has reached a final state and automatically marks the instance as COMPLETED.

In the next sections, we will add more states and transitions to build a more realistic and long-running workflowDefinition.

Run the test from your IDE or via Gradle to confirm everything is working:

./gradlew test

3. Building a Multistep Workflow

Now that our project is set up and verified, it’s time to build the foundational logic of our leave request process.

In this section, you’ll expand the workflowDefinition definition from a single step into a complete, linear process. You will learn how to define states and transitions in YAML and how to use the Java API to move a workflowDefinition instance from its initial draft all the way to final approval.

3.1. Expanding the Workflow Definition

First, let’s replace our minimal workflowDefinition definition with the full, sequential process. The key change is adding transitions within each state. A transition represents a possible action that can be taken to move the workflowDefinition from its current state to the next one.

multistep_workflow.yml
workflow:
  id: "leave_request"
  initial-transitions:
    - id: "create"
      name: "Create leave request draft"
      default-result:
        state: "draft_leave_request"
  states:
    - id: "draft_leave_request"
      name: "Draft leave request"
      transitions:
        - id: "submit"
          name: "Submit leave request"
          default-result:
            state: "manager_approval"
    - id: "manager_approval"
      name: "Manager approval"
      transitions:
        - id: "manager_deny"
          name: "Manager deny request"
          default-result:
            state: "draft_leave_request"
            exit-status: "rejected"
        - id: "manager_approve"
          name: "Manager approve request"
          default-result:
            state: "hr_approval"
    - id: "hr_approval"
      name: "HR approval"
      transitions:
        - id: "hr_deny"
          name: "HR deny request"
          default-result:
            state: "draft_leave_request"
            exit-status: "rejected"
        - id: "hr_approve"
          name: "HR approve request"
          default-result:
            state: "approved"
    - id: "approved"
      name: "Leave request approved"

3.2. Testing the "Happy Path"

With our new definition, the workflowDefinition instance will no longer be COMPLETED immediately. Instead, it will be in an ACTIVE state, waiting at draft_leave_request.

Let’s write a new test to walk through the "happy path," where the request is approved by everyone. This test demonstrates how to use the .transition() method to advance the workflowDefinition.

Step03_MultiStepTest.java
var workflowEngine =
    WorkflowEngine.builder().withInstanceStore(new InMemoryWorkflowInstanceStore()).build();

var workflowYamlString = loadFileAsString("multistep_workflow.yml"); (1)
var definitionSource = WorkflowYaml.fromYamlString(workflowYamlString);
var workflowDefinition = definitionSource.load();
workflowEngine.definitions().save(workflowDefinition);

var leaveRequest =
    workflowEngine
        .instances()
        .init(workflowDefinitionId("leave_request"), transitionId("create")); (4)

assertThat(leaveRequest).hasCurrentStates("draft_leave_request"); (2)

leaveRequest.transition(transitionId("submit")); (3)

assertThat(leaveRequest).hasCurrentStates("manager_approval");

leaveRequest.transition(transitionId("manager_approve")); (4)

assertThat(leaveRequest).hasCurrentStates("hr_approval");

leaveRequest.transition(transitionId("hr_approve")); (5)

assertThat(leaveRequest).hasStatus(WorkflowInstanceStatus.COMPLETED); (6)
1 This time we are loading the workflowDefinition definition from a file
2 Initialize the workflowDefinition. It starts in the 'draft_leave_request' status.
3 The user submits the request, moving it to manager approval.
4 The manager approves, moving it to HR approval.
5 HR approves, moving it to the final 'approved' status.
6 Since 'approved' is a final status with no further transitions, the workflowDefinition is now complete.

3.3. Testing a Rejection Path

Workflows also need to handle alternate scenarios. Let’s add one more test to simulate a manager denying the request. According to our YAML, a denial (manager_deny) should return the workflowDefinition to the draft_leave_request state.

Add this final test method to your class.

Step03_MultiStepTest.java
var workflowEngine =
    WorkflowEngine.builder()
        .withInstanceStore(new InMemoryWorkflowInstanceStore())
        .build(); (1)

var yamlDefinitionString = loadFileAsString("multistep_workflow.yml");

var workflowDefinition =
    WorkflowYaml.fromYamlString(yamlDefinitionString).toWorkflowDefinition();
workflowEngine.definitions().save(workflowDefinition);

var workflowDefinitionId = workflowDefinitionId("leave_request");
var transitionId = transitionId("create");

var leaveRequest = workflowEngine.instances().init(workflowDefinitionId, transitionId);
leaveRequest.transition(transitionId("submit"));

assertThat(leaveRequest).hasCurrentStates("manager_approval");

leaveRequest.transition(transitionId("manager_deny")); (2)

assertThat(leaveRequest).hasCurrentStates("draft_leave_request"); (3)

assertThat(leaveRequest).hasStatus(WorkflowInstanceStatus.STARTED); (4)
1 Initialize the workflowDefinition and submit it for approval.
2 The manager denies the request.
3 Assert that the workflowDefinition has returned to the draft status for correction.
4 The workflowDefinition instance is still STARTED, waiting for the user to resubmit.

After adding these tests, run them from your IDE or with ./gradlew test. You now have a functioning, multistep workflowDefinition! In the next section, we’ll see how to validate user input before allowing a transition to occur.

4. Implementing Input Validation

A workflowDefinition is only as good as the data it runs on.

In this section, we’ll enhance our leave request by adding input validation. You will learn how to define validation rules directly in your YAML and how to provide input data when calling the API. This is a critical step to prevent errors and build a truly robust and reliable workflowDefinition.

4.1. Adding Validation Rules to the YAML

We need to ensure that when a user creates a leave request, they provide from and to dates in a YYYY-MM-DD format. We can enforce this by adding a validators block to our create transition.

input_validation_workflow.yml
  initial-transitions:
    - id: "create"
      name: "Create leave request draft"
      validators:
        - alias: "validate.input"
          args:
            - name: "from"
            - format: "^\\d{4}-\\d{2}-\\d{2}$"
        - alias: "validate.input"
          args:
            - name: "to"
            - format: "^\\d{4}-\\d{2}-\\d{2}$"
      default-result:
        state: "draft_leave_request"

What is validate.input? The alias: "validate.input" refers to a pre-built extension. To use it, we first need to tell our workflowDefinition engine how to find and load these extension providers. We’ll do that right now.

4.2. Enabling the Validation Extension

The yworkflow engine is lightweight by default. To use extensions like built-in validators, you need to add an extension provider module and configure the engine to use it. In this example, we will be using an extension provider that relies on Java’s Service Loader mechanism.

4.3 Add the Service Loader Dependency

First, open your build.gradle file and add the extensions-service-loader dependency.

dependencies {
    testImplementation(platform("org.junit:junit-bom:5.10.0"))
    testImplementation("org.junit.jupiter:junit-jupiter")

    // ... keep existing dependencies
    implementation(platform("com.yworkflow:yworkflow-bom:1.0.0"))
    implementation("com.yworkflow:yworkflow-engine")
    implementation("com.yworkflow:definition-factory-yaml")

    // Add the service loader module and prebuilt extensions
    implementation("com.yworkflow:extensions-service-loader")
    implementation("com.yworkflow:yworkflow-extensions")
}

4.4. Update the Workflows Builder

Next, the initialization of your workflowEngine object is updated to include the ServiceLoadedExtensionProvider. This tells the engine to automatically find and register available extensions like validate.input.

Step04_InputValidationTest.java
var workflowEngine =
    WorkflowEngine.builder()
        .withInstanceStore(new InMemoryWorkflowInstanceStore())
        .withExtensions(new ServiceLoadedExtensionProvider()) (1)
        .build();
1 Set the service loader based extension provider

4.5. Passing Input to the Workflow

Now that our workflowDefinition expects input and the engine knows how to validate it, let’s update our Java code to provide that data. The standard way to pass data is by using an Attributes.

Step04_InputValidationTest.java
var inputs = mutableAttributes()
        .withString("from", "2024-01-01")
        .withString("to", "2024-01-07")
        .toImmutable(); (1)

var leaveRequest =
    workflowEngine
        .instances()
        .init(
              workflowDefinitionId("leave_request"),
              transitionId("create"),
              inputs
        ); (2)

assertThat(leaveRequest).hasStatus(WorkflowInstanceStatus.STARTED);
assertThat(leaveRequest).hasCurrentStates("draft_leave_request"); (3)
1 Define the input data.
2 Call init() with the inputs map.
3 Assert that the workflowDefinition was created successfully.

4.6. Testing for Validation Failure

The true test of a validator is seeing it fail correctly. The engine should now throw an InvalidInputException if the input data doesn’t match the format we defined.

Step04_InputValidationTest.java
var inputs =
        mutableAttributes()
                .withString("from", "2024-01-01")
                .toImmutable(); (1)

assertThatThrownBy(() -> {

  workflowEngine.instances().init(
          workflowDefinitionId("leave_request"),
          transitionId("create"),
          inputs); (2)

}).isInstanceOf(InvalidInputException.class); (3)
1 Define the input data.
2 Call init() with the inputs map.
3 Assert that the workflowDefinition initialization failed due to an InvalidInputException.

5. How to Persist Workflow Data

Validating input is a great first step, but that data is only useful if we can store and access it later.

In this section, you’ll learn how to persist the validated from and to dates into the workflowDefinition instance’s attributes. This makes the data available for the entire lifecycle of the request, which is essential for approvals and notifications.

5.1. Persisting Input with post-functions

To save data, we use a post-functions block. These functions are actions that run after all validators have successfully passed but before the workflowDefinition officially transitions to the next state. This is the perfect place to save our validated input.

We’ll use another built-in extension, persist.input, which takes an input field and saves it as an attribute within the workflowDefinition instance.

Update the Workflow definition by adding the post-functions block to the create transition, right after the validators.

persist_input_workflow.yml
  initial-transitions:
    - id: "create"
      name: "Create leave request draft"
      validators:
        - alias: "validate.input"
          args:
            - name: "from"
            - format: "^\\d{4}-\\d{2}-\\d{2}$"
        - alias: "validate.input"
          args:
            - name: "to"
            - format: "^\\d{4}-\\d{2}-\\d{2}$"
      post-functions: (1)
        - alias: "persist.input"
          args:
            - name: "from"
        - alias: "persist.input"
          args:
            - name: "to"
      default-result:
        state: "draft_leave_request"
1 post-functions block added

5.2. Verifying the Persisted Data

Now, let’s write a test to confirm that the from and to input attributes are actually being saved to the workflowDefinition instance. The instance API provides a .attributes() method to access all stored data.

The following code creates a leave request with valid data and then asserts that the attributes have been correctly saved.

Step05_PersistWorkflowDataTest.java
var inputs = mutableAttributes()
        .withString("from", "2024-01-01")
        .withString("to", "2025-01-07")
        .toImmutable(); (1)

var workflowInstanceId = workflowDefinitionId("leave_request");
var transitionId = transitionId("create");

var leaveRequest = workflowEngine.instances().init(workflowInstanceId, transitionId, inputs); (2)

assertThat(leaveRequest).hasAttribute("from", "2024-01-01"); (3)
assertThat(leaveRequest).hasAttribute("to", "2025-01-07"); (3)
1 Define the input data.
2 Initialize the workflowDefinition instance with the data.
3 Assert that the 'from' and 'to' attributes now exist and hold the correct values.

6. Parallel Transitions using Fork-Join

Linear workflowEngine are simple, but not always efficient. Why should the HR department have to wait for the manager to approve a request first?

In this section, you’ll refactor the workflowDefinition to be much more efficient by allowing the manager and HR to review and approve the leave request at the same time. This will introduce you to the powerful fork and join concepts, which are essential for modeling real-world, parallel processes.

Expand to see the workflowDefinition state diagram.

leave request state diagram

6.1. Refactoring the YAML for a Parallel Flow

To enable a parallel flow, we need to make a few key changes to our YAML definition. We will introduce two new top-level blocks, forks and joins, and update our states to use them.

Let’s replace the highlighted parts of the workflowDefinition definition. We’ll break down the changes below.

fork_join_workflow.yml
  states:
    - id: "draft_leave_request"
      name: "Draft Leave Request"
      transitions:
        - id: "submit"
          name: "Submit Leave Request"
          default-result:
            fork: "forward_to_approvers" (1)
    - id: "manager_approval"
      name: "Manager Approval"
      transitions:
        - id: "manager_deny"
          name: "Manager Deny Request"
          default-result:
            state: "draft_leave_request"
            exit-status: "rejected"
        - id: "manager_approve"
          name: "Manager Approve Request"
          default-result:
            join: "check_everybody_approved" (2)
    - id: "hr_approval"
      name: "HR approval"
      transitions:
        - id: "hr_deny"
          name: "HR deny request"
          default-result:
            state: "draft_leave_request"
            exit-status: "rejected"
        - id: "hr_approve"
          name: "HR approve request"
          default-result:
            join: "check_everybody_approved" (2)
    - id: "approved"
      name: "Leave request approved"
  forks: (3)
    - id: "forward_to_approvers"
      default-results:
        - state: "manager_approval"
        - state: "hr_approval"
  joins: (4)
    - id: "check_everybody_approved"
      condition: (5)
        - alias: "check.join.states.status"
      default-result:
        state: "approved"
1 modified to trigger a fork instead of a state
2 modified to trigger a join instead of a state
3 the forks block defines how the workflowDefinition splits.
4 the joins block defines how parallel branches merge.
5 a join condition (see the explanation below)

Key concepts in this new workflowDefinition definition

  • fork: The submit transition now points to a fork. The forks block defines what happens: the workflowDefinition splits and creates two active states simultaneously: manager_approval and hr_approval.

  • join: When an approval transition is triggered (manager_approve or hr_approve), it now points to a join. The joins block defines a gatekeeper that waits for incoming branches to finish.

  • join condition: A condition that must be met before moving the state that the join defines. The check.join.states.status is a condition that tells the join to wait until all branches created by the fork have reached a given status (completed by default). Only when both the manager and HR have approved will the join condition pass, merge the paths, and move the workflowDefinition to the final approved state.

6.2. Testing the Parallel Happy Path

Testing a parallel workflowDefinition is different. After the submit transition, the instance will have two current states. We need to simulate both approval transitions before the workflowDefinition can complete.

Step06_ForkJoinTest.java
var inputs = mutableAttributes()
        .withString("from", "2024-01-01")
        .withString("to", "2024-01-07")
        .toImmutable();

var workflowDefinitionId = workflowDefinitionId("leave_request");
var transitionId = transitionId("create");

var leaveRequest = workflowEngine.instances().init(workflowDefinitionId, transitionId, inputs);

leaveRequest.transition(transitionId("submit")); (1)

assertThat(leaveRequest).hasStatus(WorkflowInstanceStatus.STARTED);
assertThat(leaveRequest).hasCurrentStates("manager_approval", "hr_approval"); (2)

leaveRequest.transition(transitionId("manager_approve")); (3)

assertThat(leaveRequest).hasStatus(WorkflowInstanceStatus.STARTED);
assertThat(leaveRequest).hasCurrentStates("hr_approval"); (4)

leaveRequest.transition(transitionId("hr_approve")); (5)

assertThat(leaveRequest).hasNoAvailableTransitions();
assertThat(leaveRequest).hasStatus(WorkflowInstanceStatus.COMPLETED); (6)
1 Create and submit the request. This will trigger the fork.
2 After forking, the instance is STARTED with TWO current states.
3 One approver (e.g., manager) approves.
4 The workflowDefinition is still ACTIVE, waiting for the other branch. The manager_approval state is now complete, so only hr_approval is left.
5 The second approver (HR) approves.
6 Both branches are complete, the join condition passes, and the workflowDefinition is now fully completed.

7. Exposing Transitions to State Owners

Our workflowDefinition is now efficient, but it’s not yet secure. Currently, any user could approve the manager’s task or the HR task.

In this section, you will learn how to assign ownership to states and use conditions to ensure that only the correct person can execute a specific action. This will make our workflowDefinition not only powerful but also secure and ready for a multi-user environment.

7.1. Assigning Owners in the YAML

First, we need to define who is responsible for each approval state. We can do this by adding an owners list to each result within our forks block. We also need to add a condition to our approval transitions to enforce this ownership.

state_owner_workflow.yml
states:
  - id: "draft_leave_request"
    name: "Draft Leave Request"
    transitions:
      - id: "submit"
        name: "Submit Leave Request"
        default-result:
          fork: "forward_to_approvers"
  - id: "manager_approval"
    name: "Manager Approval"
    transitions:
      - id: "manager_deny"
        name: "Manager Deny Request"
        guards: (1)
          - alias: "check.state.owner"
        default-result:
          state: "draft_leave_request"
          exit-status: "rejected"
      - id: "manager_approve"
        name: "Manager Approve Request"
        guards: (1)
          - alias: "check.state.owner"
        default-result:
          join: "check_everybody_approved"
  - id: "hr_approval"
    name: "HR approval"
    transitions:
      - id: "hr_deny"
        name: "HR deny request"
        guards: (1)
          - alias: "check.state.owner"
        default-result:
          state: "draft_leave_request"
          exit-status: "rejected"
      - id: "hr_approve"
        name: "HR approve request"
        guards: (1)
          - alias: "check.state.owner"
        default-result:
          join: "check_everybody_approved"
  - id: "approved"
    name: "Leave request approved"
forks:
  - id: "forward_to_approvers"
    default-results:
      - state: "manager_approval"
        owners: (2)
          - "manager"
      - state: "hr_approval"
        owners: (2)
          - "hr"
1 Add the check.state.owner condition to the transition, so it is only available to the state owners
2 Assign an owner to the state during the fork

7.2 Key Concepts

  • owners: When the workflowDefinition forks, the manager_approval state is now explicitly owned by the "manager" role.

  • check.state.owner condition: This is the enforcement rule. Before allowing a transition like manager_approve to run by the caller or to be listed as available for the given caller, the engine will execute the check.state.owner extension. This check passes only if the person trying to perform the action is listed in the state’s owners list.

7.3. Acting as a Specific User

To test this, we need a way to tell the workflowDefinition engine who is performing the action. The API provides a way to do this by creating a new workflowDefinition instance in which the caller has been updated.

Let’s write a final test that demonstrates this feature. This test will show that only the manager can see and perform the manager’s actions, and only HR can act on the HR state.

Step07_StateOwnerTest.java
var inputs = mutableAttributes()
        .withString("from", "2024-01-01")
        .withString("to", "2024-01-07")
        .toImmutable();

var workflowDefinitionId = workflowDefinitionId("leave_request");
var transitionId = transitionId("create");

var leaveRequest = workflowEngine.instances().init(workflowDefinitionId, transitionId, inputs);

leaveRequest.transition(transitionId("submit"));

assertThat(leaveRequest).hasNoAvailableTransitions(); (1)

// let's approve as a manager

var leaveRequestAsManager = leaveRequest.actAs(caller("manager")); (2)

assertThat(leaveRequestAsManager).hasAvailableTransitions("manager_deny", "manager_approve");

leaveRequestAsManager.transition(transitionId("manager_approve")); (3)

assertThat(leaveRequestAsManager).hasNoAvailableTransitions();

// let's approve as an HR representative

var leaveRequestAsHr = leaveRequest.actAs(caller("hr")); (4)

assertThat(leaveRequestAsHr).hasAvailableTransitions("hr_deny", "hr_approve");

leaveRequestAsHr.transition(transitionId("hr_approve")); (5)

assertThat(leaveRequestAsHr).hasNoAvailableTransitions();

assertThat(leaveRequest).hasStatus(WorkflowInstanceStatus.COMPLETED); (6)
1 As the original user (no specific context), no approval transitions are available.
2 Act as the manager. Now, two transitions are available for this context (approve & deny).
3 The manager approves the request.
4 Act as HR. Now, HR also has two available transitions (approve & deny).
5 HR approves the request.
6 The join condition passes and the workflowDefinition is complete.