White to Black Gradient
blog

Introducing Kalix Workflow Component

Andrzej Ludwikowski.
Software Architect, Lightbend.
  • 8 July 2023,
  • 6 minute read

Finally! The long-awaited Kalix Workflow component has been officially released. It’s a new component in the Kalix ecosystem that makes building long-running business processes (a.k.a. Sagas) faster and easier than ever before.

Previously, Kalix allowed you to build choreography-based Sagas. Values Entities or Event Sourced Entities can compose complex business flows with Subscriptions, Actions, and Timers. The Kalix ecosystem is event-driven by design, so it’s very natural to embrace asynchronous communication not only between separate microservices but also between components within the same service.

The usual steps required to build a distributed application are reduced to the bare minimum. As a developer, you can finally focus only on the domain and business code. Things like message ordering, delivery guarantees, recovery, scaling, and even message buses are no longer your concern. You can build something ready to ship to production with just a few annotations.

How do Workflow components fit into this ecosystem? The best way is to show it by an example. Let’s say that you are building a basic business flow for an online shop. To confirm the order you need to execute two steps:

  1. Reserve stocks in the inventory
  2. Make a payment
Kalix Workflow Entity

Each action can return an expected business failure, so you must revert our system to the correct state when this happens. In the case of missing stock, you can immediately reject the order. Whereas, after a payment failure, you must cancel the stock reservation (yellow diamond), before rejecting the order. Canceling the reservation is a compensating action for the first step.

Each step calls a different Kalix component. For the stock reservation (and cancellation), you have the InventoryEntity, with endpoints:

  • PATCH /inventory/❴id❵/reserve
  • PATCH /inventory/❴id❵/cancel-reservation/❴orderId❵

The PaymentProcessorEntity is responsible for making payments and exposes:

  • POST /payment-processor/❴id❵

Both are represented as Event Sourced Entities, but from the Workflow perspective, it doesn’t matter. It might as well be a call to a Kalix Value Entity or an external service.

To create our Workflow, you need to extend the WorkflowEntity<> class and put some additional annotations.

@Id("id")
@TypeId("order")
@RequestMapping("/order/{id}")
public class OrderWorkflow extends Workflow<Order> {

A Kalix workflow could be described as a state-machine implementation. The Order class is a representation of our state, with a starting OrderStatus set to PLACED. Depending on the workflow execution, the end status could be CONFIRMED or REJECTED.

public record Order(String id, String userId, String productId, int quantity, BigDecimal price, 
        OrderStatus status) {

  public Order asConfirmed() {
    return new Order(id, userId, productId, quantity, price, CONFIRMED);
  }

  public Order asRejected() {
    return new Order(id, userId, productId, quantity, price, REJECTED);
  }
}

Everything starts with placing an order, which is an initial call to the Workflow, that returns a Workflow.Effect. This is the instruction for Kalix machinery to transition to reserve-stocks as the first step (and update the workflow state).

@PostMapping
public Effect<Response> placeOrder(@RequestBody PlaceOrder placeOrder) {
  if (currentState() != null) {
    return effects().error("order already placed");
  } else {
    String orderId = commandContext().workflowId();
    Order order = new Order(orderId, placeOrder.userId(), placeOrder.productId(), placeOrder.quantity(), placeOrder.price(), OrderStatus.PLACED);

    return effects()
        .updateState(order)
        .transitionTo("reserve-stocks")
        .thenReply(Success.of("order placed"));
  }
}

A step is identified by a unique name. It has an execution definition (a call to reserve the inventory). Based on the result, a decision should be made about what happens next. In this case, you can either go to the make-payment step or reject the order.

Step reserveStocks = step("reserve-stocks")
  .call(this::reserveInventoryStocks)
  .andThen(Response.class, this::moveToPaymentOrReject);

private DeferredCall<Any, Response> reserveInventoryStocks() {
  var order = currentState();
  var reserveStocksCommand = new ReserveStocks(order.id(), order.userId(), order.productId(), order.quantity(), order.price());
  return componentClient.forEventSourcedEntity(INVENTORY_ID)
    .call(InventoryEntity::reserve)
    .params(reserveStocksCommand);
}

private TransitionalEffect<Void> moveToPaymentOrReject(Response response) {
  return switch (response) {
    case Failure failure -> effects().updateState(currentState().asRejected()).end();
    case Success success -> effects().transitionTo("make-payment");
  };
}

The next step, called make-payment, is very similar, but this time in the case of a payment failure, you will initiate a compensation action.

Step makePayment = step("make-payment")
  .call(this::callPaymentProcessor)
  .andThen(Response.class, this::confirmOrderOrRollback);

private TransitionalEffect<Void> confirmOrderOrRollback(Response response) {
  return switch (response) {
    case Failure __ -> effects()
      .updateState(currentState().asRejected())
      .transitionTo("cancel-reservation");
    case Success __ -> effects()
      .updateState(currentState().asConfirmed())
      .end();
  };
}

The compensation action in the Kalix Workflow is just like any other workflow step. Very often, each step would have a dedicated compensation step, but in some cases, this might not be enough to correct the system state after a failure. In other cases, a single step might be sufficient to perform the compensation. The API provides basic building blocks and you have a lot of freedom in composing them together.

When it comes to error handling and compensation this is just a starting point. We are preparing a more detailed deep dive blog post series about Saga patterns in Kalix. For now, you can grasp some of it from the official documentation.

With additional configuration properties, all steps create a workflow definition. This is the core of the Workflow component, a runnable framework for our state machine.

@Override
public WorkflowDef<Order> definition() {
  Step reserveStocks = …
  Step makePayment = …
  Step cancelReservation = …

  return workflow()
    .timeout(ofSeconds(20))
    .defaultStepTimeout(ofSeconds(5))
    .addStep(reserveStocks)
    .addStep(makePayment)
    .addStep(cancelReservation);
}

Under the hood, Kalix guarantees that the state, step call results and transitions are persisted. After the restart, your workflow will continue the work from the last successfully persisted point. Moreover, the workflow state would not be concurrently updated. Similar to entities, Kalix manages concurrency for us so you can focus on domain modeling without interference from technical challenges like optimistic locking, caching, scaling, etc. In case of an unexpected error, a step will retry the execution (by default forever, unless you specify otherwise).

If you want to see the complete code example, checkout this repository and follow the Readme file. You can run the application in two different modes—with an orchestrated or a choreographed Saga implementation enabled. This way you can easily compare two different Saga flavors serving the same functionality.

As mentioned earlier, more detailed blog posts about workflows and Sagas are coming. We will describe:

  • How other components can improve our implementation?
  • How to achieve exactly-once delivery with deduplication?
  • How to set up a more advanced error handling and recovery strategy?
  • What are the compensation edge cases that we should be aware of?

Watch the video to learn more.