Skip to content

Tutorial

This guide walks you through the process of creating a Baker orchestration workflow one step at a time. Completing this tutorial takes around 20 minutes.

Note

To follow this tutorial you will need a Java, Kotlin, or Scala project that includes the dependencies mentioned in the quickstart guide.

Note

This tutorial assumes you have a basic understanding of ingredients, events, interactions, and recipes. If not, please read the concepts section before starting the tutorial.

Setting the stage

Imagine you are working as a software engineer for a modern e-commerce company. They are building a web-shop made up of different microservices. You are responsible for orchestrating the order-flow. The requirements read:

Once a customer places an order, we need to verify if the products are in stock. Stock levels are available via the StockService. If there is enough stock, ship the order by calling the ShippingService. If there is insufficient stock, cancel the order by calling the CancellationService.

Define the sensory event

A recipe is always triggered by a sensory event. In this example, our sensory event is the customer placing an order.

package examples.java.events;

import examples.java.ingredients.Address;

import java.util.List;

public record OrderPlaced(
    String orderId,
    String customerId,
    Address address,
    List<String> productIds
) { }
package examples.kotlin.events

import examples.kotlin.ingredients.Address

data class OrderPlaced(
    val orderId: String,
    val customerId: String,
    val address: Address,
    val productIds: List<String>
)
package examples.scala.events

import examples.scala.ingredients.Address

case class OrderPlaced(
  orderId: String,
  customerId: String,
  address: Address,
  productIds: List[String]
)

The OrderPlaced event carries four ingredients. The address ingredient is of type Address, which is just a simple data class.

package examples.java.ingredients;

public record Address(
    String street,
    String city,
    String zipCode,
    String country
) { }
package examples.kotlin.ingredients

data class Address(
    val street: String,
    val city: String,
    val zipCode: String,
    val country: String
)
package examples.scala.ingredients

case class Address(
  street: String,
  city: String,
  zipCode: String,
  country: String
)

Define the interactions

Next, it's time to model our interactions. We need to create a total of three interactions. One to validate if the products are in stock, one to ship the order, and one to cancel the order.

In this step we will just declare our interaction blueprints as interfaces. That's all we need to be able to declare a recipe. The implementation for these interactions will follow at a later stage.

Check stock

Our stock validation interaction requires two ingredients as input. The orderId and a list of productIds. The interaction won't execute unless these ingredients are available in the process. The interaction will either emit a SufficientStock event, if all products are in stock. Or an OrderHasUnavailableItems event otherwise. The OrderHasUnavailableItems event carries a list of unavailableProductIds as ingredient.

package examples.java.interactions;

import com.ing.baker.recipe.annotations.FiresEvent;
import com.ing.baker.recipe.annotations.RequiresIngredient;
import com.ing.baker.recipe.javadsl.Interaction;

import java.util.List;

public interface CheckStock extends Interaction {

    interface Outcome {
    }

    record OrderHasUnavailableItems(List<String> unavailableProductIds) implements Outcome {
    }

    record SufficientStock() implements Outcome {
    }

    @FiresEvent(oneOf = {SufficientStock.class, OrderHasUnavailableItems.class})
    Outcome apply(@RequiresIngredient("orderId") String orderId,
                  @RequiresIngredient("productIds") List<String> productIds
    );
}

Note

The @FiresEvent annotation is used to define the possible outcome events.

Note

The @RequiresIngredient annotation is used to define the ingredient names that this interaction needs for its execution.

Warning

The Java implementation makes use of Bakers reflection API. For this to work, the method in the interaction must be named apply. Other names won't work.

package examples.kotlin.interactions

import com.ing.baker.recipe.javadsl.Interaction

interface CheckStock : Interaction {

    sealed interface Outcome

    data class OrderHasUnavailableItems(
        val unavailableProductIds: List<String>
    ) : Outcome

    object SufficientStock : Outcome

    fun apply(orderId: String, productIds: List<String>): Outcome
}

Note

Kotlin's reflection API is more powerful than Java's. There is no need for any annotations when you model the possible outcome events as a sealed hierarchy.

Warning

The Kotlin implementation makes use of Bakers reflection API. For this to work, the method in the interaction must be named apply. Other names won't work.

package examples.scala.interactions

