Implementing Value Entities
Value Entities persist state on every change and thus Kalix needs to serialize that data to send it to the underlying data store. However, we recommend that you do not persist your service’s public API messages. This may introduce some overhead to convert from one type to an internal one but allows the service public interface logic to evolve independently of the data storage format, which should be private.
The steps necessary to implement a Value Entity include:
-
Defining the API and model the entity’s state.
-
Implementing behavior in command handlers.
-
Creating and initializing the Entity.
The following sections walk through these steps using a counter service as an example.
Modeling the Entity
As mentioned above, to help us illustrate a Value Entity, you will be implementing a Counter service. For such service, you will want to be able to set the initial counter value but also to increase the counter modifying its state. The state will be a simple Integer
but you will use a wrapper class Number
for our API, as shown below:
package com.example;
public record Number(Integer value) {}
Identifying the Entity
In order to interact with an Entity in Kalix, we need to assign an entity type and one or more entity keys:
-
entity type is a unique identifier for all entities of a given type. To define the entity type, the entity class must be annotated with
@EntityType
and have a unique and stable name assigned. -
entity key, on the other hand, is unique per instance. In most cases, the entity key is passed as a path parameter of a REST request. The exception to the rule is when we request Kalix to auto-generate a key for us. In such a case, Kalix won’t try to extract the key from the endpoint path.
The entity key can be defined in different ways, as detailed below.
Single keys
The most common use is to annotate the class with @EntityKey
and assign one path variable name to it.
For instance, @EntityKey("id")
will instruct Kalix to look up a matching path variable. For an endpoint defined with @RequestMapping("/users/{id}")
, Kalix will extract whatever path segment is used to replace {id}
and treat it as the Entity unique identifier.
Composite keys
It’s also possible to have composite keys. For example, @EntityKey({"groupId", "id"})
defines a composite key made of groupId
and id
. In such a case, the endpoints for this entity will need to have both path variables, e.g.: @RequestMapping("/users/{groupId}/{id}")
.
Generated keys
Finally, you can ask Kalix to generate an Entity key, this is typically useful when creating an Entity, and the key is a surrogate key. To indicate to Kalix that an Entity key should be generated rather than extracted from the path, be sure to annotate the corresponding command method with @GenerateEntityKey
. Typically, an Entity has only one method annotated with @GenerateEntityKey
. The one that creates the Entity. All other methods will have @EntityKey
annotation in order to extract the surrogate key from the endpoint path.
It will often be necessary to access the generated entity key from inside the entities code. This can be done using the EntityContext.entityId
method.
Kalix generates a UUID version 4 (random) keys. Only version 4 UUIDs are currently supported for generated Entity keys. |
Implementing behavior
Now that you have a good idea of what we want to build and what its API looks like, you will need to:
-
Declare your entity and pick an entity key (it needs to be a unique identifier).
-
Define an access point (i.e. a route path) to your entity.
-
Implement how each command is handled.
Let us start by showing how to create the Value Entity:
@EntityType("counter") (1)
@EntityKey("counter_id") (2)
public class CounterEntity extends ValueEntity<Integer> { (3)
@Override
public Integer emptyState() { return 0; } (4)
1 | Every Entity must be annotated with @EntityType with a stable name. This name should be unique among the different existing entities within a Kalix application. |
2 | The @EntityKey value should be unique per entity and map to some field being received on the route path, in this example it’s the counter_id . |
3 | The CounterEntity class should extend kalix.javasdk.valueentity.ValueEntity . |
4 | The initial state of each counter is defined as 0. |
Updating state
We will now show how to add the command handlers for supporting the two desired operations (set
and plusOne
). Command handlers are implemented as methods on the entity class but are also exposed for external interactions and always return an Effect
of some type.
@PutMapping("/counter/{counter_id}/set") (1)
public Effect<Number> set(@RequestBody Number number) {
int newCounter = number.value();
return effects()
.updateState(newCounter) (2)
.thenReply(new Number(newCounter)); (3)
}
@PostMapping("/counter/{counter_id}/plusone") (4)
public Effect<Number> plusOne() {
int newCounter = currentState() + 1; (5)
return effects()
.updateState(newCounter) (6)
.thenReply(new Number(newCounter));
}
1 | Expose the set command handler on path /counter/{counter_id}/set as a PUT endpoint where counter_id will be its unique identifier. |
2 | Set the new counter value to the value received as body of the command request. |
3 | Reply with the new counter value wrapped within a Number object. |
4 | The method is accessible as a POST endpoint on /counter/{counter_id}/plusone , where counter_id will be its unique identifier. |
5 | plusOne increases the counter by adding 1 to the current state. |
6 | Finally, using the Effect API, you instruct Kalix to persist the new state, and build a reply with the wrapper object. |
The counter_id parameter matches the entityKey value. Also, for this example, we have opted to always repeat the common route /counter/{counter_id} for each command but a simpler option could be to use a @RequestMethod("/counter/{counter_id}") at class level.
|
Deleting state
The next example shows how to delete a Value Entity state by returning special deleteEntity()
effect.
@DeleteMapping("/counter/{counter_id}")
public Effect<String> delete() {
return effects()
.deleteEntity() (1)
.thenReply("deleted: " + commandContext().entityId());
}
1 | We delete the state by returning an Effect with effects().deleteEntity() . |
When you give the instruction to delete the entity it will still exist with an empty state from some time. The actual removal happens later to give downstream consumers time to process the change. By default, the existence of the entity is completely cleaned up after a week.
It is not allowed to make further changes after the entity has been "marked" as deleted. You can still handle read requests of the entity until it has been completely removed, but be the current state will be empty.
If you want to make changes after deleting the state you should use the updateState effect with an empty state instead of using deleteEntity .
|
It is best to not reuse the same entity key after deletion, but if that happens after the entity has been completely removed it will be instantiated as a completely new entity without any knowledge of previous state.
Note that deleting View state must be handled explicitly.
Retrieving state
The following example shows how to implement a simple endpoint to retrieve the current state of the entity, in this case the value for a specific counter.
@GetMapping("/counter/{counter_id}") (1)
public Effect<Number> get() {
return effects()
.reply(new Number(currentState())); (2)
}
1 | Expose the get query handler on path /counter/{counter_id} as a GET endpoint where counter_id will be its unique identifier. |
2 | Reply with the current state wrapped within a Number . |
Testing the Entity
There are two ways to test an Entity:
-
Unit tests, which run the Entity class in the same JVM as the test code itself with the help of a test kit.
-
Integration tests, with the service deployed in a docker container running the entire service and the test interacting with it over HTTP requests.
Each way has its benefits, unit tests are faster and provide more immediate feedback about success or failure but can only test a single entity at a time and in isolation. Integration tests, on the other hand, are more realistic and allow many entities to interact with other components inside and outside the service. For example, actually publishing to a pub/sub topic.
Unit tests
The following snippet shows how the ValueEntityTestKit
is used to test the CountertEntity
implementation. Kalix provides two main APIs for unit tests, the ValueEntityTestKit
and the ValueEntityResult
. The former gives us the overall state of the entity and the ability to call the command handlers while the latter only holds the effects produced for each individual call to the Entity.
@Test
public void testSetAndIncrease() {
var testKit = ValueEntityTestKit.of(CounterEntity::new); (1)
var resultSet = testKit.call(e -> e.set(new Number(10))); (2)
assertTrue(resultSet.isReply());
assertEquals(10, resultSet.getReply().value()); (3)
var resultPlusOne = testKit.call(CounterEntity::plusOne); (4)
assertTrue(resultPlusOne.isReply());
assertEquals(11, resultPlusOne.getReply().value());
assertEquals(11, testKit.getState()); (5)
}
1 | Creates the TestKit passing the constructor of the Entity. |
2 | Calls the method set from the Entity in the ValueEntityTestKit with value 10 . |
3 | Asserts the reply value is 10 . |
4 | Calls the method plusOne from the Entity in the ValueEntityTestKit and assert reply value of 11 . |
5 | Asserts the state value after both operations is 11 . |
The ValueEntityTestKit is stateful, and it holds the state of a single entity instance in memory. If you want to test more than one entity in a test, you need to create multiple instance of ValueEntityTestKit .
|
Integration tests
The skeleton of an Integration Test is generated for you if you use the archetype to start your Kalix app. Let’s see what it could look like to test our Counter Entity:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Main.class)
public class CounterIntegrationTest extends KalixIntegrationTestKitSupport { (1)
@Autowired
private WebClient webClient; (2)
private Duration timeout = Duration.of(10, SECONDS);
@Test
public void verifyCounterSetAndIncrease() {
Number counterGet = (3)
webClient
.get()
.uri("/counter/bar")
.retrieve()
.bodyToMono(Number.class)
.block(timeout);
Assertions.assertEquals(0, counterGet.value());
Number counterPlusOne = (4)
webClient
.post()
.uri("/counter/bar/plusone")
.retrieve()
.bodyToMono(Number.class)
.block(timeout);
Assertions.assertEquals(1, counterPlusOne.value());
Number counterGetAfter = (5)
webClient
.get()
.uri("/counter/bar")
.retrieve()
.bodyToMono(Number.class)
.block(timeout);
Assertions.assertEquals(1, counterGetAfter.value());
}
}
1 | Note the test class must extend KalixIntegrationTestKitSupport . |
2 | A built-in web-client is provided to interact with the components. |
3 | Request to get the current value of the counter named bar . Initial value of counter is expected to be 0 . |
4 | Request to increase the value of counter bar . Response should have value 1 . |
5 | Explicit GET request to retrieve value of bar that should be 1 . |
The integration tests are under in a specific project profile it and can be run using mvn verify -Pit .
|