Actions as Controllers
Actions can be used to implement MVC Controllers by acting as the external interface of a service, receiving requests, operating over the requests values and forwarding the call to other components in the same service.
To illustrate how you can use an Action as a Controller, we will build on top of the Value Entity Shopping Cart example, adding a new Action to the existing shopping cart service.
Forwarding Commands
The forward
effect allows us to transform or further validate an incoming request before passing it on to another
component and have the response message directly passed back to the client making the request. The response from the
forwarded operation must have the same response type as the original request.
In this example we accept the same command as the entity, AddLineItem
, but add some additional verification of the
request and only conditionally forward the request to the entity if the verification is successful:
- Java
-
src/main/java/com/example/shoppingcart/ShoppingCartActionImpl.java
@Override public Effect<Empty> verifiedAddItem(ShoppingCartApi.AddLineItem addLineItem) { if (addLineItem.getName().equalsIgnoreCase("carrot")) { (1) return effects().error("Carrots no longer for sale"); (2) } else { DeferredCall<ShoppingCartApi.AddLineItem, Empty> call = components().shoppingCart().addItem(addLineItem); (3) return effects().forward(call); (4) } }
1 Check if the added item is carrots. 2 If it is "carrots" immediately return an error, disallowing adding the item. 3 For allowed requests, use components().shoppingCart().addItem()
to get aDeferredCall
.4 The DeferredCall
is used witheffects().forward()
to forward the request to the entity. - Scala
-
src/main/java/com/example/shoppingcart/ShoppingCartActionImpl.scala
override def verifiedAddItem(addLineItem: AddLineItem): Action.Effect[Empty]= if (addLineItem.name.equalsIgnoreCase("carrot")) (1) effects.error("Carrots no longer for sale") (2) else { val call = components.shoppingCart.addItem(addLineItem) (3) effects.forward(call) (4) }
1 Check if the added item is carrots. 2 If it is "carrots" immediately return an error, disallowing adding the item. 3 For allowed requests, use components.shoppingCart.addItem()
to get aDeferredCall
.4 The DeferredCall
is used witheffects.forward()
to forward the request to the entity.
Transform Request and Response to Another Component
The asyncReply
and asyncEffect
effects allow us to process and transform a request before calling another component and then also transform the response.
As an example, let us look at the problem of creating a new entity with an id generated by another component.
In this example we implement an Initialize
command for the controller Action which returns the message NewCartCreated
with the entity id that can subsequently be used to interact with the cart.
- Java
-
src/main/java/com/example/shoppingcart/ShoppingCartActionImpl.java
@Override public Effect<ShoppingCartController.NewCartCreated> initializeCart(ShoppingCartController.NewCart newCart) { final String cartId = UUID.randomUUID().toString(); (1) CompletionStage<Empty> shoppingCartCreated = components().shoppingCart().create(ShoppingCartApi.CreateCart.newBuilder().setCartId(cartId).build()) (2) .execute(); (3) // transform response CompletionStage<Effect<ShoppingCartController.NewCartCreated>> effect = shoppingCartCreated.handle((empty, error) -> { (4) if (error == null) { return effects().reply(ShoppingCartController.NewCartCreated.newBuilder().setCartId(cartId).build()); (5) } else { return effects().error("Failed to create cart, please retry"); (6) } }); return effects().asyncEffect(effect); (7) }
1 We generate a new UUID. 2 We use components().shoppingCart().create(…)
to create aDeferredCall
forcreate
on the shopping cart.3 execute()
on theDeferredCall
immediately triggers a call and returns aCompletionStage
for the response.4 Once the call succeeds or fails the CompletionStage
is completed or failed, we can transform the result fromCompletionStage<Empty>
toCompletionStage<Effect<NewCartCreated>>
usinghandle
.5 On a successful response, we create a reply effect with a NewCartCreated
.6 If the call leads to an error, we create an error effect asking the client to retry. 7 effects().asyncEffect()
allows us to reply with aCompletionStage<Effect<NewCartCreated>>
. - Scala
-
src/main/java/com/example/shoppingcart/ShoppingCartActionImpl.scala
override def initializeCart(newCart: NewCart): Action.Effect[NewCartCreated] = { val cartId = UUID.randomUUID().toString (1) val created: Future[Empty] = components.shoppingCart.create(CreateCart(cartId)).execute() (2) val effect: Future[Action.Effect[NewCartCreated]] = (3) created.map(_ => effects.reply(NewCartCreated(cartId))) (4) .recover(_ => effects.error("Failed to create cart, please retry")) (5) effects.asyncEffect(effect) (6) }
1 We generate a new UUID. 2 We use components.shoppingCart.create(…)
to create aDeferredCall
forcreate
on the shopping cart.3 execute()
on theDeferredCall
immediately triggers a call and returns aFuture
for the response.4 On a successful response, we map
theEmpty
reply to a reply effect with the replyNewCartCreated
.5 If the call leads to an error, we recover
and return an error effect asking the client to retry.6 effects.asyncEffect()
allows us to reply with aFuture[Effect[NewCartCreated]]
rather than a reply we already have created.
The action generates a UUID to use as entity id for the shopping cart. UUIDs are extremely unlikely to lead to the same id being generated, but to completely guarantee two calls can never be assigned the same shopping cart we make use of the "boundary of consistency" provided by the entity - the entity will only process a single command at a time and can safely make decisions based on its state - for example to only allow creation once by storing something in its state signifying that it has been created.
In this case we mark that the entity has been created using a creation timestamp in the shopping cart state stored on first
create
call - when the timestamp has the default value of 0
). If the cart has already been stored with a timestamp we return an error effect:
- Java
-
src/main/java/com/example/shoppingcart/domain/ShoppingCart.java
@Override public Effect<Empty> create(ShoppingCartDomain.Cart currentState, ShoppingCartApi.CreateCart createCart) { if (currentState.getCreationTimestamp() > 0L) { return effects().error("Cart was already created"); } else { return effects().updateState(currentState.toBuilder().setCreationTimestamp(Instant.now().toEpochMilli()).build()) .thenReply(Empty.getDefaultInstance()); } }
- Scala
-
src/main/java/com/example/shoppingcart/domain/ShoppingCart.scala
override def create(currentState: Cart, createCart: shoppingcart.CreateCart): ValueEntity.Effect[Empty] = if (currentState.creationTimestamp > 0) effects.error("Cart was already created") else effects.updateState(currentState.copy(creationTimestamp = Instant.now().toEpochMilli)) .thenReply(Empty.defaultInstance)
Composing calls
The async call shown in the previous section, can also be used to chain or compose multiple calls to a single action response.
In this example we build on the previous cart creation by adding an initial item in the cart once it has been created, but before we return the new id to the client:
- Java
-
src/main/java/com/example/shoppingcart/ShoppingCartActionImpl.java
@Override public Effect<ShoppingCartController.NewCartCreated> createPrePopulated(ShoppingCartController.NewCart newCart) { final String cartId = UUID.randomUUID().toString(); CompletionStage<Empty> shoppingCartCreated = components().shoppingCart().create(ShoppingCartApi.CreateCart.newBuilder().setCartId(cartId).build()) .execute(); CompletionStage<Empty> cartPopulated = shoppingCartCreated.thenCompose(empty -> { (1) ShoppingCartApi.AddLineItem initialItem = (2) ShoppingCartApi.AddLineItem.newBuilder() .setCartId(cartId) .setProductId("e") .setName("eggplant") .setQuantity(1) .build(); return components().shoppingCart().addItem(initialItem).execute(); (3) }); CompletionStage<ShoppingCartController.NewCartCreated> reply = cartPopulated.thenApply(empty -> (4) ShoppingCartController.NewCartCreated.newBuilder().setCartId(cartId).build() ); return effects().asyncReply(reply); (5) }
1 CompletionStage#thenCompose
allow us to perform an additional async operation, returning aCompletionStage
once the current one completes successfully.2 Create a request to add an initial item to the cart. 3 Executing the addItem
call returns aCompletionStage<Empty>
once it succeeds.4 handle
allows us to transform the successful completion ofaddItem
withEmpty
to the response type of this method -NewCartCreated
.5 effects().asyncReply()
lets us reply once theCompletionStage<NewCartCreated>
completes. - Scala
-
src/main/java/com/example/shoppingcart/ShoppingCartActionImpl.scala
def createPrePopulated(newCart: NewCart): Action.Effect[NewCartCreated] = { val cartId = UUID.randomUUID().toString val reply: Future[NewCartCreated] = for { (1) created <- components.shoppingCart.create(CreateCart(cartId)).execute() populated <- components.shoppingCart.addItem(AddLineItem(cartId, "e", "eggplant", 1)).execute() } yield NewCartCreated(cartId) (2) effects.asyncReply(reply) (3) }
1 For comprehensions (or directly using flatMap
) allow us to compose the individual async steps returningFuture
.2 Once both steps have completed, create a NewCartCreated
leading to aFuture[NewCartCreated]
coming out of the for-comprehension.3 effect.asyncReply
lets us reply once theFuture[NewCartCreated]
completes.
In this sample it is safe to base a subsequent call to the entity on the reply of the previous one, no client will know
of the cart id until createPrePopulated
replies.
For many other use cases it is important to understand that there is no transaction or consistency boundary outside of the entity, so for a sequence of calls from an action to an entity, the state of the entity could be updated by other calls it receives in-between.
For example, imagine an action that for a cart id retrieves the state using getState
to verify if too many items are
already in the cart, and once that has been verified, it adds the item to the cart.
- Java
-
src/main/java/com/example/shoppingcart/ShoppingCartActionImpl.java
@Override public Effect<Empty> unsafeValidation(ShoppingCartApi.AddLineItem addLineItem) { // NOTE: This is an example of an anti-pattern, do not copy this CompletionStage<ShoppingCartApi.Cart> cartReply = components().shoppingCart().getCart( ShoppingCartApi.GetShoppingCart.newBuilder() .setCartId(addLineItem.getCartId()) .build()) .execute(); (1) CompletionStage<Effect<Empty>> effect = cartReply.thenApply(cart -> { int totalCount = cart.getItemsList().stream() .mapToInt(ShoppingCartApi.LineItem::getQuantity) .sum(); if (totalCount < 10) { return effects().error("Max 10 items in a cart"); } else { return effects().forward(components().shoppingCart().addItem(addLineItem)); (2) } }); return effects().asyncEffect(effect); }
1 Between this call returning. 2 And this next call to the same entity, the entity could accept other commands that change the total count of items in the cart. - Scala
-
src/main/java/com/example/shoppingcart/ShoppingCartActionImpl.scala
override def unsafeValidation(addLineItem: AddLineItem): Action.Effect[Empty] = { // NOTE: This is an example of an anti-pattern, do not copy this val cartReply = components.shoppingCart.getCart(GetShoppingCart(addLineItem.cartId)) .execute() (1) val effect = cartReply.map { cart => val totalCount = cart.items.map(_.quantity).sum if (totalCount < 10) effects.error("Max 10 items in a cart") else effects.forward(components.shoppingCart.addItem(addLineItem)) (2) } effects.asyncEffect(effect) }
1 Between this call returning. 2 And this next call to the same entity, the entity could accept other commands that changes the total count of items in the cart.
The problem with this is that an addItem
call directly to the entity happening between the getState
action returning and the addItem
call from the action would lead to more items in the cart than the allowed limit.
Such validation depending on state can only safely be done handling the command inside of the entity.
Unit tests (with cross-component calls)
Testing an Action serving as a controller, or more generally, one that depends on calling other components, requires that a mock registry containing the mocks to be used be provided to TestKit. Later, at runtime, the TestKit will try to find the appropriate mock object it needs by matching those with the dependency component’s class type.
So, let’s say we want to test the previous example where we rely on 2 external
calls to create and populate the shopping cart before replying. A unit test for such action method would look like:
- Java
-
src/test/java/com/example/shoppingcart/ShoppingCartActionImplTest.java
public class ShoppingCartActionImplTest { @Mock private ShoppingCartService shoppingCartService; (1) @Test public void prePopulatedCartTest() throws ExecutionException, InterruptedException, TimeoutException { when(shoppingCartService.create(notNull())) (2) .thenReturn(CompletableFuture.completedFuture(Empty.getDefaultInstance())); when(shoppingCartService.addItem(any())) .thenReturn(CompletableFuture.completedFuture(Empty.getDefaultInstance())); var mockRegistry = MockRegistry.create().withMock(ShoppingCartService.class, shoppingCartService); (3) var service = ShoppingCartActionImplTestKit.of(ShoppingCartActionImpl::new, mockRegistry); (4) var result = service.createPrePopulated(NewCart.getDefaultInstance()).getAsyncResult(); var reply = ((CompletableFuture<ActionResult<NewCartCreated>>) result) .get(1, TimeUnit.SECONDS) .getReply(); // assertions go here } }
1 First step is to declare our mock object. In this example, shoppingCartService
is a@Mock
object by Mockito framework.2 We start by configuring our mock service how to reply to the two calls: create
andaddItem
.3 Then we use the TestKit-provided MockRegistry
to initialize and addshoppingCartService
to serve as a mock for class typeShoppingCartService
.4 Finally, we just need to pass the mockRegistry
while initializing theShoppingCartActionImplTestKit
and the TestKit will make sure to try to find our mock object when it needs. - Scala
-
src/main/test/com/example/shoppingcart/ShoppingCartActionImplSpec.scala
class ShoppingCartActionImplSpec extends AsyncWordSpec with Matchers with AsyncMockFactory { "ShoppingCartActionImpl" must { "create a prepopulated cart" in { val mockShoppingCart = stub[ShoppingCartService] (1) (mockShoppingCart.create _) (2) .when(*) .returns(Future.successful(Empty.defaultInstance)) (mockShoppingCart.addItem _) .when(where { li: AddLineItem => li.name == "eggplant"}) .returns(Future.successful(Empty.defaultInstance)) val mockRegistry = MockRegistry.withMock(mockShoppingCart) (3) val service = ShoppingCartActionImplTestKit(new ShoppingCartActionImpl(_), mockRegistry) (4) val cartId = service.createPrePopulated(NewCart.defaultInstance).asyncResult // assertions go here } } }
1 First step is to declare our mock object. In this case, shoppingCartService
is astub
object provided by ScalaMock framework.2 Then we configure our mock service how to reply to the two calls: create
andaddItem
.3 We use the TestKit-provided MockRegistry
to initialize and addshoppingCartService
to serve as a mock for class typeShoppingCartService
.4 Finally, we just need to pass the mockRegistry
while initializing theShoppingCartActionImplTestKit
and the TestKit will make sure to try to find our mock object when it needs.