Quickstart: Shopping Cart in Java

Learn how to create a shopping cart in Java, package it into a container, and run it on Akka Serverless.

Before you begin

If you want to bypass writing code and jump straight to the deployment:

  1. Download the source code using the Akka Serverless CLI: akkasls quickstart download shopping-cart-java

  2. Skip to Package and deploy your service.

Writing the Shopping Cart

  1. From the command line, create a directory for your project.

    mkdir shoppingcart
  2. Change into the project directory.

    cd shoppingcart
  3. Download the pom.xml file

    curl -OL https://raw.githubusercontent.com/lightbend/akkaserverless-java-sdk/main/samples/java-shopping-cart-quickstart/pom.xml
  4. Update the dockerImage property (line 13 of the pom.xml file) with your container registry name.

Define the external API

The Shopping Cart service will store shopping carts for your customers, including the items in those carts. The shoppingcart_api.proto will contain the external API your clients will invoke.

  1. In your project, create a src/main/proto/shopping/cart/api and a src/main/proto/shopping/cart/domain directory.

    Linux or macOS
    mkdir -p ./src/main/proto/shopping/cart/api
    mkdir -p ./src/main/proto/shopping/cart/domain
    Windows 10+
    mkdir src/main/proto/shopping/cart/api
    mkdir src/main/proto/shopping/cart/domain
  2. Create a shopping_cart_api.proto file and save it in the src/main/proto/shopping/cart/api directory.

  3. Add declarations for:

    • The protobuf syntax version, proto3.

    • The package name, shopping.cart.api.

    • The required Java outer classname, ShoppingCartAPI. Messages defined in this file will be generated as inner classes.

    • Import google/protobuf/empty.proto and Akka Serverless akkaserverless/annotations.proto.

      src/main/proto/shopping/cart/shopping_cart_api.proto
      syntax = "proto3";
      
      package shopping.cart.api;
      
      option java_outer_classname = "ShoppingCartApi";
      
      import "akkaserverless/annotations.proto";
      import "google/api/annotations.proto";
      import "google/protobuf/empty.proto";
  4. Add the service endpoint

    src/main/proto/shopping/cart/api/shopping_cart_api.proto
    service ShoppingCart {
      option (akkaserverless.service) = {
        type: SERVICE_TYPE_ENTITY
        component: "shopping.cart.domain.ShoppingCartEntity"
      };
    
      rpc AddItem(AddLineItem) returns (google.protobuf.Empty) {
        option (google.api.http) = {
          post: "/cart/{cart_id}/items/add"
          body: "*"
        };
      }
      rpc RemoveItem(RemoveLineItem) returns (google.protobuf.Empty) {
        option (google.api.http).post = "/cart/{cart_id}/items/{product_id}/remove";
      }
      rpc GetCart(GetShoppingCart) returns (Cart) {
        option (google.api.http) = {
          get: "/carts/{cart_id}"
          additional_bindings: {
            get: "/carts/{cart_id}/items"
            response_body: "items"
          }
        };
      }
    }
  5. Add messages to define the fields that comprise a Cart object (and its compound LineItem)

    src/main/proto/shopping/cart/api/shopping_cart_api.proto
    message Cart {
      repeated LineItem items = 1;
    }
    
    message LineItem {
      string product_id = 1;
      string name = 2;
      int32 quantity = 3;
    }
  6. Add the messages to carry the arguments for the service calls:

    src/main/proto/shopping/cart/api/shopping_cart_api.proto
    message AddLineItem {
      string cart_id = 1 [(akkaserverless.field).entity_key = true];
      string product_id = 2;
      string name = 3;
      int32 quantity = 4;
    }
    
    message RemoveLineItem {
      string cart_id = 1 [(akkaserverless.field).entity_key = true];
      string product_id = 2;
    }
    
    message GetShoppingCart {
      string cart_id = 1 [(akkaserverless.field).entity_key = true];
    }

Define the domain model

The shopping_cart_domain.proto contains all the internal data objects (Entities). The Event Sourced Entity in this quickstart keeps all events sent for a specific shopping cart in a journal.

  1. Create a shopping_cart_domain.proto file and save it in the src/main/proto/shopping/cart/domain directory.

  2. Add declarations for the proto syntax, the Akka Serverless annotations, and package name

    src/main/proto/shopping/cart/domain/shopping_cart_domain.proto
    syntax = "proto3";
    
    package shopping.cart.domain;
    
    option java_outer_classname = "ShoppingCartDomain";
    
    import "akkaserverless/annotations.proto";
  3. Add the CartState message with fields for entity data and the LineItem message that defines the compound line item:

    src/main/proto/shopping/cart/domain/shopping_cart_domain.proto
    message CartState {
      repeated LineItem items = 1;
    }
    
    message LineItem {
      string productId = 1;
      string name = 2;
      int32 quantity = 3;
    }
  4. Event Sourced entities work based on events. Add the events that can occur in this quickstart:

    src/main/proto/shopping/cart/domain/shopping_cart_domain.proto
    message ItemAdded {
      LineItem item = 1;
    }
    
    message ItemRemoved {
      string productId = 1;
    }
  5. Run mvn compile from the project root directory to generate source classes in which you add business logic.

    mvn compile

