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:

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.ComponentId;
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;

@ComponentId("customers_by_email") (1)
public class CustomersByEmail extends View { (2)

  public record Customers(List<Customer> customers) { }

  @Consume.FromKeyValueEntity(CustomerEntity.class) (3)
  public static class CustomerByEmail 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. @ComponentId) 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’s 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, let’s 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.ComponentId;
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;

@ComponentId("customers_by_name")
public class CustomersByName extends View {

  public record CustomerSummary(String customerId, String name, String email) { }

  @Consume.FromKeyValueEntity(CustomerEntity.class)
  public static class CustomerByNameUpdater 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.

Handling Key Value Entity deletes

The View state corresponding to an Entity is not automatically deleted when the Entity is deleted.

We can update our table updater with an additional handler marked with @DeleteHandler, to handle a Key Value Entity delete operation.

@Consume.FromKeyValueEntity(value = CustomerEntity.class)
public static class Customers extends TableUpdater<CustomerSummary> { (1)
  public Effect<CustomerSummary> onUpdate(Customer customer) {
    return effects()
        .updateRow(new CustomerSummary(updateContext().eventSubject().get(), customer.name()));
  }

  // ...
  @DeleteHandler (2)
  public Effect<CustomerSummary> onDelete() {
    return effects().deleteRow(); (3)
  }
}
1 Note we are adding a new handler to the existing table updater.
2 Marks the method as a delete handler.
3 An effect to delete the view row effects().deleteRow(). It could also be an update of a special column, to mark the view row as deleted.

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.Migration;
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’s 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.ComponentId;
import akka.javasdk.annotations.Consume;
import akka.javasdk.annotations.Query;
import akka.javasdk.view.TableUpdater;
import akka.javasdk.view.View;
import customer.domain.CustomerEvent;
import customer.domain.CustomerRow;
import customer.domain.CustomersList;

@ComponentId("view_customers_by_name") (1)
public class CustomerByNameView extends View {

  @Consume.FromEventSourcedEntity(CustomerEntity.class)
  public static class CustomersByName extends TableUpdater<CustomerRow> { (2)

