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.