Create command handlers

Command handlers, as the name suggests, handle incoming API requests. State is not updated directly by command handlers. Instead, if state should be updated, an event is persisted that describes the intended transaction.

  1. Open src/main/java/shopping/cart/domain/ShoppingCartEntity.java for editing.

  2. Add some imports that are needed later:

    src/main/java/shopping/cart/domain/ShoppingCartEntity.java
    import com.akkaserverless.javasdk.eventsourcedentity.EventSourcedEntity;
    import com.akkaserverless.javasdk.eventsourcedentity.EventSourcedEntity.Effect;
    import com.akkaserverless.javasdk.eventsourcedentity.EventSourcedEntityContext;
    import com.google.protobuf.Empty;
    import shopping.cart.api.ShoppingCartApi;
    
    import java.util.Comparator;
    import java.util.List;
    import java.util.Map;
    import java.util.Optional;
    import java.util.function.Predicate;
    import java.util.stream.Collectors;
  3. Modify the emptyState method to return the initial state for the entity. The method should look like this:

    src/main/java/shopping/cart/domain/ShoppingCartEntity.java
      @Override
      public ShoppingCartDomain.CartState emptyState() {
        return ShoppingCartDomain.CartState.getDefaultInstance();
      }
  4. Modify the addItem method by adding the logic to handle the command. The complete method should include the following:

    src/main/java/shopping/cart/domain/ShoppingCartEntity.java
      @Override
      public Effect<Empty> addItem(ShoppingCartDomain.CartState currentState, ShoppingCartApi.AddLineItem addLineItem) {
        if (addLineItem.getQuantity() <= 0) {
          return effects().error("Quantity for item " + addLineItem.getProductId() + " must be greater than zero.");
        }
        ShoppingCartDomain.ItemAdded itemAddedEvent =
                ShoppingCartDomain.ItemAdded.newBuilder()
                        .setItem(
                                ShoppingCartDomain.LineItem.newBuilder()
                                        .setProductId(addLineItem.getProductId())
                                        .setName(addLineItem.getName())
                                        .setQuantity(addLineItem.getQuantity())
                                        .build())
                        .build();
        return effects().emitEvent(itemAddedEvent).thenReply(__ -> Empty.getDefaultInstance());
      }
    • This method will handle an incoming API request. It gets passed the current state and the request argument.

    • It checks the input parameters and fails using an error effect if the precondition fails.

    • Otherwise, it creates an ItemAdded event that is persisted by using the emitEvent effect.

  5. Modify the getCart method as follows to handle the GetShoppingCart command:

    src/main/java/shopping/cart/domain/ShoppingCartEntity.java
    @Override
    public Effect<ShoppingCartApi.Cart> getCart(
            ShoppingCartDomain.CartState currentState, (1)
            ShoppingCartApi.GetShoppingCart command) {
      List<ShoppingCartApi.LineItem> apiItems =
              currentState.getItemsList().stream()
                      .map(this::convert)
                      .sorted(Comparator.comparing(ShoppingCartApi.LineItem::getProductId))
                      .collect(Collectors.toList());
      ShoppingCartApi.Cart apiCart =
              ShoppingCartApi.Cart.newBuilder().addAllItems(apiItems).build(); (2)
      return effects().reply(apiCart);
    }
    
    private ShoppingCartApi.LineItem convert(ShoppingCartDomain.LineItem item) {
      return ShoppingCartApi.LineItem.newBuilder()
              .setProductId(item.getProductId())
              .setName(item.getName())
              .setQuantity(item.getQuantity())
              .build();
    }
    • The method takes the current internal state and converts it to the API model.

    • Each LineItem in the state is converted to its corresponding API form using the convert method.

  6. Modify the removeItem method by adding the logic to handle removing an item. The complete method should include the following:

    src/main/java/shopping/cart/domain/ShoppingCartEntity.java
      @Override
      public Effect<Empty> removeItem(
              ShoppingCartDomain.CartState currentState,
              ShoppingCartApi.RemoveLineItem command) {
        if (findItemByProductId(currentState, command.getProductId()).isEmpty()) {
          return effects()
                  .error(
                          "Cannot remove item " + command.getProductId() + " because it is not in the cart.");
        }
    
        ShoppingCartDomain.ItemRemoved event =
                ShoppingCartDomain.ItemRemoved.newBuilder().setProductId(command.getProductId()).build();
    
        return effects()
                .emitEvent(event)
                .thenReply(newState -> Empty.getDefaultInstance());
      }
      private Optional<ShoppingCartDomain.LineItem> findItemByProductId(
              ShoppingCartDomain.CartState cart, String productId) {
        Predicate<ShoppingCartDomain.LineItem> lineItemExists =
                lineItem -> lineItem.getProductId().equals(productId);
        return cart.getItemsList().stream().filter(lineItemExists).findFirst();
      }
    • This method will handle removing an item from the shopping cart. It will first check the precondition whether the requested item can be currently found in the shopping cart. If not, the API call returns an error effect.

    • If the item is found, the handler creates an ItemRemoved event that is persisted by using the emitEvent effect.

