Implementing Replicated Entities in JavaScript
Replicated Entities distribute state using a conflict-free replicated data type (CRDT). Data is shared across multiple instances of a Replicated Entity and is eventually consistent to provide high availability with low latency. The underlying CRDT semantics allow Replicated Entity instances to update their state independently and concurrently and without coordination. The state changes will always converge without conflicts, but note that with the state being eventually consistent, reading the current data may return an out-of-date value.
Kalix needs to serialize the data to replicate, and it is recommended that this is done with Protocol Buffers using protobuf
types. While Protocol Buffers are the recommended format for state, we recommend that you do not use your service’s public protobuf
messages in the replicated data. This may introduce some overhead to convert from one type to the other, but allows the service public interface logic to evolve independently of the data format, which should be private.
The steps necessary to implement a Replicated Entity include:
-
Defining the API and domain objects in
.proto
files. -
Implementing behavior in command handlers.
-
Creating and initializing the Replicated Entity.
The sections on this page walk through these steps using a shopping cart service as an example.
Defining the proto
files
Our Replicated Entity example is a shopping cart service. |
The following shoppingcart_domain.proto
file defines our "Shopping Cart" Replicated Entity. The entity manages line items of a cart and stores these as a Replicated Counter Map, mapping from each item’s product details to its quantity. The counter for each item can be incremented independently in separate Replicated Entity instances and will converge to a total quantity.
// The messages and data that will be replicated for the shopping cart.
syntax = "proto3";
package com.example.shoppingcart.domain; (1)
message Product { (2)
string id = 1;
string name = 2;
}
1 | Define the protobuf package for the domain messages. |
2 | A Product message will be the key for the Replicated Counter Map. |
The shoppingcart_api.proto
file defines the commands we can send to the shopping cart service to manipulate or access the cart’s state. They make up the service API:
// This is the public API offered by the Shopping Cart Replicated Entity.
syntax = "proto3";
package com.example.shoppingcart; (1)
import "google/protobuf/empty.proto";
import "kalix/annotations.proto"; (2)
import "google/api/annotations.proto";
message AddLineItem { (3)
string cart_id = 1 [(kalix.field).entity_key = true]; (4)
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;
string name = 3;
}
message GetShoppingCart {
string cart_id = 1 [(kalix.field).entity_key = true];
}
message RemoveShoppingCart {
string cart_id = 1 [(kalix.field).entity_key = true];
}
message LineItem {
string product_id = 1;
string name = 2;
int32 quantity = 3;
}
message Cart { (5)
repeated LineItem items = 1;
}
service ShoppingCartService { (6)
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"
}
};
}
rpc RemoveCart (RemoveShoppingCart) returns (google.protobuf.Empty) {
option (google.api.http).post = "/carts/{cart_id}/remove";
}
}
1 | Define the protobuf package for the service and API messages. |
2 | Import the Kalix protobuf annotations, or options. |
3 | We use protobuf messages to describe the Commands that our service handles. They may contain other messages to represent structured data. |
4 | Every Command must contain a string field that contains the entity ID and is marked with the (kalix.field).entity_key option. |
5 | Messages describe the return value for our API. For methods that don’t have return values, we use google.protobuf.Empty . |
6 | The service descriptor shows the API of the entity. It lists the methods a client can use to issue Commands to the entity. |
Implementing behavior
A Replicated Entity is implemented with the ReplicatedEntity class:
- JavaScript
-
src/shoppingcart.js
import { replicatedentity } from "@kalix-io/kalix-javascript-sdk"; const entity = new replicatedentity.ReplicatedEntity( (1) ["shoppingcart_domain.proto", "shoppingcart_api.proto"], (2) "com.example.shoppingcart.ShoppingCartService", (3) "shopping-cart", (4) { includeDirs: ["./proto"], (5) } );
- TypeScript
-
src/shoppingcart.ts
import { replicatedentity } from "@kalix-io/kalix-javascript-sdk"; const entity = new replicatedentity.ReplicatedEntity< replicatedentity.ReplicatedCounterMap<Product> >( (1) ["shoppingcart_domain.proto", "shoppingcart_api.proto"], (2) "com.example.shoppingcart.ShoppingCartService", (3) "shopping-cart", (4) { includeDirs: ["./proto"], (5) } );
1 | Create a Replicated Entity using the constructor. |
2 | Specify the protobuf files for this entity. |
3 | Provide the fully qualified gRPC service name (defined in the protobuf files). |
4 | The entity type is a unique identifier for data replication (and can’t be changed). |
5 | Add any options, such as the directory to find protobuf files. |
Set a default Replicated Data value for the Replicated Entity:
- JavaScript
-
src/shoppingcart.js
entity.defaultValue = () => new replicatedentity.ReplicatedCounterMap(); (1)
- TypeScript
-
src/shoppingcart.ts
entity.defaultValue = () => new replicatedentity.ReplicatedCounterMap<Product>(); (1)
1 | Create a new Replicated Counter Map as the default value for when this entity is first initialized. |
Each Replicated Entity is associated with one underlying Replicated Data type. |
Using protobuf types
To create protobuf messages, for responses or the replicated state, first lookup the protobuf type constructors using the Replicated Entity lookupType
helper. For TypeScript, the protobuf types used in command handlers can be added using the statically generated type declarations.
- JavaScript
-
src/shoppingcart.js
const Product = entity.lookupType("com.example.shoppingcart.domain.Product"); const Cart = entity.lookupType("com.example.shoppingcart.Cart"); const Empty = entity.lookupType("google.protobuf.Empty");
- TypeScript
-
src/shoppingcart.ts
import * as proto from "../lib/generated/proto"; type AddLineItem = proto.com.example.shoppingcart.AddLineItem; type RemoveLineItem = proto.com.example.shoppingcart.RemoveLineItem; type GetShoppingCart = proto.com.example.shoppingcart.GetShoppingCart; type RemoveShoppingCart = proto.com.example.shoppingcart.RemoveShoppingCart; type Context = replicatedentity.ReplicatedEntity.CommandContext< replicatedentity.ReplicatedCounterMap<Product> >; type Product = proto.com.example.shoppingcart.domain.IProduct & protobuf.Message; const Product = entity.lookupType("com.example.shoppingcart.domain.Product"); const Cart = entity.lookupType("com.example.shoppingcart.Cart"); const Empty = entity.lookupType("google.protobuf.Empty");
Each protobuf type constructor has a create method for creating a protobuf message.
|
Defining command handlers
We need to implement all methods our Replicated Entity offers as command handlers. A command handler
is a function that takes a command and a
ReplicatedEntityCommandContext
. The command is the input message type for the gRPC service call, and the command handler must return a message of the same type as the output type of the gRPC service call.
We map each method to a command handler function using commandHandlers
.
- JavaScript
-
src/shoppingcart.js
entity.commandHandlers = { AddItem: addItem, RemoveItem: removeItem, GetCart: getCart, RemoveCart: removeCart };
- TypeScript
-
src/shoppingcart.ts
entity.commandHandlers = { AddItem: addItem, RemoveItem: removeItem, GetCart: getCart, RemoveCart: removeCart, };
Updating state
In the example below, the AddItem
service call uses the request message AddLineItem
to update items in the shopping cart.
The current Replicated Data value can be accessed using the state
method on the provided context.
- JavaScript
-
src/shoppingcart.js
import { replies } from "@kalix-io/kalix-javascript-sdk"; function addItem(addLineItem, context) { if (addLineItem.quantity <= 0) { (1) return replies.failure(`Quantity for item ${addLineItem.productId} must be greater than zero`); } const cart = context.state; (2) const product = Product.create({ (3) id: addLineItem.productId, name: addLineItem.name, }); cart.increment(product, addLineItem.quantity); (4) return replies.message(Empty.create()); (5) }
- TypeScript
-
src/shoppingcart.ts
import { replies } from "@kalix-io/kalix-javascript-sdk"; function addItem(addLineItem: AddLineItem, context: Context) { if (addLineItem.quantity <= 0) { return replies.failure(`Quantity for item ${addLineItem.productId} must be greater than zero`); (1) } const cart = context.state; (2) const product = Product.create({ id: addLineItem.productId, (3) name: addLineItem.name, }); cart.increment(product, addLineItem.quantity); (4) return replies.message(Empty.create()); (5) }
1 | The validation ensures that the quantity of items added is greater than zero or fails the call using replies.failure . |
2 | Access the current Replicated Data value using context.state . |
3 | From the current incoming AddLineItem we create a new Product object to represent the item’s key in the counter map. |
4 | We increment the counter for this item in the cart. A new counter will be created if the cart doesn’t contain this item already. |
5 | An acknowledgment that the command was successfully processed is sent with a reply message. |
Retrieving state
The following example shows the implementation of the GetCart
command handler. This command handler is a read-only command handler—it doesn’t update the state, it just returns it.
The current Replicated Data value can be accessed using the state
method on the provided context.
The state of Replicated Entities is eventually consistent. An individual Replicated Entity instance may have an out-of-date value, if there are concurrent modifications made by another instance. |
- JavaScript
-
src/shoppingcart.js
import { replies } from "@kalix-io/kalix-javascript-sdk"; function getCart(getShoppingCart, context) { const cart = context.state; (1) const items = Array.from(cart.keys()) (2) .map(product => ({ productId: product.id, name: product.name, quantity: cart.get(product), (3) })); return replies.message(Cart.create({ items: items })); (4) }
- TypeScript
-
src/shoppingcart.ts
import { replies } from "@kalix-io/kalix-javascript-sdk"; function getCart(getShoppingCart: GetShoppingCart, context: Context) { const cart = context.state; (1) const items = Array.from(cart.keys()) (2) .map(product => ({ productId: product.id, name: product.name, quantity: cart.get(product), (3) })); return replies.message(Cart.create({ items: items })); (4) }
1 | Access the current Replicated Data value using context.state . |
2 | Iterate over the items in the cart to convert the domain representation to the API representation. |
3 | Access a value in the Replicated Counter Map using get with the key. |
4 | Return the reply with a Cart message. |
Deleting state
The following example shows the implementation of the RemoveCart
command handler. Replicated Entity instances for a particular entity identifier can be deleted, using the delete
method on the provided context. Once deleted, an entity instance cannot be recreated, and all subsequent commands for that entity identifier will be rejected with an error.
Caution should be taken with creating and deleting Replicated Entities, as Kalix maintains the replicated state in memory and also retains tombstones for each deleted entity. Over time, if many Replicated Entities are created and deleted, this will result in hitting memory limits. |
- JavaScript
-
src/shoppingcart.js
import { replies } from "@kalix-io/kalix-javascript-sdk"; function removeCart(removeShoppingCart, context) { context.delete(); (1) return replies.message(Empty.create()); (2) }
- TypeScript
-
src/shoppingcart.ts
import { replies } from "@kalix-io/kalix-javascript-sdk"; function removeCart(removeShoppingCart: RemoveShoppingCart, context: Context) { context.delete(); (1) return replies.message(Empty.create()); (2) }
1 | The Replicated Entity instances for the associated entity key are deleted by using context.delete . |
2 | An acknowledgment that the command was successfully processed is sent with a reply message. |
Registering the Entity
To make Kalix aware of the Replicated Entity, we need to register it with the service.
- JavaScript
-
src/index.js
import { Kalix } from "@kalix-io/kalix-javascript-sdk"; import shoppingcart from "./shoppingcart.js"; new Kalix() (1) .addComponent(shoppingcart) (2) .start(); (3)
- TypeScript
-
src/index.ts
import { Kalix } from "@kalix-io/kalix-javascript-sdk"; import shoppingcart from "./shoppingcart.js"; new Kalix() (1) .addComponent(shoppingcart) (2) .start(); (3)
1 | Create a Kalix service. |
2 | Register the Replicated Entity using addComponent . |
3 | Start the service. |
Replicated Data types
Each Replicated Entity is associated with one underlying Replicated Data type. Counter, Register, Set, and Map data structures are available. This section describes how to configure and implement a Replicated Entity with each of the Replicated Data types.
The current value for a Replicated Data object may not be the most up-to-date value when there are concurrent modifications. |
Replicated Counter
A ReplicatedCounter can be incremented and decremented.
When implementing a Replicated Counter entity, the state can be updated by calling the increment
or decrement
methods on the current data object. The current value of a Replicated Counter can be retrieved using value
or longValue
.
Replicated Register
A ReplicatedRegister can contain any (serializable) value. Updates to the value are replicated using last-write-wins semantics, where concurrent modifications are resolved by using the update with the highest timestamp.
When creating a Replicated Register, an initial or empty value needs to be defined. The current value of a Replicated Register can be retrieved using the value
property, and updated by assigning a new value to the value
property.
Replicated Set
A ReplicatedSet is a set of (serializable) values, where elements can be added or removed.
When implementing a Replicated Set entity, the state can be updated by calling the add
or delete
methods on the current data object. The elements
method for Replicated Set returns a regular Set
. Replicated Sets are also iterable.
Care needs to be taken to ensure that the serialized values for elements in the set are stable. |
Replicated Counter Map
A ReplicatedCounterMap maps (serializable) keys to replicated counters, where each value can be incremented and decremented.
When implementing a Replicated Counter Map entity, the value of an entry can be updated by calling the increment
or decrement
methods. Entries can be removed from the map using the delete
method. Individual counters in a Replicated Counter Map can be accessed using get
or getLong
, or the set of keys
can be used to iterate over all counters.
Replicated Register Map
A ReplicatedRegisterMap maps (serializable) keys to replicated registers of (serializable) values. Updates to values are replicated using last-write-wins semantics, where concurrent modifications are resolved by using the update with the highest timestamp.
When implementing a Replicated Register Map entity, the value of an entry can be updated by calling the set
method on the current data object. Entries can be removed from the map using the delete
method. Individual registers in a Replicated Register Map can be accessed using the get
method, or the set of keys
can be used to iterate over all registers.
Replicated Multi-Map
A ReplicatedMultiMap maps (serializable) keys to replicated sets of (serializable) values, providing a multi-map interface that can associate multiple values with each key.
When implementing a Replicated Multi-Map entity, the values of an entry can be updated by calling the put
, putAll
, or delete
methods on the current data object. Entries can be removed entirely from the map using the deleteAll
method. Individual entries in a Replicated Multi-Map can be accessed using the get
method which returns a Set
of values, or the set of keys
can be used to iterate over all value sets.
Replicated Map
A ReplicatedMap maps (serializable) keys to any other Replicated Data types, allowing a heterogeneous map where values can be of any Replicated Data type.
Prefer to use the specialized replicated maps (Replicated Counter Map, Replicated Register Map, or Replicated Multi-Map) whenever the values of the map are of the same type — counters, registers, or sets. |
When implementing a Replicated Map entity, the replicated data for an entry can be updated by retrieving the data value using the get
method, with a default value defined using defaultValue
for when a key is not present in the map, and then updating the Replicated Data value. Entries can be removed from the map using the delete
method.
All objects used within Replicated Data types — as keys, values, or elements — must be immutable, and their serialized form must be stable. Kalix uses the serialized form of these values to track changes in Replicated Sets or Maps. If the same value serializes to different bytes on different occasions, they will be treated as different keys, values, or elements in a Replicated Set or Map. This is particularly relevant when using Protocol Buffers ( For the rest of the protobuf specification, while no guarantees are made on the stability by the protobuf specification itself, the Java libraries do produce stable orderings for message fields and repeated fields. But care should be taken when changing the protobuf structure of any types used within Replicated Data objects — many changes that are backwards compatible from a protobuf standpoint do not necessarily translate into stable serializations. |