Customer Registry in Scala/Protobuf

Learn how to create a customer registry in Scala, 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 customer-registry-scala-protobuf

  2. Skip to Package and deploy your service.

Writing the Customer Registry

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

    mkdir customerregistry
  2. Change into the project directory.

    cd customerregistry
  3. Download the build.sbt file

    curl -OL https://raw.githubusercontent.com/lightbend/kalix-jvm-sdk/main/samples/scala-protobuf-customer-registry-quickstart/build.sbt
  4. Create the sbt project directory

    mkdir project
  5. Download the plugins.sbt

    curl -L https://raw.githubusercontent.com/lightbend/kalix-jvm-sdk/main/samples/scala-protobuf-customer-registry-quickstart/project/plugins.sbt -o project/plugins.sbt

Define the external API

The Customer Registry service will create or retrieve a customer, including their email, phone number and mailing address. The customer_api.proto will contain the external API your clients will invoke.

  1. In your project, create two directories for you protobuf files, src/main/proto/customer/domain and src/main/proto/customer/api.

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

  3. Add declarations for:

    • The protobuf syntax version, proto3.

    • The package name, customer.api.

    • Import google/protobuf/empty.proto.

      src/main/proto/customer/api/customer_api.proto
      syntax = "proto3";
      
      package customer.api;
      
      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 a Value Entity for this service.

    src/main/proto/customer/api/customer_api.proto
    service CustomerService {
      option (kalix.codegen) = {
        value_entity: {
          name: "customer.domain.Customer"
          type_id: "customers"
          state: "customer.domain.CustomerState"
        }
      };
    
      rpc Create(Customer) returns (google.protobuf.Empty) {}
    
      rpc GetCustomer(GetCustomerRequest) returns (Customer) {}
    
    }
  5. Add messages to define the fields that comprise a Customer object (and its compound Address)

    src/main/proto/customer/api/customer_api.proto
    message Customer {
      string customer_id = 1 [(kalix.field).id = true];
      string email = 2;
      string name = 3;
      Address address = 4;
    }
    
    message Address {
      string street = 1;
      string city = 2;
    }
  6. Add the message that will identify which customer to retrieve for the GetCustomer message:

    src/main/proto/customer/api/customer_api.proto
    message GetCustomerRequest {
      string customer_id = 1 [(kalix.field).id = true];
    }

Define the domain model

The customer_domain.proto contains all the internal data objects (Entities). The Value Entity in this sample is a Key/Value store that stores only the latest updates.

  1. Create a customer_domain.proto file and save it in the src/main/proto/customer/domain directory.

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

    src/main/proto/customer/domain/customer_domain.proto
    syntax = "proto3";
    
    package customer.domain;
  3. Add the CustomerState message with fields for entity data, and the Address message that defines the compound address:

    src/main/proto/customer/domain/customer_domain.proto
    message CustomerState {
      string customer_id = 1;
      string email = 2;
      string name = 3;
      Address address = 4;
    }
    
    message Address {
      string street = 1;
      string city = 2;
    }
  4. Run sbt compile from the project root directory to generate source classes in which you add business logic.

    sbt compile

Create command handlers

Command handlers, as the name suggests, handle incoming requests before persisting them.

  1. If it’s not open already, open src/main/scala/customer/domain/Customer.scala for editing.

  2. Modify the create method by adding the logic to handle the command. The complete method should include the following:

    src/main/scala/customer/domain/Customer.scala
      override def create(currentState: CustomerState, customer: api.Customer): ValueEntity.Effect[Empty] = {
        val state = convertToDomain(customer)
        effects.updateState(state).thenReply(Empty.defaultInstance)
      }
    
      def convertToDomain(customer: api.Customer): CustomerState =
        CustomerState(
          customerId = customer.customerId,
          email = customer.email,
          name = customer.name,
          address = customer.address.map(convertToDomain)
        )
    
      def convertToDomain(address: api.Address): Address =
        Address(
          street = address.street,
          city = address.city
        )
    • The incoming message contains the request data from your client and the command handler updates the state of the customer.

    • The convertToDomain methods convert the incoming request to your domain model.

  3. Modify the getCustomer method as follows to handle the GetCustomerRequest command:

    src/main/scala/customer/domain/Customer.scala
    override def getCustomer(currentState: CustomerState, getCustomerRequest: api.GetCustomerRequest): ValueEntity.Effect[api.Customer] =
      if (currentState.customerId == "") {
        effects.error(s"Customer ${getCustomerRequest.customerId} has not been created.")
      } else {
        effects.reply(convertToApi(currentState))
      }
    
    def convertToApi(customer: CustomerState): api.Customer =
      api.Customer(
        customerId = customer.customerId,
        email = customer.email,
        name = customer.name,
        address = customer.address.map(address => api.Address(address.street, address.city))
      )
    • If that customer doesn’t exist, processing the command fails.

    • If the customer exists, the reply message contains the customer’s information.

    • The convertToApi method converts the state of the customer to a response message for your external API.

      The src/main/scala/customer/Main.scala file already contains the required code to start your service and register it with Kalix.

Define the initial entity state

To give the domain model a starting point, the initial state for the entity needs to be defined.

  1. Implement the emptyState method by returning an instance of the CustomerState case class:

    src/main/scala/customer/domain/Customer.scala
      override def emptyState: CustomerState = CustomerState()

Package and deploy your service

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

  1. Use the Docker/publish task to build the container image and publish it to your container registry. At the end of this command sbt will show you the container image URL you’ll need in the next part of this sample.

    sbt Docker/publish -Ddocker.username=[your-docker-hub-username]
  2. 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
  3. Deploy the service with the published container image from above:

    kalix service deploy <service name> <container image>
  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 <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.

A customer can be created using the Create method on CustomerService, in the gRPC web UI, or with grpcurl:

grpcurl \
  -d '{
    "customer_id": "abc123",
    "email": "someone@example.com",
    "name": "Someone",
    "address": {
      "street": "123 Some Street",
      "city": "Somewhere"
    }
  }' \
  --plaintext localhost:8080 \
  customer.api.CustomerService/Create

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

grpcurl \
  -d '{"customer_id": "abc123"}' \
  --plaintext localhost:8080 \
  customer.api.CustomerService/GetCustomer

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

kalix service expose <service name>

Try to call the exposed service with grpcurl:

grpcurl \
  -d '{"customer_id": "abc123"}' \
  <generated hostname>:443 \
  customer.api.CustomerService/GetCustomer

Next steps