Create event handlers

Event handlers maintain the state of an entity by sequentially applying the effects of events to the local state.

  1. Modify the itemAdded event handling method by adding the logic to apply the event to the state:

    src/main/java/shopping/cart/domain/ShoppingCartEntity.java
    @Override
    public ShoppingCartDomain.CartState itemAdded(ShoppingCartDomain.CartState currentState, ShoppingCartDomain.ItemAdded itemAdded) {
      Map<String, ShoppingCartApi.LineItem> cart = domainCartToMap(currentState);
      ShoppingCartApi.LineItem item = cart.get(itemAdded.getItem().getProductId());
      if (item == null) {
        item = domainItemToApi(itemAdded.getItem());
      } else {
        item =
                item.toBuilder()
                        .setQuantity(item.getQuantity() + itemAdded.getItem().getQuantity())
                        .build();
      }
      cart.put(item.getProductId(), item);
      return mapToDomainCart(cart);
    }
    
    private ShoppingCartApi.LineItem domainItemToApi(ShoppingCartDomain.LineItem item) {
      return ShoppingCartApi.LineItem.newBuilder()
              .setProductId(item.getProductId())
              .setName(item.getName())
              .setQuantity(item.getQuantity())
              .build();
    }
    private Map<String, ShoppingCartApi.LineItem> domainCartToMap(ShoppingCartDomain.CartState state) {
      return state.getItemsList().stream().collect(Collectors.toMap(ShoppingCartDomain.LineItem::getProductId, this::domainItemToApi));
    }
    private ShoppingCartDomain.CartState mapToDomainCart(Map<String, ShoppingCartApi.LineItem> cart) {
      return ShoppingCartDomain.CartState.newBuilder()
              .addAllItems(cart.values().stream().map(this::apiItemToDomain).collect(Collectors.toList()))
              .build();
    }
    private ShoppingCartDomain.LineItem apiItemToDomain(ShoppingCartApi.LineItem item) {
      return ShoppingCartDomain.LineItem.newBuilder()
              .setProductId(item.getProductId())
              .setName(item.getName())
              .setQuantity(item.getQuantity())
              .build();
    }
    • First, the method looks for an existing line item for the newly added product.

    • If an existing item is found, its quantity is adjusted.

    • Otherwise, the new item can be directly added to the cart (after conversion from API to domain types)

    • Finally, the new cart state is returned.

    • Several helper methods convert between API and domain types and help with management of state.

  2. Modify the itemRemoved event handling method by adding the logic to apply the event to the state:

    src/main/java/shopping/cart/domain/ShoppingCartEntity.java
    @Override
    public ShoppingCartDomain.CartState itemRemoved(
            ShoppingCartDomain.CartState currentState,
            ShoppingCartDomain.ItemRemoved itemRemoved) {
      List<ShoppingCartDomain.LineItem> items =
              removeItemByProductId(currentState, itemRemoved.getProductId());
      items.sort(Comparator.comparing(ShoppingCartDomain.LineItem::getProductId));
      return ShoppingCartDomain.CartState.newBuilder().addAllItems(items).build();
    }
    private List<ShoppingCartDomain.LineItem> removeItemByProductId(
            ShoppingCartDomain.CartState cart, String productId) {
      return cart.getItemsList().stream()
              .filter(lineItem -> !lineItem.getProductId().equals(productId))
              .collect(Collectors.toList());
    }
    • The method removes the given product from the cart and returns the new state.

The src/main/java/shopping/cart/Main.java file already contains the required code to start your service and register it with Akka Serverless.

Package and deploy your service

To compile, build the container image, and publish it to your container registry, follow these steps

  1. From the root project directory, compile the source code using Maven:

    mvn compile
  2. Use the deploy target to build the container image and publish it to your container registry. At the end of this command Maven will show you the container image URL you’ll need in the next part of this quickstart.

    mvn deploy
  3. Sign in to your Akka Serverless account at: https://console.akkaserverless.com/

  4. If you do not have a project, click Add Project to create one, otherwise choose the project you want to deploy your service to.

  5. On the project dashboard click the "+" next to services to start the deployment wizard

  6. Choose a name for your service and click Next

  7. Enter the container image URL from the above step and click Next

  8. Click Next (no environment variables are needed for these samples)

  9. Check both Add a route to this service and Enable CORS and click Next

  10. Click Finish to start the deployment

  11. Click Go to Service to see your newly deployed service

Invoke your service

Now that you have deployed your service, the next step is to invoke it using gRPCurl

  1. From the "Service Explorer" click on the method you want to invoke

  2. Click on "gRPCurl"

  3. In the bottom section of the dialog, fill in the values you want to send to your service

  4. In the top section of the dialog, click the "Copy to clipboard" button

  5. Open a new command line and paste the content you just copied