import com.ing.baker.recipe.scaladsl.{Event, Ingredient, Interaction}

object CheckStock {

  sealed trait Outcome

  case class SufficientStock() extends Outcome
  case class OrderHasUnavailableItems(unavailableProductIds: List[String]) extends Outcome

  val interaction: Interaction = Interaction(
    name = "CheckStock",
    inputIngredients = Seq(
      Ingredient[String](name = "orderId"),
      Ingredient[List[String]](name = "productIds")
    ),
    output = Seq(
      Event[SufficientStock],
      Event[OrderHasUnavailableItems]
    )
  )
}

Ship Order

To ship an order we'll need the orderId and an address. For the sake of simplicity, this interaction will always result in a OrderShipped event.

package examples.java.interactions;

import com.ing.baker.recipe.annotations.FiresEvent;
import com.ing.baker.recipe.annotations.RequiresIngredient;
import com.ing.baker.recipe.javadsl.Interaction;
import examples.java.ingredients.Address;

public interface ShipOrder extends Interaction {

    record OrderShipped() {
    }

    @FiresEvent(oneOf = {OrderShipped.class})
    OrderShipped apply(@RequiresIngredient("orderId") String orderId,
                       @RequiresIngredient("address") Address address
    );
}
package examples.kotlin.interactions

import com.ing.baker.recipe.javadsl.Interaction
import examples.kotlin.ingredients.Address

interface ShipOrder : Interaction {
    object OrderShipped

    fun apply(orderId: String, address: Address): OrderShipped
}
package examples.scala.interactions

import com.ing.baker.recipe.scaladsl.{Event, Ingredient, Interaction}
import examples.scala.ingredients.Address

object ShipOrder {

  case class OrderShipped()

  val interaction: Interaction = Interaction(
    name = "ShipOrder",
    inputIngredients = Seq(
      Ingredient[String](name = "orderId"),
      Ingredient[Address](name = "address")
    ),
    output = Seq(
      Event[OrderShipped]
    )
  )
}

Cancel order

To cancel the order we'll need the orderId and a list of unavailableProductIds. Of course, unavailableProductIds will only be available if the stock validation failed.

package examples.java.interactions;

import com.ing.baker.recipe.annotations.FiresEvent;
import com.ing.baker.recipe.annotations.RequiresIngredient;
import com.ing.baker.recipe.javadsl.Interaction;

import java.util.List;

public interface CancelOrder extends Interaction {

    record OrderCancelled() {
    }

    @FiresEvent(oneOf = {OrderCancelled.class})
    OrderCancelled apply(@RequiresIngredient("orderId") String orderId,
                         @RequiresIngredient("unavailableProductIds") List<String> unavailableProductIds
    );
}
package examples.kotlin.interactions

import com.ing.baker.recipe.javadsl.Interaction

interface CancelOrder : Interaction {
    object OrderCancelled

    fun apply(orderId: String, unavailableProductIds: List<String>): OrderCancelled
}
package examples.scala.interactions

import com.ing.baker.recipe.scaladsl.{Event, Ingredient, Interaction}
import examples.scala.ingredients.Address

object CancelOrder {

  case class OrderCancelled()

  val interaction: Interaction = Interaction(
    name = "CancelOrder",
    inputIngredients = Seq(
      Ingredient[String](name = "orderId"),
      Ingredient[List[String]](name = "unavailableProductIds")
    ),
    output = Seq(
      Event[OrderCancelled]
    )
  )
}

Define the recipe

At this point we can compose our sensory event and three interactions into a recipe. The OrderPlaced event is declared as a sensory event. OrderPlaced carries all the ingredients required by the CheckStock interaction, so once the sensory event fires the CheckStock interaction will execute.

CheckStock will output either a SufficientStock or OrderHasUnavailableItems event. ShipOrder will only execute if the process contains an event of SufficientStock and CancelOrder will only execute if the process contains an event of OrderHasUnavailableItems.

package examples.java.recipes;

import com.ing.baker.recipe.javadsl.InteractionDescriptor;
import com.ing.baker.recipe.javadsl.Recipe;
import examples.java.events.OrderPlaced;
import examples.java.interactions.CancelOrder;
import examples.java.interactions.CheckStock;
import examples.java.interactions.ShipOrder;

public class WebShopRecipe {

