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’re new to Kalix, create an account so you can try out Kalix for free.
-
You’ll need to install the Kalix CLI to deploy from a terminal window.
-
You’ll also need
-
Java 11 or higher
If you want to bypass writing code and jump straight to the deployment:
|
Writing the Shopping Cart
-
From the command line, create a directory for your project.
mkdir shoppingcart
-
Change into the project directory.
cd shoppingcart
-
Download the
pom.xml
filecurl -OL https://raw.githubusercontent.com/lightbend/kalix-jvm-sdk/main/samples/java-protobuf-shopping-cart-quickstart/pom.xml
-
Update the
dockerImage
property (line 13 of thepom.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.
-
In your project, create a
src/main/proto/shopping/cart/api
and asrc/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
-
Create a
shopping_cart_api.proto
file and save it in thesrc/main/proto/shopping/cart/api
directory. -
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 Kalixkalix/annotations.proto
.src/main/proto/shopping/cart/shopping_cart_api.protosyntax = "proto3"; package shopping.cart.api; option java_outer_classname = "ShoppingCartApi"; import "kalix/annotations.proto"; import "google/api/annotations.proto"; import "google/protobuf/empty.proto";
-
-
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.protoservice 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" } }; } }
-
Add messages to define the fields that comprise a
Cart
object (and its compoundLineItem
)src/main/proto/shopping/cart/api/shopping_cart_api.protomessage Cart { repeated LineItem items = 1; } message LineItem { string product_id = 1; string name = 2; int32 quantity = 3; }
-
Add the messages to carry the arguments for the service calls:
src/main/proto/shopping/cart/api/shopping_cart_api.protomessage 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.
-
Create a
shopping_cart_domain.proto
file and save it in thesrc/main/proto/shopping/cart/domain
directory. -
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.protosyntax = "proto3"; package shopping.cart.domain; option java_outer_classname = "ShoppingCartDomain";
-
-
Add the
CartState
message with fields for entity data and theLineItem
message that defines the compound line item:src/main/proto/shopping/cart/domain/shopping_cart_domain.protomessage CartState { repeated LineItem items = 1; } message LineItem { string productId = 1; string name = 2; int32 quantity = 3; }
-
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.protomessage ItemAdded { LineItem item = 1; } message ItemRemoved { string productId = 1; }
-
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.
-
Open
src/main/java/shopping/cart/domain/ShoppingCartEntity.java
for editing. -
Add some imports that are needed later:
src/main/java/shopping/cart/domain/ShoppingCartEntity.javaimport 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;
-
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(); }
-
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 theemitEvent
effect.
-
-
Modify the
getCart
method as follows to handle theGetShoppingCart
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 theconvert
method.
-
-
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 theemitEvent
effect.
-
Create event handlers
Event handlers maintain the state of an entity by sequentially applying the effects of events to the local state.
-
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.
-
-
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 |
Package and deploy your service
To build and publish the container image and then deploy the service, follow these steps:
-
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
-
Use the
deploy
target to build the container image, publish it to the container registry as configured in thepom.xml
file, and use the targetkalix: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 runmvn deploy
first and thenmvn 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. -
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
-
You can learn more about Event Sourced Entities.