Shopping Cart in Java/Protobuf

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

Before you begin

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

  1. Download the source code using the Kalix CLI: kalix quickstart download shopping-cart-java-protobuf

  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/kalix-jvm-sdk/main/samples/java-protobuf-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 Kalix kalix/annotations.proto.

      src/main/proto/shopping/cart/shopping_cart_api.proto
      syntax = "proto3";
      
      package shopping.cart.api;
      
      option java_outer_classname = "ShoppingCartApi";
      
      import "kalix/annotations.proto";
      import "google/api/annotations.proto";
      import "google/protobuf/empty.proto";
  4. Add the service endpoint. The service endpoint is annotated with kalix.codegen indicating we want to generate an Event Sourced Entity for this service.

    src/main/proto/shopping/cart/api/shopping_cart_api.proto
    service ShoppingCart {
      option (kalix.codegen) = {
        event_sourced_entity: {
          name: "shopping.cart.domain.ShoppingCartEntity"
          type_id: "eventsourced-shopping-cart"
          state: "shopping.cart.domain.CartState"
          events: [
            "shopping.cart.domain.ItemAdded",
            "shopping.cart.domain.ItemRemoved"]
        }
      };
    
      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 [(kalix.field).id = true];
      string product_id = 2;
      string name = 3;
      int32 quantity = 4;
    }
    
    message RemoveLineItem {
      string cart_id = 1 [(kalix.field).id = true];
      string product_id = 2;
    }
    
    message GetShoppingCart {
      string cart_id = 1 [(kalix.field).id = true];
    }

Define the domain model

The shopping_cart_domain.proto contains all the internal data objects (Entities). The Event Sourced Entity in this sample 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 and domain package.

    • The package name, shopping.cart.domain.

    • The Java outer classname, ShoppingCartDomain.

      src/main/proto/shopping/cart/domain/shopping_cart_domain.proto
      syntax = "proto3";
      
      package shopping.cart.domain;
      
      option java_outer_classname = "ShoppingCartDomain";
  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 sample:

    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 kalix.javasdk.eventsourcedentity.EventSourcedEntity;
    import kalix.javasdk.eventsourcedentity.EventSourcedEntity.Effect;
    import kalix.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 Kalix.

Package and deploy your service

To build and publish the container image and then deploy the service, follow these steps:

  1. If you haven’t done so yet, sign in to your Kalix account. If this is your first time using Kalix, this will let you register an account, create your first project, and set this project as the default.

    kalix auth login
  2. Use the deploy target to build the container image, publish it to the container registry as configured in the pom.xml file, and use the target kalix:deploy to automatically deploy the service to Kalix:

    mvn deploy kalix:deploy
    If you time stamp your image. For example, <dockerTag>${project.version}-${build.timestamp}</dockerTag> you must always run both targets in one pass, i.e. mvn deploy kalix:deploy. You cannot run mvn deploy first and then mvn kalix:deploy because they will have different timestamps and thus different `dockerTag`s. This makes it impossible to reference the image in the repository from the second target.
  3. You can verify the status of the deployed service using:

    kalix service list

Invoke your service

Once the service has started successfully, you can start a proxy locally to access the service:

kalix service proxy <service name> --grpcui

The --grpcui option also starts and opens a gRPC web UI for exploring and invoking the service (available at http://127.0.0.1:8080/ui/).

Or you can use command line gRPC or HTTP clients, such as grpcurl or curl, to invoke the service through the proxy at localhost:8080, using plaintext connections.

Items can be added to a shopping cart using the AddItem method on the ShoppingCart service, in the gRPC web UI, or with grpcurl:

grpcurl \
  -d '{
    "cart_id": "abc123",
    "product_id": "AAPL",
    "name": "Apples",
    "quantity": 42
  }' \
  --plaintext localhost:8080 \
  shopping.cart.api.ShoppingCart/AddItem

The GetCart method can be used to retrieve this cart, in the gRPC web UI, or with grpcurl:

grpcurl \
  -d '{"cart_id": "abc123"}' \
  --plaintext localhost:8080 \
  shopping.cart.api.ShoppingCart/GetCart

You can expose the service to the internet. A generated hostname will be returned from the expose command:

kalix service expose <service name>

Next steps