    public final static Recipe recipe = new Recipe("web-shop recipe")
        .withSensoryEvent(OrderPlaced.class)
        .withInteractions(
            InteractionDescriptor.of(CheckStock.class),
            InteractionDescriptor.of(ShipOrder.class)
                .withRequiredEvent(CheckStock.SufficientStock.class),
            InteractionDescriptor.of(CancelOrder.class)
                .withRequiredEvent(CheckStock.OrderHasUnavailableItems.class)
        );
}
package examples.kotlin.recipes

import com.ing.baker.recipe.kotlindsl.ExperimentalDsl
import com.ing.baker.recipe.kotlindsl.recipe
import examples.kotlin.events.OrderPlaced
import examples.kotlin.interactions.CancelOrder
import examples.kotlin.interactions.CheckStock
import examples.kotlin.interactions.CheckStock.*
import examples.kotlin.interactions.ShipOrder

@ExperimentalDsl
object WebShopRecipe {
    val recipe = recipe("web-shop recipe") {
        sensoryEvents {
            event<OrderPlaced>()
        }
        interaction<CheckStock>()
        interaction<ShipOrder> {
            requiredEvents {
                event<SufficientStock>()
            }
        }
        interaction<CancelOrder> {
            requiredEvents {
                event<OrderHasUnavailableItems>()
            }
        }
    }
}
package examples.scala.recipes

import com.ing.baker.recipe.scaladsl.{Event, Recipe}
import examples.scala.events.OrderPlaced
import examples.scala.interactions.{CancelOrder, CheckStock, ShipOrder}

object WebShopRecipe {
  val recipe: Recipe = Recipe("web-shop recipe")
    .withSensoryEvent(
      Event[OrderPlaced]
    )
    .withInteractions(
      CheckStock.interaction,
      ShipOrder.interaction
        .withRequiredEvent(
          Event[CheckStock.SufficientStock]
        ),
      CancelOrder.interaction
        .withRequiredEvent(
          Event[CheckStock.OrderHasUnavailableItems]
        )
    )
}

Implement the interactions

Before we can run our recipe, we need to create InteractionInstances that the Baker runtime will use to execute the interactions. In other words, we need to provide implementations for the interactions.

Since this is a tutorial, we'll just create some dummy implementations. In a real solution, this is the part where you would implement your actual logic.

Tip

In these examples we use a Impl suffix for the implementation classes. In your real solution you might want to use a more meaningful name.

Check stock implementation

package examples.java.interactions;

import java.util.List;

public class CheckStockImpl implements CheckStock {

    @Override
    public Outcome apply(String orderId, List<String> productIds) {
        System.out.printf("Checking stock for order: %s and products: %s%n", orderId, productIds);

        int random = (int) (Math.random() * (1000 - 1)) + 1;
        if (random < 500) {
            return new SufficientStock();
        } else {
            return new OrderHasUnavailableItems(productIds);
        }
    }
}
package examples.kotlin.interactions

object CheckStockImpl : CheckStock {
    override fun apply(orderId: String, productIds: List<String>): CheckStock.Outcome {
        println("Checking stock for order: $orderId and products: $productIds")

        return if ((1..1000).random() < 500) {
            CheckStock.SufficientStock
        } else {
            CheckStock.OrderHasUnavailableItems(productIds)
        }
    }
}
package examples.scala.interactions

import scala.concurrent.Future
import scala.util.Random

trait CheckStockTrait {
  def apply(orderId: String, productIds: List[String]): Future[CheckStock.Outcome]
}

class CheckStockImpl extends CheckStockTrait {
  override def apply(orderId: String, productIds: List[String]): Future[CheckStock.Outcome] = {
    println(s"Checking stock for order: $orderId and products: $productIds")

    val randomNumber = new Random().nextInt(1000) + 1
    if (randomNumber < 500) {
      Future.successful(CheckStock.SufficientStock())
    } else {
      Future.successful(CheckStock.OrderHasUnavailableItems(productIds))
    }
  }
}

Ship order implementation

package examples.java.interactions;

import examples.java.ingredients.Address;

public class ShipOrderImpl implements ShipOrder {
    @Override
    public OrderShipped apply(String orderId, Address address) {
        System.out.printf("Shipping order %s to %s", orderId, address);
        return new OrderShipped();
    }
}
package examples.kotlin.interactions

