
Building with Akka: Customer Registry App (part 3 – Views and APIs)

Welcome to the next part of our “Building with Akka” series, where we implement the API layer and interaction mechanisms for our Customer Registry Application. In the previous section, we presented the core logic of our application: the domain model (events and commands) and the customer entity. In this section, we show how to implement the API layer and how to interact with the application. This article is part of the series and, in case you missed them, we recommend starting with this introduction.
Alright, we are now ready to introduce views!
Views
Views provide flexible ways to access your data. They are eventually consistent by design, so they are not updated immediately when the entity state changes. The changes will eventually be visible in the view, but it is not instant, especially during failure scenarios and network instability.
In our application, we are going to create two views for the CustomerEntity component. Remember that the customer entity is an event-sourced entity. The views will be later used through the HTTP APIs. They are:
CustomerByNameView: get all customers by name;CustomerByEmailView: get all customers by email.
A view must be configured to consume the events produced by the ConsumerEntity. To do so, we define a new component which extends the View type, say CustomerByNameView. Then, we implement a inner class CustomersByName, which extends a TableUpdater. The abstract method onEvent must be implemented: for each customer event, we define the effect it has on our view.
package customer.application;
import akka.javasdk.annotations.Consume;
import akka.javasdk.annotations.Query;
import akka.javasdk.view.TableUpdater;
import akka.javasdk.view.View;
// other imports omitted
@ComponentId("view_customers_by_name")
public class CustomerByNameView extends View {
@Consume.FromEventSourcedEntity(CustomerEntity.class)
public static class CustomersByName extends TableUpdater<CustomerRow> {
public Effect<CustomerRow> onEvent(CustomerEvent event) {
return switch (event) {
case CustomerEvent.CustomerCreated created -> {
AddressRow addressRow = new AddressRow(
created.address().street(),
created.address().city()
);
CustomerRow customerRow = new CustomerRow(
created.email(),
created.name(),
addressRow
);
yield effects().updateRow(customerRow);
}
case CustomerEvent.NameChanged nameChanged ->
effects().updateRow(
rowState().withName(nameChanged.newName())
);
case CustomerEvent.AddressChanged addressChanged -> {
AddressRow addressRow = new AddressRow(
addressChanged.address().street(),
addressChanged.address().city()
);
yield effects().updateRow(
rowState().withAddress(addressRow)
);
}
};
}
}
// ... other consumers and/or queries
}The implementation handles the events in a switch-case and updates the view’s state via the updateRow() method, which is available through the effects() API. It’s also possible to ignore events by using the ignore() method, and react to an entry deletion by calling deleteRow(). Also note the usage of classes CustomerRow and AddressRow, which mimic the domain classes and provide a better separation between the business model and the view model, and the introduction of the CustomersList wrapper, defines as:
public record CustomersList(Collection<CustomerRow> customers) { }In order to query the view, one or more queries can be defined. For more details about the query capabilities see the View Query Language page.
@Query("SELECT * as customers FROM customers_by_name WHERE name = :name")
public QueryEffect<CustomersList> getCustomers(String name) {
return queryResult();
}Query methods define the type of query parameters as their input, and the type of the returned result as the type parameter of QueryEffect<T>, which is CustomersList in our example.
We won’t cover the implementation of CustomerByEmailView, which is almost identical to the previous one.
The query strings and table field types defines which fields should be indexed and how the query will be executed. The query is executed by the runtime when a request is made to the View component.
Views support both streaming and pagination, which are crucial for handling large collections, and other features such as aggregations, sorting, and text search capabilities. For more information about views, we recommend exploring the Views page.
Endpoint
In order to interact with the Customer entity, an API layer is needed. Creating an HTTP interface is pretty easy, and the CustomerEndpoint class shows how to define the endpoints.
The class declaration looks like the following:
package customer.api;
import akka.http.javadsl.model.HttpResponse;
import akka.javasdk.annotations.Acl;
import akka.javasdk.annotations.http.HttpEndpoint;
import akka.javasdk.client.ComponentClient;
// other imports omitted
// Opened up for access from the public internet to make the sample service easy to try out.
// For actual services meant for production this must be carefully considered, and often set more limited
@Acl(allow = @Acl.Matcher(principal = Acl.Principal.ALL))
@HttpEndpoint("/customer")
public class CustomerEndpoint {
private final ComponentClient componentClient;
public CustomerEndpoint(ComponentClient componentClient) {
this.componentClient = componentClient;
}
// ...
}The class is annotated with @HttpEndpoint, which defines the base path /customer. The base path is used for all the endpoints defined within the class. The annotation @Acl defines the access control list rules for the endpoints.
For the purpose of this article, the access is open from the public internet. However, for actual services meant for production this must be carefully considered, and often set more limited. You can find more information in the Access Control List concepts and Access Control Lists (ACLs) guides.
Component Client
The ComponentClient instance is injected through the constructor and it’s fundamental for the interaction between different components (such as the CustomerEntity) or with external services. Components in Akka can run on any node in your service cluster, potentially in different availability zones or cloud providers. That’s why components communicate through clients rather than direct method calls – the target component could be running locally or on a different machine, and Akka handles this location transparency automatically.
HTTP API
Let’s see how we can handle the creation and retrieval of a customer by defining two endpoints:
POST /customer/{id} {...payload...}GET /customer/id
The payload for the creation is:
{
"email": "test@example.com",
"name": "Testsson",
"address": {
"street": "Teststreet 25",
"city": "Testcity"
}
}It’s a good practice to separate the business model from the API model, so we create two new record classes:
record CreateCustomerRequest(
String email,
String name,
CreateAddressRequest address) { }
record CreateAddressRequest(
String street,
String city) { }The implementation for the endpoints is the following:
@Post("/{customerId}")
public CompletionStage<HttpResponse> create(
String customerId,
CreateCustomerRequest createCustomerRequest) {
log.info("Request to create customer: {}", createCustomerRequest);
Address address = new Address(
createCustomerRequest.address.street,
createCustomerRequest.address.city);
Customer customer = new Customer(
createCustomerRequest.email,
createCustomerRequest.name,
address);
return componentClient.forEventSourcedEntity(customerId)
.method(CustomerEntity::create)
.invokeAsync(customer)
.thenApply(__ -> HttpResponses.created());
}
@Get("/{customerId}")
public CompletionStage<Customer> getCustomer(String customerId) {
return componentClient.forEventSourcedEntity(customerId)
.method(CustomerEntity::getCustomer)
.invokeAsync()
.exceptionally(ex -> {
if (ex.getMessage().contains("No customer found for id"))
throw HttpException.notFound();
else
throw new RuntimeException(ex);
}
);
}In both methods, the call componentClient.forEventSourcedEntity() is used to retrieve the customer entity associated with the given customer id. The value of customerId represents the unique id of the entity instance. Then, the method() call is used to reference the appropriate method of the entity, and invokeAsync() is used to call the method asynchronously. Indeed, a CompletionStage is returned and:
thenApplyis used in the customer creation to map the result to a204 CreatedHTTP response;exceptionallyis used to catch the error when a customer does not exist.
The methods to patch a customer are the following:
PATCH /customer/{id}/name/{newName}PATCH /customer/{id}/address/ {...address payload...}
The implementation is very similar to the previous ones, so we won’t comment further.
@Patch("/{customerId}/name/{newName}")
public CompletionStage<HttpResponse> changeName(
String customerId,
String newName) {
log.info("Request to change customer [{}] name: {}",customerId, newName);
return componentClient.forEventSourcedEntity(customerId)
.method(CustomerEntity::changeName)
.invokeAsync(newName)
.thenApply(__ -> HttpResponses.ok());
}
@Patch("/{customerId}/address")
public CompletionStage<HttpResponse> changeAddress(
String customerId,
Address newAddress) {
log.info("Request to change customer [{}] address: {}",customerId, newAddress);
return componentClient.forEventSourcedEntity(customerId)
.method(CustomerEntity::changeAddress)
.invokeAsync(newAddress)
.thenApply(__ -> HttpResponses.ok());
}Finally, we want to leverage the CustomerByNameView and CustomerByEmailView views and expose the results through the following endpoints:
GET /consumer/{id}/by-name/{name}GET /consumer/{id}/by-email/{email}
@Get("/by-name/{name}")
public CompletionStage<CustomersList> userByName(String name) {
return componentClient.forView()
.method(CustomerByNameView::getCustomers)
.invokeAsync(name);
}
@Get("/by-email/{email}")
public CompletionStage<CustomersList> userByEmail(String email) {
return componentClient.forView()
.method(CustomerByEmailView::getCustomers)
.invokeAsync(email);
}Note the componentClient.forView() method invocation which doesn’t require any parameter.
You’ve made it! We completed the overview of all components and in the next part we’ll see how to run and interact with our customer registry application locally.