    public Effect<CustomerRow> onEvent(CustomerEvent event) { (3)
      return switch (event) {
        case CustomerEvent.CustomerCreated created ->
            effects().updateRow(new CustomerRow(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<CustomersList> getCustomers(String name) {
    return queryResult();
  }

}
1 Defines a component id for the view.
2 Declares a TableUpdater of type CustomerRow.
3 Handles the super type CustomerEvent and defines the proper update row method for each subtype.

Ignoring events

You can ignore events by returning Effect.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

The View row corresponding to an Entity is not automatically deleted when the Entity is deleted.

To delete from the View you can use the deleteRow() effect from an event transformation method, similarly to the example shown above for a Key Value Entity.

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. See the example below for how to pass such metadata when producing to a topic with Akka SDK.
@ComponentId("counter-journal-to-topic-with-meta")
@Consume.FromEventSourcedEntity(CounterEntity.class)
@Produce.ToTopic("counter-events-with-meta")  (1)
public class CounterJournalToTopicWithMetaConsumer extends Consumer {

  public Effect onEvent(CounterEvent event) {
    String counterId = messageContext().metadata().asCloudEvent().subject().get(); (2)
    Metadata metadata = Metadata.EMPTY.add("ce-subject", counterId);
    logger.info("Received event for counter id {}: {}", counterId, event);
    return effects().produce(event, metadata); (3)
  }
}
1 Uses @Consume.FromTopic annotation to set the target topic.
2 Composes the ce-subject attribute from the entity’s event subject.
3 Returns a producing effect with the updated metadata together with the message payload.

How to transform results

When creating a View, you can transform the results as a projection for constructing a new type instead of using a SELECT * statement.

Result projection

Instead of using SELECT * you can define which columns will be used in the response message. If you want to use a CustomerSummary used on the previous section, you will need to define your entity as this:

@Query("SELECT id, name FROM customers WHERE name = :customerName") (1)
public QueryEffect<CustomerSummary> getCustomer(String customerName) {
  return queryResult(); (2)
}
1 Note the renaming from customerId as id on the query, as id and name match the record CustomerSummary.
2 Returns the query result.

In a similar way, you can include values from the request in the response, for example :requestId:

SELECT :requestId, customerId as id, name FROM customers
WHERE name = :customerName

Multiple results

Oftentimes a query might be designed to return multiple results. In this case, you can either:

  • Wrap the results in a Collection field in the response type.

  • Stream the results to the client.

Wrapping results in a Collection

To include the results in a Collection field in the response object, you can do as below:

public record CustomerList(Collection<Customer> customers) { }
public class CustomersResponseByName extends View {

  @Consume.FromKeyValueEntity(CustomerEntity.class)
  public static class Customers extends TableUpdater<Customer> { } (1)

  @Query("""
    SELECT * AS customers
      FROM customers_by_name
      WHERE name = :name
    """) (2)
  public QueryEffect<CustomerList> getCustomers(String name) { (3)
    return queryResult();
  }
}
1 Table updater type is the original Customer as shown at the beginning of this section.
2 Note the use of * AS customers so records are matched to customers field in CustomersList.
3 Return type of the query is CustomersList.

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.

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, modify the returned type to be QueryStreamEffect instead and use queryStreamResult() to return the stream, then add streamUpdates = true to the Query annotation.

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 aren’t 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

Incompatible changes

Some specific scenarios might require a complete rebuild of the View, for example:

  • adding or removing tables for multi-table views;

  • changing the data type of a column that is part of an index.

Such changes require you to define a new View. Akka will then rebuild it from the source event log or value changes.

You should be able to test if a change is compatible locally by running the service with persistence mode enabled, producing some data, and then changing the View query and re-running the service. If the service boots up correctly and is able to serve the new query, the change is compatible.

Rebuilding a new View may take some time if there are many events that have to be processed. The recommended way when changing a View is multi-step, with two deployments:

  1. Define the new View with a new @ComponentId, and keep the old View intact.

  2. Deploy the new View, and let it rebuild. Verify that the new query works as expected. The old View can still be used.

  3. Remove the old View and redirect the endpoint calls to the new View.

  4. Deploy the second change.

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.

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.

Query syntax reference

Define View queries in a language that is similar to SQL. The following examples are added to illustrate the syntax.

Retrieving

  • All customers without any filtering conditions (no WHERE clause):

    SELECT * FROM customers
  • Customers with a name matching the customer_name property of the request message:

    SELECT * FROM customers WHERE name = :customer_name
  • Customers matching the customer_name AND city properties of the request message, with city being matched on a nested field:

    SELECT * FROM customers WHERE name = :customer_name AND address.city = :city
  • Customers in a city matching a literal value:

    SELECT * FROM customers WHERE address.city = 'New York'

Filter predicates

Use filter predicates in WHERE conditions to further refine results.

Comparison operators

The following comparison operators are supported:

  • = equals

  • != not equals

  • > greater than

  • >= greater than or equals

  • < less than

  • <= less than or equals

Logical operators

Combine filter conditions with the AND or OR operators, and negate using the NOT operator. Group conditions using parentheses.

SELECT * FROM customers WHERE
  name = :customer_name AND NOT (address.city = 'New York' AND age > 65)

Array operators

Use IN or = ANY to check whether a value is contained in a group of values or in an array column or parameter (a repeated field in the Protobuf message).

Use IN with a list of values or parameters:

SELECT * FROM customers WHERE email IN ('[email protected]', :some_email)

Use = ANY to check against an array column (a repeated field in the Protobuf message):

SELECT * FROM customers WHERE :some_email = ANY(emails)

Or use = ANY with a repeated field in the request parameters:

SELECT * FROM customers WHERE email = ANY(:some_emails)

Pattern matching

Use LIKE to pattern match on strings. The standard SQL LIKE patterns are supported, with _ (underscore) matching a single character, and % (percent sign) matching any sequence of zero or more characters.

SELECT * FROM customers WHERE name LIKE 'Bob%'
For index efficiency, the pattern must have a non-wildcard prefix or suffix as used in the query above. A pattern like '%foo%' is not supported. Given this limitation, only constant patterns with literal strings are supported; patterns in request parameters are not allowed.

Use the text_search function to search text values for words, with automatic tokenization and normalization based on language-specific configuration. The text_search function takes the text column to search, the query (as a parameter or literal string), and an optional language configuration.

text_search(<column>, <query parameter or string>, [<configuration>])

If the query contains multiple words, the text search will find values that contain all of these words (logically combined with AND), with tokenization and normalization automatically applied.

The following text search language configurations are supported: 'danish', 'dutch', 'english', 'finnish', 'french', 'german', 'hungarian', 'italian', 'norwegian', 'portuguese', 'romanian', 'russian', 'simple', 'spanish', 'swedish', 'turkish'. By default, a 'simple' configuration will be used, without language-specific features.

SELECT * FROM customers WHERE text_search(profile, :search_words, 'english')
Text search is currently only available for deployed services, and can’t be used in local testing.

Data types

When modeling your queries, the following data types are supported:

Data type Java type

Text

String

Integer

int / Integer

Long (Big Integer)

long / Long

Float (Real)

float / Float

Double

double / Double

Boolean

boolean / Boolean

Byte String

ByteString

Array

Collection<T> and derived

Timestamp

java.time.Instant

Optional fields

Fields in a view type that were not given a value are handled as the default value for primitive Java data types.

However, in some use cases it is important to explicitly express that a value is missing, doing that in a view column can be done in two ways:

  • use one of the Java non-primitive types for the field (e.g. use Integer instead of int)

  • make the field a part of another class and leave it uninitialized (i.e. null), for example address.street where the lack of an address message implies there is no street field.

Optional fields with values present can be queried just like regular view fields:

SELECT * FROM customers WHERE phone_number = :number

Finding results with missing values can be done using IS NULL:

SELECT * FROM customers WHERE phone_number IS NULL

Finding entries with any value present can be queried using IS NOT NULL:

SELECT * FROM customers WHERE phone_number IS NOT NULL

Optional fields in query requests messages are handled like normal fields if they have a value, however missing optional request parameters are seen as an invalid request and lead to a bad request response.

Sorting

Results for a view query can be sorted. Use ORDER BY with view columns to sort results in ascending (ASC, by default) or descending (DESC) order.

If no explicit ordering is specified in a view query, results will be returned in the natural index order, which is based on the filter predicates in the query.

SELECT * FROM customers WHERE name = :name AND age > :min_age ORDER BY age DESC
Some orderings may be rejected, if the view index cannot be efficiently ordered. Generally, to order by a column it should also appear in the WHERE conditions.

Paging

Splitting a query result into one "page" at a time rather than returning the entire result at once is possible in two ways:

  • a count based offset;

  • a token based offset.

In both cases OFFSET and LIMIT are used.

OFFSET specifies at which offset in the result to start

LIMIT specifies a maximum number of results to return

Count based offset

The values can either be static, defined up front in the query:

SELECT * FROM customers LIMIT 10

Or come from fields in the request message:

SELECT * FROM customers OFFSET :start_from LIMIT :max_customers

Note: Using count based offsets can lead to missing or duplicated entries in the result if entries are added to or removed from the view between requests for the pages.

Token based offset

The count based offset requires that you keep track of how far you got by adding the page size to the offset for each query.

An alternative to this is to use a string token emitted by Akka identifying how far into the result set the paging has reached using the functions next_page_token() and page_token_offset().

When reading the first page, an empty token is provided to page_token_offset. For each returned result page a new token that can be used to read the next page is returned by next_page_token(), once the last page has been read, an empty token is returned. (See here for determining if the last page was reached).

The size of each page can optionally be specified using LIMIT, if it is not present a default page size of 100 is used.

With the query return type like this:

public record Response(List<Customer> customers, String next_page_token) { }

A query such as the one below will allow for reading through the view in pages, each containing 10 customers:

SELECT * AS customers, next_page_token() AS next_page_token
FROM customers
OFFSET page_token_offset(:page_token)
LIMIT 10

The token value is not meant to be parseable into any meaningful information other than being a token for reading the next page.

Total count of results

To get the total number of results that will be returned over all pages, use COUNT(*) in a query that projects its results into a field. The total count will be returned in the aliased field (using AS) or otherwise into a field named count.

SELECT * AS customers, COUNT(*) AS total, has_more() AS more FROM customers LIMIT 10

Check if there are more pages

To check if there are more pages left, you can use the function has_more() providing a boolean value for the result. This works both for the count and token based offset paging, and also when only using LIMIT without any OFFSET:

SELECT * AS customers, has_more() AS more_customers FROM customers LIMIT 10

This query will return more_customers = true when the view contains more than 10 customers.

Advanced view queries

Advanced view queries include additional sort operations, grouping operations, joins across tables, and subquery support.

Advanced view queries might not be available by default. Please contact the Akka support team if you require access to these features.

Joins and multiple tables

Advanced views can subscribe to events and changes from multiple entities or event sources. Data for multiple tables can then be joined using relational join operations, similar to SQL. Supported join types are:

  • (INNER) JOIN - only returns entries with matching values in both tables

  • LEFT (OUTER) JOIN - returns all entries in the left table, joined with any matching entries from the right table

  • RIGHT (OUTER) JOIN - returns all entries in the right table, joined with any matching entries from the left table

  • FULL (OUTER) JOIN - returns all entries from both tables, with joined entries for matching values

In these examples, the Customer Registry used for simple views is extended to be a simple Store, adding Products and Orders for Customers. Customers and Products are implemented using Event Sourced Entities, while Orders is a Key Value Entity.

Each Product includes a name and a price:

public record Product(String name, Money price) {
  public Product withName(String newName) {
    return new Product(newName, price);
  }

  public Product withPrice(Money newPrice) {
    return new Product(name, newPrice);
  }
}
public record Money(String currency, long units, int cents) {
}

Each Order has an id, refers to the Customer and Product ids for this order, has the quantity of the ordered product, and a timestamp for when the order was created:

public record Order(
  String orderId,
  String productId,
  String customerId,
  int quantity,
  long createdTimestamp) {
}

A view can subscribe to the events or changes for each of the Customer, Order, and Product entities.

The view query can then JOIN across these tables, to return all orders for a specified customer, and include the customer and product details with each order.

To do this, create a class with a ComponentId annotation and extending from View. Inside, various inner classes that extend TableUpdater can be declared, each subscribing to one of the entities and setting a table name.

public record CustomerOrder(
  String orderId,
  String productId,
  String productName,
  Money price,
  int quantity,
  String customerId,
  String email,
  String name,
  Address address,
  long createdTimestamp) {
}
@ComponentId("joined-customer-orders") (1)
public class JoinedCustomerOrdersView extends View {

  @Table("customers") (2)
  @Consume.FromEventSourcedEntity(CustomerEntity.class)
  public static class Customers extends TableUpdater<Customer> {
    public Effect<Customer> onEvent(CustomerEvent event) {
      return switch (event) {
        case CustomerEvent.CustomerCreated created -> {
          String id = updateContext().eventSubject().orElse("");
          yield effects()
            .updateRow(new Customer(id, created.email(), created.name(), created.address()));
        }

        case CustomerEvent.CustomerNameChanged nameChanged ->
          effects().updateRow(rowState().withName(nameChanged.newName()));

        case CustomerEvent.CustomerAddressChanged addressChanged ->
          effects().updateRow(rowState().withAddress(addressChanged.newAddress()));
      };
    }
  }

  @Table("products") (2)
  @Consume.FromEventSourcedEntity(ProductEntity.class)
  public static class Products extends TableUpdater<Product> {
    public Effect<Product> onEvent(ProductEvent event) {
      return switch (event) {
        case ProductEvent.ProductCreated created -> {
          String id = updateContext().eventSubject().orElse("");
          yield effects().updateRow(new Product(id, created.name(), created.price()));
        }

        case ProductEvent.ProductNameChanged nameChanged ->
          effects().updateRow(rowState().withProductName(nameChanged.newName()));

        case ProductEvent.ProductPriceChanged priceChanged ->
          effects().updateRow(rowState().withPrice(priceChanged.newPrice()));
      };
    }
  }

  @Table("orders") (2)
  @Consume.FromKeyValueEntity(OrderEntity.class)
  public static class Orders extends TableUpdater<Order> {
  }

  public record CustomerOrders(List<CustomerOrder> orders) { }

  @Query( (3)
      """
        SELECT * AS orders
        FROM customers
        JOIN orders ON customers.customerId = orders.customerId
        JOIN products ON products.productId = orders.productId
        WHERE customers.customerId = :customerId
        ORDER BY orders.createdTimestamp
        """)
  public QueryEffect<CustomerOrders> get(String customerId) { (4)
    return queryResult();
  }

}
1 Add a component id for this multi-table view.
2 Each nested table updater stores its state type in a different table (declared with @Table) and subscribes to one of the entities: customers, products, and orders.
3 The view query does the following:
  • Select all columns from the joined entries to project into the combined CustomerOrder result type.

  • Join customers with orders on a matching customer id.

  • Join products with orders on a matching product id.

  • Find orders for a particular customer.

  • Sort all the orders by their created timestamp.

4 The query method returns a collections of customer orders.

In the example above, each CustomerOrder returned will contain the same customer details. The results can instead include the customer details once, and then all the ordered products in a collection, using a projection in the SELECT clause. That is, instead of using SELECT * you can define which columns will be used in the response message:

public record CustomerOrders(
  String customerId,
  String email,
  String name,
  Address address,
  List<CustomerOrder> orders) {
} (1)
1 The orders field will contain the nested CustomerOrder objects.
public record CustomerOrder(
  String customerId,
  String orderId,
  String productId,
  String productName,
  Money price,
  int quantity,
  long createdTimestamp) {
}
@Query( (1)
  """
    SELECT customers.*, (orders.*, products.*) AS orders
    FROM customers
    JOIN orders ON customers.customerId = orders.customerId
    JOIN products ON products.productId = orders.productId
    WHERE customers.customerId = :customerId
    ORDER BY orders.createdTimestamp
    """)
public QueryEffect<CustomerOrders> get(String customerId) { (2)
  return queryResult();
}
1 In the view query, the customer columns are projected into the result, and the order and product columns are combined into a nested object and projected into the orders field.
2 A single CustomerOrders object is returned, which will have the customer details and all orders for this customer.

A projection for a JOIN query can also restructure the results. For example, the shipping details for a customer can be constructed in a particular form, and the product orders transformed into a different nested message structure:

public record CustomerOrders(
  String id,
  CustomerShipping shipping,
  List<ProductOrder> orders) {
}
public record CustomerShipping(
  String name,
  String address1,
  String address2,
  String contactEmail) {
}
public record ProductOrder(
  String id,
  String name,
  int quantity,
  ProductValue value,
  String orderId,
  long orderCreatedTimestamp) {
}
public record ProductValue(String currency, long units, int cents) {
}
@Query( (1)
  """
    SELECT
     customers.customerId AS id,
     (name,
      address.street AS address1,
      address.city AS address2,
      email AS contactEmail) AS shipping,
     (products.productId AS id,
      productName AS name,
      quantity,
      (price.currency, price.units, price.cents) AS value,
      orderId,
      createdTimestamp AS orderCreatedTimestamp) AS orders
    FROM customers
    JOIN orders ON orders.customerId = customers.customerId
    JOIN products ON products.productId = orders.productId
    WHERE customers.customerId = :customerId
    ORDER BY orders.createdTimestamp
    """)
public QueryEffect<CustomerOrders> get(String customerId) {
  return queryResult();
}
1 The view query does the following:
  • The customerId is renamed to just id in the result.

  • Customer shipping details are transformed and combined into a nested object.

  • The product price is reconstructed into a ProductValue object, nested within the order object.

  • The order and associated product information is transformed and combined into a collection of ProductOrder objects.

  • The nested orders in the result will still be sorted by their created timestamps.

Rather than transforming results in a projection, it’s also possible to transform the stored state in the update methods for the view table.

Enable advanced views

Advanced view queries are not available by default when you deploy your Akka service. Please contact the Akka support team if you require access to these features.

For local development and when running integration tests, the advanced view features are available by default.

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 Customers 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();
  }
}

An integration test can be implemented as below.

class CustomersByCityIntegrationTest extends TestKitSupport {

  @Override
  protected TestKit.Settings testKitSettings() {
    return TestKit.Settings.DEFAULT
            .withKeyValueEntityIncomingMessages("customer"); (1)
  }

  @Test
  public void shouldGetCustomerByCity() {
    IncomingMessages customerEvents = testKit.getKeyValueEntityIncomingMessages("customer"); (2)

    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 =
            await(
              componentClient.forView()
                .method(CustomersByCity::getCustomers) (4)
                .invokeAsync(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 customer Key Value Entity.
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.

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.

Exposing views directly

You would normally not expose Entities, Workflows and Views directly to the outside world, but have an Endpoint or Consumer in front of it.

However, a built-in HTTP endpoint for the component can be enabled. The primary purpose of this component endpoint is for convenience in local development so that you can try the entity with curl without having to define the final HttpEndpoint.

Enable access

To enable the component’s HTTP endpoint you can disable access control checks in application.conf:

src/main/resources/application.conf
akka.javasdk.dev-mode.acl.enabled = false

Alternatively, start with:

mvn compile exec:java -Dakka.javasdk.dev-mode.acl.enabled=false

Note that this acl configuration is only used when running locally and not in production. To enable component endpoints in production you must add @Acl to the component class:

// Opened up for access from the public internet.
// For actual services meant for production this must be carefully considered,
// and often set more limited
@Acl(allow = @Acl.Matcher(principal = Acl.Principal.INTERNET))

API

The view is exposed at a fixed path:

/akka/v1.0/view/<component id>/<method>

Taking the sample from the first section as an example, that would be:

curl localhost:9000/akka/v1.0/view/view_customers_by_email/getCustomer \
  --header "Content-Type: application/json" \
  -XPOST \
  --data '{"email":"[email protected]"}'