import examples.kotlin.ingredients.Address

object ShipOrderImpl : ShipOrder {
    override fun apply(orderId: String, address: Address): ShipOrder.OrderShipped {
        println("Shipping order $orderId to $address")
        return ShipOrder.OrderShipped
    }
}
package examples.scala.interactions

import examples.scala.ingredients.Address

import scala.concurrent.Future

trait ShipOrderTrait {
  def apply(orderId: String, address: Address): Future[ShipOrder.OrderShipped]
}

class ShipOrderImpl extends ShipOrderTrait {
  override def apply(orderId: String, address: Address): Future[ShipOrder.OrderShipped] = {
    println(s"Shipping order $orderId to $address")
    Future.successful(ShipOrder.OrderShipped())
  }
}

Cancel order implementation

package examples.java.interactions;

import java.util.List;

public class CancelOrderImpl implements CancelOrder {
    @Override
    public OrderCancelled apply(String orderId, List<String> unavailableProductIds) {
        System.out.printf("Canceling order %s. The following products are unavailable: %s", orderId, unavailableProductIds);
        return new OrderCancelled();
    }
}
package examples.kotlin.interactions

object CancelOrderImpl : CancelOrder {
    override fun apply(orderId: String, unavailableProductIds: List<String>): CancelOrder.OrderCancelled {
        println("Canceling order $orderId. The following products are unavailable: $unavailableProductIds")
        return CancelOrder.OrderCancelled
    }
}
package examples.scala.interactions

import scala.concurrent.Future

trait CancelOrderTrait {
  def apply(orderId: String, unavailableProductIds: List[String]): Future[CancelOrder.OrderCancelled]
}

class CancelOrderImpl extends CancelOrderTrait {
  override def apply(orderId: String, unavailableProductIds: List[String]): Future[CancelOrder.OrderCancelled] = {
    println(s"Canceling order $orderId. The following products are unavailable: $unavailableProductIds")
    Future.successful(CancelOrder.OrderCancelled())
  }
}

Execute the recipe

To execute the recipe we first need an instance of the InMemoryBaker. You can create one by providing the interaction implementations to the InMemoryBaker static factory.

The next step is to add the recipe to Baker. You can do this via the addRecipe method. If the validate flag is set to true, Baker checks if all required interaction implementations are available. Adding the recipe is something you only need to do once for each unique recipe.

Before we can fire the sensory event, we need to create a new process instance of the recipe. We do this via the bake method. You are required to specify a recipeInstanceId. Here we use UUID, but it can be anything as long as it's unique within your processes.

Finally, we can fire the sensory event via fireEventAndResolveWhenCompleted. The moment the event arrives our process will start.

package examples.java.application;

import com.ing.baker.compiler.RecipeCompiler;
import com.ing.baker.runtime.inmemory.InMemoryBaker;
import com.ing.baker.runtime.javadsl.EventInstance;
import examples.java.events.OrderPlaced;
import examples.java.ingredients.Address;
import examples.java.interactions.CancelOrderImpl;
import examples.java.interactions.CheckStockImpl;
import examples.java.interactions.ShipOrderImpl;
import examples.java.recipes.WebShopRecipe;

import java.util.List;
import java.util.UUID;

public class WebShopApp {

    public static void main(String[] args) {
        var baker = InMemoryBaker.java(
            List.of(new CheckStockImpl(), new CancelOrderImpl(), new ShipOrderImpl())
        );

        var recipeInstanceId = UUID.randomUUID().toString();
        var sensoryEvent = EventInstance.from(createOrderPlaced());

        baker.addRecipe(RecipeCompiler.compileRecipe(WebShopRecipe.recipe), true)
            .thenCompose(recipeId -> baker.bake(recipeId, recipeInstanceId))
            .thenCompose(ignored -> baker.fireEventAndResolveWhenCompleted(recipeInstanceId, sensoryEvent))
            .join();
    }

    private static OrderPlaced createOrderPlaced() {
        var address = new Address("Hoofdstraat", "Amsterdam", "1234AA", "The Netherlands");
        return new OrderPlaced("123", "456", address, List.of("iPhone", "Playstation5"));
    }
}
package examples.kotlin.application

