Quickstart: Shopping Cart in JavaScript

Learn how to create a shopping cart in JavaScript, 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, take a look at our prebuilt samples.

Install Kalix SDK and dependencies

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

    mkdir shoppingcart
  2. Change into the project directory.

    cd shoppingcart
  3. Run the npm init command, accepting default values

    npm init -y
  4. Install the Kalix JavaScript SDK and dependencies:

    npm install @kalix-io/kalix-javascript-sdk --save
  5. Add these additional scripts to the scripts property in your package.json

"scripts": {
    "start": "node src/index.js",
    "test": "mocha ./test",
    "integration-test": "mocha ./integration-test",
    "test-all": "npm run test && npm run integration-test",
    "build": "kalix-scripts build",
    "package": "kalix-scripts package",
    "deploy": "kalix-scripts deploy"
}
  1. Add these additional configuration options to your package.json

"config": {
    "dockerImage": "gcr.io/kalix-public/samples-js-event-sourced-shopping-cart",
    "sourceDir": "./src",
    "testSourceDir": "./test",
    "protoSourceDir": "./proto",
    "generatedSourceDir": "./lib/generated",
    "compileDescriptorArgs": []
}

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

    Linux or macOS
    mkdir -p ./proto
    Windows 10+
    mkdir proto
  2. Create a shoppingcart_api.proto file and save it in the proto directory of your project.

  3. Add declarations for:

    • The protobuf syntax version, proto3.

    • The package name, com.example.shoppingcart.

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

      syntax = "proto3";
      
      package com.example.shoppingcart;
      
      import "google/api/annotations.proto";
      import "google/protobuf/empty.proto";
      import "kalix/annotations.proto";
  4. Add the service endpoint

    service ShoppingCartService {
      option (kalix.service) = {
        type: SERVICE_TYPE_ENTITY
        component: ".domain.ShoppingCart"
      };
    
      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}"
        };
      }
    }

