The release of Akka 3 marks a pivotal moment for the Akka ecosystem, bringing not only a higher level of abstraction to simplify building distributed systems but also a suite of powerful new components designed to enhance developer productivity. Among these, the Akka Workflow component stands out as a groundbreaking addition, enabling developers to model, orchestrate, and execute complex workflows with ease. This innovative feature redefines how we approach stateful processing in distributed systems, making Akka 3 not just an upgrade but a transformation of how reactive applications are built and scaled. Building long-running business processes (a.k.a. Sagas) is faster and easier than ever before.
Previously, Akka allowed us to build choreography-based Sagas. Key Values Entities or Event Sourced Entities can compose complex business flows with Consumers and Timers. The Akka 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 developers, we 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 our concern. We 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 we are building a basic business flow for an online shop. To confirm the order, we need to execute two steps:
Each action can return an expected business failure, so we must revert our system to the correct state when this happens. In the case of missing stock, we can immediately reject the order. Whereas, after a payment failure, we 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 Akka component. For the stock reservation (and cancellation), we have the InventoryEntity
.
@ComponentId("inventory")
public class InventoryEntity extends EventSourcedEntity<Inventory, InventoryEvent> {
public Effect<Response> add(AddProduct addProduct) {
}
public Effect<Response> reserve(ReserveStocks reserveStocks) {
}
public Effect<Success> cancelReservation(String orderId) {
}
}
The PaymentProcessorEntity
is responsible for making payments.
@ComponentId("payment-processor")
public class PaymentProcessorEntity extends EventSourcedEntity<PaymentProcessor, PaymentProcessorEvent> {
public Effect<Response> makePayment(MakePayment makePayment) {
}
}
Both are represented as Event Sourced Entities, but from the Workflow perspective, it doesn’t matter. It might as well be a call to an Akka Key Value Entity or an external service.
To create our Workflow, we need to extend the Workflow<>
class and put @ComponentId
annotation (like for any other component).
@ComponentId("order")
public class OrderWorkflow extends Workflow<Order> {
A 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 static Order create(OrderPlaced orderPlaced) {
return new Order(orderPlaced.orderId(), orderPlaced.userId(),
orderPlaced.productId(), orderPlaced.quantity(), orderPlaced.price(), PLACED);
}
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.Effec
t. This is the instruction for Akka machinery to transition to reserve-stocks
as the first step (and update the workflow state).
public class OrderWorkflow extends Workflow<Order> {
public Effect<Response> placeOrder(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 with the Component Client). Based on the result, a decision should be made about what happens next. In this case, we can either go to the `make-payment` step or reject the order.
Step reserveStocks = step("reserve-stocks")
.asyncCall(this::reserveInventoryStocks)
.andThen(Response.class, this::moveToPaymentOrReject);
private CompletionStage<Response> reserveInventoryStocks() {
var order = currentState();
var reserveStocksCommand = new ReserveStocks(order.id(), order.userId(),
order.productId(), order.quantity(), order.price());
return componentClient.forEventSourcedEntity(INVENTORY_ID)
.method(InventoryEntity::reserve)
.invokeAsync(reserveStocksCommand);
}
private TransitionalEffect<Void> moveToPaymentOrReject(Response response) {
return switch (response) {
case Failure __ -> {
yield effects().updateState(currentState().asRejected()).end();
}
case Success __ -> effects().transitionTo("make-payment");
};
}
The next step, called make-payment
, is very similar, but this time in the case of a payment failure, we will initiate a compensation action or finish the workflow otherwise.
Step makePayment = step("make-payment")
.asyncCall(this::callPaymentProcessor)
.andThen(Response.class, this::confirmOrderOrRollback);
private CompletionStage<Response> callPaymentProcessor() {
var order = currentState();
var amount = order.price().multiply(BigDecimal.valueOf(order.quantity()));
var makePayment = new PaymentProcessorCommand.MakePayment(order.id(), order.userId(), amount);
return componentClient.forEventSourcedEntity(CARD_PAYMENT_PROCESSOR_ID)
.method(PaymentProcessorEntity::makePayment)
.invokeAsync(makePayment);
}
private TransitionalEffect<Void> confirmOrderOrRollback(Response response) {
return switch (response) {
case Failure __ -> {
yield effects()
.updateState(currentState().asRejected())
.transitionTo("cancel-reservation");
}
case Success __ -> {
yield effects()
.updateState(currentState().asConfirmed())
.end();
}
};
}
The compensation action in Akka 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 we 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 Akka. 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, Akka guarantees that the state, step call results, and transitions are persisted. After the restart, our workflow will continue the work from the last successfully persisted point. Moreover, the workflow state would not be concurrently updated. Similar to entities, Akka manages concurrency for us so we 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 we specify otherwise).
If you want to see the complete code example, checkout this repository and follow the Readme
file. As mentioned earlier, more detailed blog posts about workflows and Sagas are coming. We will describe:
Stay tuned!