import com.ing.baker.compiler.RecipeCompiler
import com.ing.baker.recipe.kotlindsl.ExperimentalDsl
import com.ing.baker.runtime.javadsl.EventInstance
import com.ing.baker.runtime.kotlindsl.InMemoryBaker
import examples.kotlin.events.OrderPlaced
import examples.kotlin.ingredients.Address
import examples.kotlin.interactions.CancelOrderImpl
import examples.kotlin.interactions.CheckStockImpl
import examples.kotlin.interactions.ShipOrderImpl
import examples.kotlin.recipes.WebShopRecipe
import kotlinx.coroutines.runBlocking
import java.util.*

@ExperimentalDsl
fun main(): Unit = runBlocking {
    val baker = InMemoryBaker.kotlin(
        implementations = listOf(CheckStockImpl, CancelOrderImpl, ShipOrderImpl)
    )

    val recipeId = baker.addRecipe(
        compiledRecipe = RecipeCompiler.compileRecipe(WebShopRecipe.recipe),
        validate = true
    )

    val recipeInstanceId = UUID.randomUUID().toString()
    val sensoryEvent = EventInstance.from(orderPlaced)

    baker.bake(recipeId, recipeInstanceId)
    baker.fireEventAndResolveWhenCompleted(recipeInstanceId, sensoryEvent)
}

private val orderPlaced = OrderPlaced(
    orderId = "123",
    customerId = "456",
    productIds = listOf("iPhone", "PlayStation5"),
    address = Address(
        street = "Hoofdstraat",
        city = "Amsterdam",
        zipCode = "1234AA",
        country = "The Netherlands"
    )
)
package examples.scala.application

import cats.effect.{ContextShift, IO, Timer}
import com.ing.baker.compiler.RecipeCompiler
import examples.scala.events.OrderPlaced
import com.ing.baker.runtime.inmemory.InMemoryBaker
import com.ing.baker.runtime.model.InteractionInstance
import com.ing.baker.runtime.scaladsl.EventInstance
import examples.scala.ingredients.Address
import examples.scala.interactions.{CancelOrderImpl, CheckStockImpl, ShipOrderImpl}
import examples.scala.recipes.WebShopRecipe

import java.util.UUID
import scala.concurrent.ExecutionContext

class WebShopApp {
  def main(args: Array[String]): Unit = {

    implicit val timer: Timer[IO] = IO.timer(ExecutionContext.global)
    implicit val contextShift: ContextShift[IO] = IO.contextShift(ExecutionContext.global)

    val interactions = List(
      new CancelOrderImpl(),
      new ShipOrderImpl(),
      new CheckStockImpl(),
    )

    val bakerF = InMemoryBaker.build(implementations = interactions.map(InteractionInstance.unsafeFrom[IO]))
      .unsafeRunSync()

    val recipeInstanceId = UUID.randomUUID().toString
    val sensoryEvent = EventInstance.unsafeFrom(orderPlaced)

    for {
      recipeId <- bakerF.addRecipe(RecipeCompiler.compileRecipe(recipe = WebShopRecipe.recipe), validate = true)
      _ <- bakerF.bake(recipeId, recipeInstanceId)
      _ <- bakerF.fireEventAndResolveWhenCompleted(recipeInstanceId, sensoryEvent)

    } yield recipeId
  }

  private val orderPlaced = OrderPlaced(
    orderId = "123",
    customerId = "456",
    productIds = List("iPhone", "PlayStation5"),
    address = Address(
      street = "Hoofdstraat",
      city = "Amsterdam",
      zipCode = "1234AA",
      country = "The Netherlands"
    )
  )
}

Run the main function and observe the results. Depending on the outcome of the CheckStock interaction you will see one of these messages in the console:

Checking stock for order: 123 and products: [iPhone, PlayStation5]

Shipping order 123 to Address(street=Hoofdstraat, city=Amsterdam, zipCode=1234AA, country=The Netherlands)

Checking stock for order: 123 and products: [iPhone, PlayStation5]

Canceling order 123. The following products are unavailable: [iPhone, PlayStation5]

Wrap-up

Congratulations! You just build your first Baker process. Of course, this is just a simplified example. To learn more about what you can do with Baker, please refer to the cookbook section. There you will find information on things like error handling, testing recipes, creating visualizations, and more.