Each of the API endpoints will be available over HTTP and gRPC

  1. Add messages to define the fields that comprise a Cart object (and its compound LineItem)

    message Cart {
      repeated LineItem items = 1;
    }
    
    message LineItem {
      string product_id = 1;
      string name = 2;
      int32 quantity = 3;
    }
  2. Add the messages that are the requests to the shopping cart service:

    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 shoppingcart_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 shoppingcart_domain.proto file and save it in the proto directory of your project.

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

    syntax = "proto3";
    
    package com.example.shoppingcart.domain;
    
    import "kalix/annotations.proto";
  3. Add the Cart message with fields for entity data and the LineItem message that defines the compound line item:

    // The shopping cart state.
    message Cart {
      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:

    // The item added event.
    message ItemAdded {
      LineItem item = 1;
    }
    
    // The item removed event.
    message ItemRemoved {
      string productId = 1;
    }

Implement your business logic

  1. Build and generate JavaScript sources

    npm install
    npm run build
  2. In your project, create a src directory.

    Linux or macOS
    mkdir -p ./src
    Windows 10+
    mkdir src
  3. Create a shoppingcart.js file and save it in the src directory of your project.

  4. Add the import statements for the JavaScript SDK to shoppingcart.js

    import { EventSourcedEntity } from "@kalix-io/kalix-javascript-sdk";
  5. Create the EventSourcedEntity object

    /*
     * Copyright 2021 Lightbend Inc.
     *
     * Licensed under the Apache License, Version 2.0 (the "License");
     * you may not use this file except in compliance with the License.
     * You may obtain a copy of the License at
     *
     *     http://www.apache.org/licenses/LICENSE-2.0
     *
     * Unless required by applicable law or agreed to in writing, software
     * distributed under the License is distributed on an "AS IS" BASIS,
     * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     * See the License for the specific language governing permissions and
     * limitations under the License.
     */
    // tag::imports[]
    import { EventSourcedEntity } from "@kalix-io/kalix-javascript-sdk";
    // end::imports[]
    /**
     * Type definitions.
     * These types have been generated based on your proto source.
     * A TypeScript aware editor such as VS Code will be able to leverage them to provide hinting and validation.
     *
     * ShoppingCartService; a strongly typed extension of EventSourcedEntity derived from your proto source
     * @typedef { import("../lib/generated/shoppingcart").ShoppingCartService } ShoppingCartService
     * @typedef { import("../lib/generated/shoppingcart").ShoppingCartService.CommandHandlers } CommandHandlers
     * @typedef { import("../lib/generated/shoppingcart").ShoppingCartService.EventHandlers } EventHandlers
     */
    
    /**
     * @type ShoppingCartService
     */
    // tag::esentity[]
    const entity = new EventSourcedEntity(
      [
        "shoppingcart_domain.proto",
        "shoppingcart_api.proto"
      ],
      "com.example.shoppingcart.ShoppingCartService",
      "eventsourced-shopping-cart",
      {
        includeDirs: ["./proto"]
      }
    );
    // end::esentity[]
    /*
     * Here we load the Protobuf types. When emitting events or setting state, we need to return
     * protobuf message objects, not just ordinary JavaScript objects, so that the framework can
     * know how to serialize these objects when they are persisted.
     *
     * Note this shows loading them dynamically, they could also be compiled and statically loaded.
     */
    // tag::espersistence[]
    const pkg = "com.example.shoppingcart.domain.";
    const ItemAdded = entity.lookupType(pkg + "ItemAdded");
    const ItemRemoved = entity.lookupType(pkg + "ItemRemoved");
    const Cart = entity.lookupType(pkg + "Cart");
    // end::espersistence[]
    /*
     * Set a callback to create the initial state. This is what is created if there is no
     * snapshot to load.
     *
     * We can ignore the cartId parameter if we want, it's the id of the entity, which is
     * automatically associated with all events and state for this entity.
     */
    // tag::initialstate[]
    entity.setInitial(cartId => Cart.create({items: []}));
    // end::initialstate[]
    // tag::behavior[]
    entity.setBehavior(cart => {
      return {
        // Command handlers. The name of the command corresponds to the name of the rpc call in
        // the gRPC service that this entity offers.
        commandHandlers: {
          AddItem: addItem,
          RemoveItem: removeItem,
          GetCart: getCart
        },
        // Event handlers. The name of the event corresponds to the (unqualified) name of the
        // persisted protobuf message.
        eventHandlers: {
          ItemAdded: itemAdded,
          ItemRemoved: itemRemoved
        }
      };
    });
    // end::behavior[]
    
    /**
     * Handler for add item commands.
     *
     * @type CommandHandlers['AddItem']
     */
    // tag::additem[]
    function addItem(addItem, _cart, ctx) {
      // Validation:
      // Make sure that it is not possible to add negative quantities
      if (addItem.quantity < 1) {
        ctx.fail("Quantity for item " + addItem.productId + " must be greater than zero.");
      } else {
        // Create the event.
        const itemAdded = ItemAdded.create({
          item: {
            productId: addItem.productId,
            name: addItem.name,
            quantity: addItem.quantity
          }
        });
        // Emit the event.
        ctx.emit(itemAdded);
        return {};
      }
    }
    // end::additem[]
    /**
     * Handler for remove item commands.
     *
     * @type CommandHandlers['RemoveItem']
     */
    // tag::removeitem[]
    function removeItem(removeItem, cart, ctx) {
      // Validation:
      // Check that the item that we're removing actually exists.
      const existing = cart.items.find(item => {
        return item.productId === removeItem.productId;
      });
    
      // If not, fail the command.
      if (!existing) {
        ctx.fail("Item " + removeItem.productId + " not in cart");
      } else {
        // Otherwise, emit an item removed event.
        const itemRemoved = ItemRemoved.create({
          productId: removeItem.productId
        });
        ctx.emit(itemRemoved);
        return {};
      }
    }
    // end::removeitem[]
    /**
     * Handler for get cart commands.
     *
     * @type CommandHandlers['GetCart']
     */
    // tag::getcart[]
    function getCart(_request, cart) {
      // Simply return the shopping cart as is.
      return cart;
    }
    // end::getcart[]
    /**
     * Handler for item added events.
     *
     * @type EventHandlers['ItemAdded']
     */
    // tag::itemadded[]
    function itemAdded(added, cart) {
      // If there is an existing item with that product id, we need to increment its quantity.
      const existing = cart.items.find(item => {
        return item.productId === added.item.productId;
      });
    
      if (existing) {
        existing.quantity = existing.quantity + added.item.quantity;
      } else {
        // Otherwise, we just add the item to the existing list.
        cart.items.push(added.item);
      }
    
      // And return the new state.
      return cart;
    }
    // end::itemadded[]
    /**
     * Handler for item removed events.
     *
     * @type EventHandlers['ItemRemoved']
     */
    // tag::itemremoved[]
    function itemRemoved(removed, cart) {
      // Filter the removed item from the items by product id.
      cart.items = cart.items.filter(item => {
        return item.productId !== removed.productId;
      });
    
      // And return the new state.
      return cart;
    }
    // end::itemremoved[]
    
    // tag::export[]
    export default entity;
    // end::export[]
  6. Create objects for the internal and external representations of your carts

    const pkg = "com.example.shoppingcart.domain.";
    const ItemAdded = entity.lookupType(pkg + "ItemAdded");
    const ItemRemoved = entity.lookupType(pkg + "ItemRemoved");
    const Cart = entity.lookupType(pkg + "Cart");
  7. Create the "initial state" for the entities (this method is called when no other data can be found for your entity)

    entity.setInitial(cartId => Cart.create({items: []}));
  8. Create the behavior for your shopping cart, which consists of Command Handlers and Event Handlers.

    • Command Handlers, as the name suggests, handle incoming requests before persisting them as events.

    • Event Handlers, react to persisted events and modify the state of your cart.

      entity.setBehavior(cart => {
        return {
          // Command handlers. The name of the command corresponds to the name of the rpc call in
          // the gRPC service that this entity offers.
          commandHandlers: {
            AddItem: addItem,
            RemoveItem: removeItem,
            GetCart: getCart
          },
          // Event handlers. The name of the event corresponds to the (unqualified) name of the
          // persisted protobuf message.
          eventHandlers: {
            ItemAdded: itemAdded,
            ItemRemoved: itemRemoved
          }
        };
      });
  9. Add the addItem method to handle requests adding items to a shopping cart (this method emits an ItemAddedEvent event to modify the state)

    function addItem(addItem, _cart, ctx) {
      // Validation:
      // Make sure that it is not possible to add negative quantities
      if (addItem.quantity < 1) {
        ctx.fail("Quantity for item " + addItem.productId + " must be greater than zero.");
      } else {
        // Create the event.
        const itemAdded = ItemAdded.create({
          item: {
            productId: addItem.productId,
            name: addItem.name,
            quantity: addItem.quantity
          }
        });
        // Emit the event.
        ctx.emit(itemAdded);
        return {};
      }
    }
  10. Add the removeItem method to handle requests removing items from a shopping cart (this method emits an ItemRemovedEvent event to modify the state)

    function removeItem(removeItem, cart, ctx) {
      // Validation:
      // Check that the item that we're removing actually exists.
      const existing = cart.items.find(item => {
        return item.productId === removeItem.productId;
      });
    
      // If not, fail the command.
      if (!existing) {
        ctx.fail("Item " + removeItem.productId + " not in cart");
      } else {
        // Otherwise, emit an item removed event.
        const itemRemoved = ItemRemoved.create({
          productId: removeItem.productId
        });
        ctx.emit(itemRemoved);
        return {};
      }
    }
  11. Add the getCart method to handle requests to get a shopping cart

    function getCart(_request, cart) {
      // Simply return the shopping cart as is.
      return cart;
    }
  12. Add the itemAdded method to handle ItemAddedEvent events (this modifies the state of the cart)

    function itemAdded(added, cart) {
      // If there is an existing item with that product id, we need to increment its quantity.
      const existing = cart.items.find(item => {
        return item.productId === added.item.productId;
      });
    
      if (existing) {
        existing.quantity = existing.quantity + added.item.quantity;
      } else {
        // Otherwise, we just add the item to the existing list.
        cart.items.push(added.item);
      }
    
      // And return the new state.
      return cart;
    }
  13. Add the itemRemoved method to handle ItemRemovedEvent events (this modifies the state of the cart)

    function itemRemoved(removed, cart) {
      // Filter the removed item from the items by product id.
      cart.items = cart.items.filter(item => {
        return item.productId !== removed.productId;
      });
    
      // And return the new state.
      return cart;
    }
  14. At the bottom of the file, add an export statement so the index.js file can access your module

    export default entity;

Create the index.js file

  1. Create a index.js file and save it in the src directory of your project.

  2. Add the import statement for the JavaScript SDK to index.js

    import { Kalix } from "@kalix-io/kalix-javascript-sdk";
    import generatedComponents from "../lib/generated/index.js";
  3. Register and start the components

    const server = new Kalix();
    
    generatedComponents.forEach((component) => {
      server.addComponent(component);
    });
    
    server.start();

Package and deploy your service

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

  1. Create a dockerfile in the root directory of your project

    # This Dockerfile uses multi-stage build process.
    # See https://docs.docker.com/develop/develop-images/multistage-build/
    
    # Stage 1: Downloading dependencies and building the application
    FROM node:14.17.0-buster-slim AS builder
    
    RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
    
    # Set the working directory
    WORKDIR /home/node
    
    # Install app dependencies
    COPY package*.json ./
    RUN npm ci
    
    # Copy sources and build the app
    COPY --chown=node . .
    RUN npm run build
    
    # Remove dev packages
    # (the rest will be copied to the production image at stage 2)
    RUN npm prune --production
    
    # Stage 2: Building the production image
    FROM node:14.17.0-buster-slim
    
    # Set the working directory
    WORKDIR /home/node
    
    # Copy dependencies
    COPY --from=builder --chown=node /home/node/node_modules node_modules/
    
    # Copy the app
    COPY --from=builder --chown=node \
        /home/node/package*.json \
        /home/node/user-function.desc \
        ./
    COPY --from=builder --chown=node /home/node/proto ./proto
    COPY --from=builder --chown=node /home/node/src ./src
    COPY --from=builder --chown=node /home/node/lib ./lib
    
    # Run the app as an unprivileged user for extra security.
    USER node
    
    # Run
    EXPOSE 8080
    CMD ["npm", "start"]
  2. Run the docker build command to build your container image

    docker build . -t <your container registry>/<your registry username>/<your projectname>

When you’re using Docker Hub, you only need to specify <your registry username>/<your projectname> (like myuser/myproject)

  1. Run the docker push command to push the container image to a container registry

    docker push <your container registry>/<your registry username>/<your projectname>
  2. Run the kalix services deploy command to deploy your service using the container image from the container registry.

    kalix services deploy <name> <your container registry>/<your registry username>/<your projectname>
  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