Implementing Views
Views allow you to access multiple entities or retrieve entities by attributes other than their entity id. You can create Views for different access patterns, optimized by specific queries, or combine multiple queries into a single View.
Views can be defined from any of the following:
Reference documentation covering the view query language syntax, query capabilities and how query results are mapped to Java types can be found in View reference.
The remainder of this page describes:
| Be aware that Views are not updated immediately when the Entity state changes. It is not instant but eventually all changes will become visible in the query results. View updates might also take more time during failure scenarios (e.g. network instability) than during normal operation. |
View’s Effect API
The View’s Effect defines the operations to be performed when an event, a message or a state change is handled by a View.
A View Effect can either:
-
update the view state
-
delete the view state
-
ignore the event or state change notification (and not update the view state)
For additional details, refer to Declarative Effects.
Creating a View from a Key Value Entity
Consider an example of a Customer Registry service with a Customer Key Value Entity. When customer state changes, the entire state is emitted as a value change. Those value changes update any associated Views.
To create a View that lists customers by their name, define the view for a service that selects customers by name and associates a table name with the View. The table is created and used to store the View.
This example assumes the following Customer exists:
public record Customer(String email, String name, Address address) { (1)
public Customer withName(String newName) { (2)
return new Customer(email, newName, address);
}
public Customer withAddress(Address newAddress) { (2)
return new Customer(email, name, newAddress);
}
}
As well as a Key Value Entity component CustomerEntity.java that will produce the state changes consumed by the View. You can consult Key Value Entity documentation on how to create such an entity if needed.
Define the View
You implement a View by extending akka.javasdk.view.View and subscribing to changes from an entity. You specify how to query it by providing one or more methods annotated with @Query, which can then be made accessible via an HTTP Endpoint.
import akka.javasdk.annotations.Component;
import akka.javasdk.annotations.Consume;
import akka.javasdk.annotations.Query;
import akka.javasdk.view.TableUpdater;
import akka.javasdk.view.View;
import customer.domain.Customer;
import java.util.List;
@Component(id = "customers-by-email") (1)
public class CustomersByEmail extends View { (2)
public record Customers(List<Customer> customers) {}
@Consume.FromKeyValueEntity(CustomerEntity.class) (3)
public static class CustomersByEmailUpdater extends TableUpdater<Customer> {} (4)
@Query("SELECT * AS customers FROM customers_by_email WHERE email = :email") (5)
public QueryEffect<Customers> getCustomer(String email) {
return queryResult(); (6)
}
}
| 1 | Define a component id for the view. |
| 2 | Extend from View. |
| 3 | Subscribe to updates from Key Value Entity CustomerEntity. |
| 4 | Declare a TableUpdater of type Customer (entity’s state type). |
| 5 | Define the query, including a table name (i.e. customers_by_email) of our choice. |
| 6 | Use method queryResult() to return the result of the query. |
Assigning a component identifier (i.e. @Component) to your View is mandatory, it must be unique, and it should be stable. This allows you to refactor the name of the class later on without the risk of losing the view. If you change this identifier later, Akka will not recognize this component as the same view and will create a brand-new view. For a view consuming from an Event Sourced Entity this becomes very resource consuming because it will reprocess all the events of that entity to rebuild it. While for a view built from a topic, you can lose all the previous events because, depending on the topic configuration, you may only process events from the current time forwards. Last but not least, it is also a problem for Key Value Entities because it will need to index them again when grouping them by some value.
|
Using a transformed model
Often, you will want to transform the entity model to which the view is subscribing into a different representation. To do that, have a look at the example in which we store a summary of the Customer used in the previous section instead of the original one:
public record CustomerSummary(String customerId, String name, String email) {}
In this scenario, the view state should be of type CustomerSummary and you will need to handle and transform the incoming state changes into it, as shown below:
import akka.javasdk.annotations.Component;
import akka.javasdk.annotations.Consume;
import akka.javasdk.annotations.Query;
import akka.javasdk.view.TableUpdater;
import akka.javasdk.view.View;
import customer.domain.Customer;
import java.util.Collection;
@Component(id = "customers-by-name")
public class CustomersByName extends View {
public record CustomerSummary(String customerId, String name, String email) {}
@Consume.FromKeyValueEntity(CustomerEntity.class)
public static class CustomersByNameUpdater extends TableUpdater<CustomerSummary> { (1)
public Effect<CustomerSummary> onUpdate(Customer customer) { (2)
return effects()
.updateRow(
new CustomerSummary(
updateContext().eventSubject().get(),
customer.name(),
customer.email()
)
); (3)
}
}
@Query("SELECT * FROM customers_by_name WHERE name = :name") (4)
public QueryEffect<CustomerSummary> getFirstCustomerSummary(String name) { (5)
return queryResult();
}
}
| 1 | Declares a TableUpdater of type CustomerSummary. This type represents each stored row. |
| 2 | Implements a handler method onUpdate that receives the latest state of the entity Customer and returns an Effect with the updated row. |
| 3 | The id of the entity that was updated is available through the update context as eventSubject. |
| 4 | Defines the query. |
| 5 | Uses the new type CustomerSummary to return the result of the query. |
Some TableUpdater implementation might update the view model in a non-idempotent way. For example, the view model adds an element to the list. When the source of the changes is an Event Sourced Entity, Key Value Entity or another Akka service, the View component has a build-in deduplication mechanism to ensure that the same event is not processed twice. In other cases, you should add the deduplication mechanism in the TableUpdater implementation. See message deduplication for some suggested solutions.
|
Handling Key Value Entity deletes
When an entity is deleted, its corresponding view row will be deleted automatically.
If you want to customize this behavior, you can add a handler method marked with @DeleteHandler to your table updater. For example, instead of deleting the row, you can perform a logical deleted.
@Consume.FromKeyValueEntity(value = CustomerEntity.class)
public static class CustomersUpdater extends TableUpdater<CustomerSummary> { (1)
public Effect<CustomerSummary> onUpdate(Customer customer) {
return effects()
.updateRow(
new CustomerSummary(updateContext().eventSubject().get(), customer.name(), false)
);
}
// ...
@DeleteHandler (2)
public Effect<CustomerSummary> onDelete() {
CustomerSummary currentRow = rowState();
if (currentRow.hasActiveOrders()) {
// Logical delete: keep the row but mark it as deleted (3)
return effects().updateRow(currentRow.asDeleted());
} else {
// Hard delete: physically remove the row from the view (4)
return effects().deleteRow();
}
}
}
| 1 | Note the added handler on the existing table updater. |
| 2 | Marks the method as a delete handler. |
| 3 | Logical delete: use effects().updateRow() to keep the row but mark it as deleted by setting the deleted field to true. |
| 4 | Hard delete: use effects().deleteRow() to physically remove the row.. |
Creating a View from an Event Sourced Entity
You can create a View from an Event Sourced Entity by using events that the Entity emits to build a state representation.
Using our Customer Registry service example, to create a View for querying customers by name, you have to define the view to consume events.
This example assumes a Customer equal to the previous example and an Event Sourced Entity that uses this Customer. The Event Sourced Entity is in charge of producing the events that update the View. These events are defined as subtypes of the class CustomerEvent using a sealed interface:
import akka.javasdk.annotations.TypeName;
public sealed interface CustomerEvent {
@TypeName("internal-customer-created") (1)
record CustomerCreated(String email, String name, Address address)
implements CustomerEvent {}
@TypeName("internal-name-changed")
record NameChanged(String newName) implements CustomerEvent {}
@TypeName("internal-address-changed")
record AddressChanged(Address address) implements CustomerEvent {}
}
| 1 | Includes the logical type name using @TypeName annotation. |
It is highly recommended to add a @TypeName to your persisted events. Akka needs to identify each event in order to deliver them to the right event handlers. If no logical type name is specified, Akka uses the FQCN, check type name documentation for more details.
|
Define the View to consume events
Defining a view that consumes from an Event Sourced Entity is very similar to the one consuming a Key Value Entity. In this case, the handler method will be called for each event emitted by the Entity.
Every time an event is processed by the view, the state of the view can be updated. You can do this with the updateRow method, which is available through the effects() API. Below you can see how the View is updated:
import akka.javasdk.annotations.Component;
import akka.javasdk.annotations.Consume;
import akka.javasdk.annotations.Query;
import akka.javasdk.annotations.SnapshotHandler;
import akka.javasdk.view.TableUpdater;
import akka.javasdk.view.View;
import customer.domain.Customer;
import customer.domain.CustomerEntries;
import customer.domain.CustomerEntry;
import customer.domain.CustomerEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Component(id = "customers-by-name") (1)
public class CustomersByNameView extends View {
private static final Logger logger = LoggerFactory.getLogger(CustomersByNameView.class);
@Consume.FromEventSourcedEntity(CustomerEntity.class)
public static class CustomersByNameUpdater extends TableUpdater<CustomerEntry> { (2)
public Effect<CustomerEntry> onEvent(CustomerEvent event) { (3)
logger.info("onEvent [{}]", event);
return switch (event) {
case CustomerEvent.CustomerCreated created -> effects()
.updateRow(new CustomerEntry(created.email(), created.name(), created.address()));
case CustomerEvent.NameChanged nameChanged -> effects()
.updateRow(rowState().withName(nameChanged.newName()));
case CustomerEvent.AddressChanged addressChanged -> effects()
.updateRow(rowState().withAddress(addressChanged.address()));
};
}
}
@Query("SELECT * as customers FROM customers_by_name WHERE name = :name")
public QueryEffect<CustomerEntries> getCustomers(String name) {
return queryResult();
}
}
| 1 | Defines a component id for the view. |
| 2 | Declares a TableUpdater of type CustomerEntry. |
| 3 | Handles the super type CustomerEvent and defines the proper update row method for each subtype. |
Ignoring events
You can ignore events by returning effects().ignore() for those you are not interested in. Using a sealed interface for the events is a good practice to ensure that all events types are handled.
Handling Event Sourced Entity deletes
When an entity is deleted, its corresponding view row will be deleted automatically.
If you want to customize this behavior, you can add a handler method marked with @DeleteHandler to your table updater. For example, instead of deleting the row, you can perform a logical deleted.
Starting from Snapshot
A View that processes events from an Event Sourced Entity can optionally define a @SnapshotHandler method to receive entity snapshots. This can provide significant performance improvements when a new view needs to catch up on a long event history.
When a @SnapshotHandler is defined in the TableUpdater, the view will start processing from the most recent snapshot instead of replaying historical events. After processing the snapshot, subsequent events are processed normally.
@SnapshotHandler
public Effect<CustomerEntry> onSnapshot(Customer snapshot) {
logger.info("onSnapshot [{}]", snapshot);
return effects()
.updateRow(new CustomerEntry(snapshot.email(), snapshot.name(), snapshot.address()));
}
The @SnapshotHandler annotation marks the method that will receive entity snapshots. The parameter type must match the state type of the Event Sourced Entity.
Creating a View from a Workflow
The source of a View can be also a Workflow state changes. It works the same way as shown in Creating a View from an Event Sourced Entity or Creating a View from a Key Value Entity, but you define it with @Consume.FromWorkflow instead.
@Component(id = "transfer-view")
public class TransfersView extends View {
public record TransferEntry(String id, String status) {}
public record TransferEntries(Collection<TransferEntry> entries) {}
@Query("SELECT * as entries FROM transfers WHERE status = 'COMPLETED'")
public QueryEffect<TransferEntries> getAllCompleted() {
return queryResult();
}
@Consume.FromWorkflow(TransferWorkflow.class) (1)
public static class TransfersUpdater extends TableUpdater<TransferEntry> {
public Effect<TransferEntry> onUpdate(TransferState transferState) { (2)
var id = updateContext().eventSubject().orElse("");
return effects().updateRow(new TransferEntry(id, transferState.status().name()));
}
}
}
| 1 | Uses @Consume.FromWorkflow annotation to set the source Workflow. |
| 2 | Transforms the Workflow state TransferState into a View TransferEntry. |
Creating a View from a topic
The source of a View can be a topic. It works the same way as shown in Creating a View from an Event Sourced Entity or Creating a View from a Key Value Entity, but you define it with @Consume.FromTopic instead.
For the messages to be correctly consumed in the view, there must be a ce-subject metadata associated with each message. This is required because for each message consumed from the topic there will be a corresponding view row. That view row is selected based on such ce-subject. For an example on how to pass such metadata when producing to a topic, see page Metadata.
|
@Component(id = "counter-topic-view")
public class CounterTopicView extends View {
private static final Logger logger = LoggerFactory.getLogger(CounterTopicView.class);
public record CounterRow(String counterId, int value, Instant lastChange) {}
public record CountersResult(List<CounterRow> foundCounters) {}
@Consume.FromTopic("counter-events-with-meta") (1)
public static class CounterUpdater extends TableUpdater<CounterRow> {
public Effect<CounterRow> onEvent(CounterEvent event) {
String counterId = updateContext().eventSubject().get(); (2)
var newValue =
switch (event) {
case ValueIncreased increased -> increased.updatedValue();
case ValueMultiplied multiplied -> multiplied.updatedValue();
};
logger.info("Received new value for counter id {}: {}", counterId, event);
return effects().updateRow(new CounterRow(counterId, newValue, Instant.now())); (3)
}
}
@Query("SELECT * AS foundCounters FROM counters WHERE value >= :minimum")
public View.QueryEffect<CountersResult> countersHigherThan(int minimum) {
return queryResult();
}
}
| 1 | Uses @Consume.FromTopic annotation to set the target topic. |
| 2 | Extracts the ce-subject attribute from the topic event metadata to include in the view row. |
| 3 | Returns an updating effect with new table row state. |
View query results
How to transform results
When creating a View, you can transform the results as a projection for constructing a new type instead of returning the view row type directly, for details see Result Mapping
Streaming the result
Instead of collecting the query result in memory as a collection before returning it, the entries can be streamed.
To return the result as a stream, modify the returned type to be QueryStreamEffect and use queryStreamResult() to return the stream.
@Query(value = "SELECT * FROM customers_by_city WHERE address.city = :city")
public QueryStreamEffect<Customer> streamCustomersInCity(String city) {
return queryStreamResult();
}
Streaming view updates
A query can provide a near real-time stream of results for the query, emitting new entries matching the query as they are added or updated in the view.
This will first list the complete result for the query and then keep the response stream open, emitting new or updated entries matching the query as they are added to the view. The stream does not complete until the client closes it.
To use streaming updates, add streamUpdates = true to the Query annotation. The returned type of the
query method must be QueryStreamEffect.
@Query(
value = "SELECT * FROM customers_by_city WHERE address.city = :city",
streamUpdates = true
)
public QueryStreamEffect<Customer> continuousCustomersInCity(String city) {
return queryStreamResult();
}
This example would return the customers living in the same city, and then emit every time a customer already in the city is changed, or when a new customer is added to the view with the given city.
Streaming updates can be streamed all the way to a gRPC or HTTP client via a gRPC Endpoint or an HTTP endpoint using SSE.
| This is not intended as transport for service to service propagation of updates, and it does not guarantee delivery. For such use cases you should instead publish events to a topic, see Consuming and producing |
How to modify a View
Akka creates indexes for the View based on the queries. For example, the following query will result in a View with an index on the name column:
SELECT * FROM customers WHERE name = :customer_name
You may realize after a deployment that you forgot adding some parameters to the query parameters that are not exposed to the endpoint of the View. After adding these parameters the query is changed and therefore Akka will add indexes for these new columns. For example, changing the above query to filter by active users would mean a new index on the is-active column. This is handled automatically behind the scenes.
SELECT * FROM customers WHERE name = :customer_name AND is-active = true
Deciding if a change requires a View rebuild
Each row of a View table is stored as one JSON document. The View definitions are stored and validated when a new version is deployed. There will be an error message if the changes are not compatible. The stored definition consists of the table names and the data type of each field that a query filters, sorts, or groups on. Everything else can change without a rebuild.
| Change | Rebuild? |
|---|---|
Rename a table, or add or remove a table in a multi-table View |
Required |
Change the data type of a field that a query filters, sorts, or groups on |
Required |
Change which fields a query filters, sorts, or groups on |
Not needed |
Add or remove a query |
Not needed |
Change the |
Not needed |
Add a field to the row type |
Optional, see Changes where a rebuild is optional |
Remove a field from the row type |
Optional, see Changes where a rebuild is optional |
Changes that require a rebuild
These changes make the new View definition incompatible with the data that is already stored. When you deploy such a change, Akka logs an error, keeps the stored data as it is, and fails all queries and updates for that View until you deploy a compatible definition. Follow the steps in Rebuilding a View to replace the View.
- Renaming a table
-
The table name is part of the View identity. For a single table View the table name is taken from the
FROMclause of the query, for a multi-table View it is declared with@Tableon eachTableUpdater. Renaming a table, or adding or removing a table in a multi-table View, makes the deployed View incompatible with the stored one, even if nothing else changes. - Changing the data type of a queried field
-
If a query filters, sorts, or groups on a field, for example
age, and the new row type declares that field with a different type, for exampleinttoString, the stored index for that field can no longer be read.The stored definition keeps every field that a query in this or any previously deployed version of the View has used. Removing the field from all queries first, and changing its type in a later deployment, is still incompatible. Akka does not check the type of fields that no query has ever used, but the stored JSON documents must still deserialize into the new row type. Avoid changing the type of a field altogether. Add a new field with a new name and the new type, and remove the old field, as described in Schema evolution.
Changes where a rebuild is optional
- Adding a field to the row type
-
The whole row is stored as one JSON document, so a new field is compatible with the stored rows. Existing rows do not have a value for the field until they are updated again. Reading such a row gives the default value for the field,
nullfor reference types. To populate the field from historical entity data, rebuild the View. - Removing a field from the row type
-
Removing a field is compatible with the stored rows. The removed data stays in the JSON document of a row until the next update of that row. An update writes the whole row from the row type, which drops the removed field. Rows that are never updated again keep the removed data in the database. For primitive values the extra space is small, but for nested structures it can add up. A rebuild removes the unused data from all rows.
A query that still filters, sorts, or groups on the removed field is an error. The service fails to start with a query parse error for the View. Update or remove the query in the same change.
Changes that do not require a rebuild
Akka updates indexes in place for these changes and keeps serving queries through the deployment.
- Changing which fields a query filters, sorts, or groups on
-
Adding, removing, or replacing the fields a query filters, sorts, or groups on is compatible. Akka drops the index that is no longer needed and builds the new one, without touching the stored rows.
- Adding or removing queries
-
A new query method against the same table, or removing a query that is no longer needed, is compatible.
- Changing the
TableUpdaterlogic -
How the
TableUpdatermaps events or state changes into a row is not part of the compatibility check. - Changes elsewhere in the service
-
The compatibility check covers one View and its tables. Changes to other components in the service never affect it.
|
Running locally with persistence enabled uses an H2 database, which stores each field of the row type as a separate column instead of one JSON document per row. The compatibility check is stricter in that mode: adding or removing any field of the row type is reported as incompatible, while it is compatible in a deployed service. A View that starts and serves queries locally after a change is compatible when deployed. A compatibility error locally does not mean that the change is incompatible when deployed. Use the table above to decide, or delete the local database file and restart. |
Rebuilding a View
To rebuild a View, define a new View with a new @Component identifier. Akka builds it from the source event log or state changes.
Rebuilding a View may take some time if there are many events that have to be processed. The recommended way is multi-step, with two deployments:
-
Define the new View with a new
@Component, and keep the old View intact. -
Deploy the new View, and let it rebuild. Verify that the new query works as expected. The old View can still be used.
-
Remove the old View and redirect the endpoint calls to the new View.
-
Deploy the second change.
| Views from topics cannot be rebuilt from the source messages, because it might not be possible to consume all events from the topic again. The new View is built from new messages published to the topic. |
Delivery semantics and deduplication
All Views based on Event Sourced Entities, Key Value Entities and Workflows use exactly-once delivery semantics. It means that there is a build in deduplication mechanism based on sequence number tracking.
| For Views based on Key Value Entities, while the deduplication mechanism ensures exactly-once processing, it does not guarantee that all intermediate state changes will be delivered to the View. Due to the nature of Key Value Entities, only the latest state is guaranteed to be reflected in the View. If a Key Value Entity is updated multiple times in quick succession, some intermediate states may be skipped during restarts or rebalances. |
Testing the View
Testing Views is very similar to testing other subscription integrations.
For a View definition that subscribes to changes from the customer Key Value Entity.
public class CustomersByCity extends View {
@Consume.FromKeyValueEntity(CustomerEntity.class)
public static class CustomerUpdater extends TableUpdater<Customer> {}
@Query(
"""
SELECT * AS customers
FROM customers_by_city
WHERE address.city = ANY(:cities)
"""
)
public QueryEffect<CustomerList> getCustomers(List<String> cities) {
return queryResult();
}
@Query(value = "SELECT * FROM customers_by_city WHERE address.city = :city")
public QueryStreamEffect<Customer> streamCustomersInCity(String city) {
return queryStreamResult();
}
@Query(
value = "SELECT * FROM customers_by_city WHERE address.city = :city",
streamUpdates = true
)
public QueryStreamEffect<Customer> continuousCustomersInCity(String city) {
return queryStreamResult();
}
public record QueryParams(String customerName, String city) {} (1)
@Query(
"""
SELECT * FROM customers_by_city
WHERE name = :customerName AND address.city = :city"""
) (2)
public QueryEffect<Customer> getCustomersByCityAndName(QueryParams queryParams) {
return queryResult();
}
}
An integration test can be implemented as below.
class CustomersByCityIntegrationTest extends TestKitSupport {
@Override
protected TestKit.Settings testKitSettings() { (1)
return TestKit.Settings.DEFAULT.withKeyValueEntityIncomingMessages(CustomerEntity.class);
}
@Test
public void shouldGetCustomerByCity() {
IncomingMessages customerEvents = (2)
testKit.getKeyValueEntityIncomingMessages(CustomerEntity.class);
Customer johanna = new Customer(
"[email protected]",
"Johanna",
new Address("Cool Street", "Porto")
);
Customer bob = new Customer(
"[email protected]",
"Bob",
new Address("Baker Street", "London")
);
Customer alice = new Customer(
"[email protected]",
"Alice",
new Address("Long Street", "Wroclaw")
);
customerEvents.publish(johanna, "1"); (3)
customerEvents.publish(bob, "2");
customerEvents.publish(alice, "3");
Awaitility.await()
.ignoreExceptions()
.atMost(10, TimeUnit.SECONDS)
.untilAsserted(() -> {
CustomerList customersResponse = componentClient
.forView()
.method(CustomersByCity::getCustomers) (4)
.invoke(List.of("Porto", "London"));
assertThat(customersResponse.customers()).containsOnly(johanna, bob);
});
}
}
| 1 | Mocks incoming messages from the customer Key Value Entity. |
| 2 | Gets an IncomingMessages from the CustomerEntity. |
| 3 | Publishes test data. |
| 4 | Queries the view and asserts the results. |
Multi-region replication
Views are not replicated directly in the same way as for example Event Sourced Entity replication. A View is built from entities in the same service, or another service, in the same region. The entities will replicate all events across regions and identical Views are built in each region.
The origin of an event is the region where a message was first created. You can see the origin from updateContext().hasLocalOrigin() or updateContext().originRegion() and perform conditional processing of the event depending on the origin, such as ignoring events from other regions than the local region where the View is running. The local region can be retrieved with messageContext().selfRegion().
A View can also be built from a message broker topic, and that could be regional or global depending on how the message broker is configured.