Quickstart: Shopping Cart in TypeScript

Learn how to create a shopping cart in TypeScript, 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-typescript-protobuf
  2. Skip to Package and deploy your service.

Create the project structure and install dependencies

  1. From the command line, create a directory with the basic structure for your project using a template:

    npx @kalix-io/create-kalix-entity@latest shopping-cart --typescript --template basic
  2. Change into the project directory:

    cd shopping-cart
  3. Download and install project dependencies:

    npm install

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. Create a proto directory.

    mkdir proto
  2. Create a shopping_cart_api.proto file and save it in the proto directory.

  3. Add declarations for:

    • The protobuf syntax version, proto3.

    • The package name, shopping.cart.api.

    • Import google/api/annotations.proto, google/protobuf/empty.proto, and Kalix kalix/annotations.proto.

    proto/shopping_cart_api.proto
    syntax = "proto3";
    
    package shopping.cart.api;
    
    import "google/api/annotations.proto";
    import "google/protobuf/empty.proto";
    import "kalix/annotations.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.

    proto/shopping_cart_api.proto
    service ShoppingCart {
      option (kalix.codegen) = {
        event_sourced_entity: {
          name: "shopping.cart.domain.ShoppingCart"
          entity_type: "shopping-cart"
          state: "shopping.cart.domain.CartState"
          events: "shopping.cart.domain.ItemAdded"
          events: "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):

    proto/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 that are the requests to the shopping cart service:

    proto/shopping_cart_api.proto
    message AddLineItem {
      string cart_id = 1 [(kalix.field).entity_key = true];
      string product_id = 2;
      string name = 3;
      int32 quantity = 4;
    }
    
    message RemoveLineItem {
      string cart_id = 1 [(kalix.field).entity_key = true];
      string product_id = 2;
    }
    
    message GetShoppingCart {
      string cart_id = 1 [(kalix.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 proto directory.

  2. Add declarations for the proto syntax and domain package.

    proto/shopping_cart_domain.proto
    syntax = "proto3";
    
    package shopping.cart.domain;
  3. Add the CartState message with fields for entity data and the LineItem message that defines the compound line item:

    proto/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:

    message ItemAdded {
      LineItem item = 1;
    }
    
    message ItemRemoved {
      string productId = 1;
    }

Generate TypeScript implementation stubs

Run code generation to build TypeScript implementation stubs from your external API and domain model proto files.

npm run build

Implement shopping cart business logic

Fill in the implementation for the behavior of your shopping cart, which consists of Command Handlers and Event 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.

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

  1. Open src/shoppingcart.ts.

  2. Add imports for API and domain types, which will be used later:

    src/shoppingcart.ts
    import { api, domain } from "../lib/generated/shoppingcart";
  3. Fill in the implementation for the behavior of your shopping cart, which will be implemented by functions added below:

    src/shoppingcart.ts
    entity.setBehavior(state => ({
      commandHandlers: {
        AddItem: addItem,
        RemoveItem: removeItem,
        GetCart: getCart
      },
      eventHandlers: {
        ItemAdded: itemAdded,
        ItemRemoved: itemRemoved
      }
    }));
  4. Add the addItem function to handle requests to add items to a shopping cart.

    • This function will handle an incoming AddItem request, and emit an ItemAdded event.

    • The current state of the shopping cart is passed to the function but is not used.

    • It fails the command for an invalid quantity (needs to be at least one item).

    • Or it persists an ItemAdded event using the context emit effect.

    src/shoppingcart.ts
    function addItem(
      addLineItem: api.AddLineItem,
      _cartState: domain.CartState,
      context: ShoppingCart.CommandContext
    ): Reply<api.IEmpty> {
      if (addLineItem.quantity < 1) {
        return Reply.failure(
          `Quantity for item ${addLineItem.productId} must be at least one.`
        );
      } else {
        const itemAdded = ItemAdded.create({
          item: {
            productId: addLineItem.productId,
            name: addLineItem.name,
            quantity: addLineItem.quantity
          }
        });
        context.emit(itemAdded);
        return Reply.message({});
      }
    }
  5. Add the removeItem function to handle requests to remove items from a shopping cart.

    • This function will handle an incoming RemoveItem request, and emit an ItemRemoved event.

    • The current state of the shopping cart is passed and used to check the item exists.

    • It fails if the item to be removed is not found in the shopping cart.

    • If the item exists, it persists an ItemRemoved event using the context emit effect.

    src/shoppingcart.ts
    function removeItem(
      removeLineItem: api.RemoveLineItem,
      cartState: domain.CartState,
      context: ShoppingCart.CommandContext
    ): Reply<api.IEmpty> {
      const existing = (cartState.items ?? []).find(item =>
        item.productId === removeLineItem.productId
      );
    
      if (!existing) {
        const id = removeLineItem.productId;
        return Reply.failure(
          `Cannot remove item ${id} because it is not in the cart.`
        );
      } else {
        const itemRemoved = ItemRemoved.create({
          productId: removeLineItem.productId
        });
        context.emit(itemRemoved);
        return Reply.message({});
      }
    }
  6. Add the getCart function to handle requests to retrieve a shopping cart.

    • This function takes the current internal state and converts it to the external API model.

    • The conversion between the domain and the external API is straightforward, as they have the same fields.

    src/shoppingcart.ts
    function getCart(
      _getShoppingCart: api.GetShoppingCart,
      cartState: domain.CartState
    ): Reply<api.ICart> {
      // API and domain messages have the same fields so conversion is easy
      return Reply.message(cartState);
    }
  7. Add the itemAdded function to update the state for emitted ItemAdded events.

    • This function first checks for an existing line item for the newly added product.

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

    • Otherwise, the new item is added directly to the cart.

    • Finally, the updated cart state is returned.

    src/shoppingcart.ts
    function itemAdded(
      added: domain.ItemAdded,
      cart: domain.CartState
    ): domain.CartState {
      const existing = (cart.items ?? []).find(item =>
        item.productId === added.item?.productId
      );
    
      if (existing && existing.quantity) {
        existing.quantity += added.item?.quantity ?? 0;
      } else if (added.item) {
        if (!cart.items) cart.items = [];
        cart.items.push(added.item);
      }
    
      return cart;
    }
  8. Add the itemRemoved function to update the state for emitted ItemRemoved events.

    • This function removes an item from the cart, by filtering it from the cart items.

    • The updated cart state is then returned.

    src/shoppingcart.ts
    function itemRemoved(
      removed: domain.ItemRemoved,
      cart: domain.CartState
    ): domain.CartState {
      cart.items = (cart.items ?? []).filter(item =>
        item.productId !== removed.productId
      );
    
      return cart;
    }

The src/index.ts 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. Update the config.dockerImage setting in the package.json file with your container registry.

  3. Use the deploy script to build the container image, publish it to the container registry as configured in the package.json file, and then automatically deploy the service to Kalix using kalix:

    npm run deploy
  4. 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 shopping-cart --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 shopping-cart

Next steps