Skip to content

Views

A view is a queryable table built from one source's changes. An entity can only be found by its id, so any other question — which carts contain this product, which orders are unpaid, which are the largest — needs a view. The runtime reads the source's changes in order, hands each one to the view's handler with the current row for that source id, and stores the row the handler returns.

A view keeps one row per source id. The row's key is always the id of the entity the change came from, and every other way of finding rows is a query, not a second key. Re-keying a row by an attribute would silently orphan the old row the first time the attribute changed.

Sources

A view reads exactly one source:

Source Scala Python Delivery
An event sourced entity's events ChangeSource.eventsOf(ShoppingCartEntity) source = ShoppingCartEntity Every event, in order, exactly once.
A key value entity's state ChangeSource.stateOf(PreferencesEntity) source = CheckoutLog The latest value; intermediate values can be skipped.
A broker topic ChangeSource.fromTopic("stock-events", serializer) topic = "stock-events" At least once; see Broker topics.

The source is built from the source component's own declaration, so a view over the cart is typed against the cart's event type and decodes with the cart's own serializer. It cannot drift when the entity's serializer changes.

Writing a view

A view declares its row type and a handler for changes. The handler receives one change and the current row for its source id, and returns what should happen to the row:

Scala

package shoppingcart.application

import com.thinkmorestupidless.ankka.core.{Codecs, ComponentId}
import com.thinkmorestupidless.ankka.sdk.*
import shoppingcart.domain.ShoppingCartEvent
import shoppingcart.domain.ShoppingCartEvent.*

/**
 * A queryable projection of every cart.
 *
 * The entity can only be found by cart id. This view exists to answer the questions it cannot:
 * which carts contain a product, which have been checked out, which are the largest.
 */
final case class CartRow(
    cartId: String,
    quantities: Map[String, Int],
    checkedOut: Boolean
):
  def productIds: List[String] = quantities.keys.toList.sorted
  def totalQuantity: Int       = quantities.values.sum

final class CartRowsView extends View[ShoppingCartEvent, CartRow]:

  def onChange(event: ShoppingCartEvent): Effect =
    val current = rowState.getOrElse(CartRow(updateContext.subject, Map.empty, checkedOut = false))
    event match
      case ItemAdded(item) =>
        val existing = current.quantities.getOrElse(item.productId, 0)
        effects.updateRow(
          current.copy(quantities =
            current.quantities.updated(item.productId, existing + item.quantity)
          )
        )
      case ItemRemoved(productId) =>
        effects.updateRow(current.copy(quantities = current.quantities - productId))
      case CheckedOut =>
        effects.updateRow(current.copy(checkedOut = true))

  /**
   * Keeps the row after the cart is deleted.
   *
   * Checkout deletes the entity, but a checked-out cart is exactly what an order history needs.
   * This is the tombstone case: the row outlives the entity that produced it.
   */
  override def onDelete: Effect =
    rowState match
      case Some(row) => effects.updateRow(row.copy(checkedOut = true))
      case None      => effects.ignore()

object CartRows
    extends View.Companion[CartRowsView, ShoppingCartEvent, CartRow](
      componentId = ComponentId("cart-rows"),
      source = ChangeSource.eventsOf(ShoppingCartEntity),
      rowSerializer = Codecs.serializer[CartRow]("cart-row")
    ):
  def create(ctx: ViewComponentContext) = new CartRowsView

Python

"""A queryable projection of every cart: the entity answers by cart id, this answers the rest —
which carts contain a product, which have been checked out, which are the largest."""

from __future__ import annotations

from dataclasses import dataclass, field, replace

from ankka import json_codec
from ankka.effects.view import ViewEffect
from ankka.view import View

from examples.shopping_cart.domain import CheckedOut, ItemAdded, ItemRemoved, ShoppingCartEvent
from examples.shopping_cart.entity import ShoppingCartEntity


@dataclass(frozen=True)
class CartRow:
    cartId: str
    quantities: dict[str, int] = field(default_factory=dict)
    checkedOut: bool = False

    @property
    def total_quantity(self) -> int:
        return sum(self.quantities.values())


class CartRows(View[ShoppingCartEvent, CartRow]):
    component_id = "cart-rows"
    source = ShoppingCartEntity
    event_codec = ShoppingCartEntity.event_codec
    row_codec = json_codec(CartRow, "cart-row")
    queries = ("by-id", "all")

    def on_change(self, event: ShoppingCartEvent) -> ViewEffect:
        current = self.row or CartRow(self.metadata.subject or "")
        match event:
            case ItemAdded(item):
                quantities = {**current.quantities, item.productId: current.quantities.get(item.productId, 0) + item.quantity}
                return self.effects.update_row(replace(current, quantities=quantities))
            case ItemRemoved(product_id):
                return self.effects.update_row(replace(current, quantities={k: v for k, v in current.quantities.items() if k != product_id}))
            case CheckedOut():
                return self.effects.update_row(replace(current, checkedOut=True))
        raise AssertionError(event)

    def on_delete(self) -> ViewEffect:
        """Checkout deletes the cart, but a checked-out cart is exactly what an order history
        needs: the row outlives the entity that produced it."""
        if self.row is None:
            return self.effects.ignore()
        return self.effects.update_row(replace(self.row, checkedOut=True))
Scala Python
The current row, or none rowState: Option[Row] self.row, None when there is none
The source's id updateContext.subject self.metadata.subject
Handle a change def onChange(change: Src): Effect def on_change(self, event) -> ViewEffect
Handle a deletion override def onDelete: Effect def on_delete(self) -> ViewEffect

A handler returns one of three effects:

Scala Python Meaning
effects.updateRow(row) self.effects.update_row(row) Store row as the row for this source id, replacing any previous one.
effects.deleteRow() self.effects.delete_row() Remove the row.
effects.ignore() self.effects.ignore() Leave the row as it is.

Build the new row from the current one rather than from scratch, as the sample does. The handler sees one change at a time, so the row is the only memory it has of the changes before.

When the source is deleted

When a source entity is deleted, the view's deletion handler runs. By default it removes the row. Override it to keep a tombstone instead: the cart deletes itself on checkout, and its view keeps the row and marks it checked out, because a checked-out cart is exactly what an order history needs. The row outlives the entity that produced it.

Registering a view

A view runs only in a service that has the projection runtime. In Scala, register the view's descriptor and add ProjectionRuntime() as an extension; without it, every command still succeeds and every view stays empty. In Python, register the class; the sidecar runs the projection.

Scala

import com.thinkmorestupidless.ankka.runtime.{Ankka, ProjectionRuntime}

val service = Ankka.service
  .register(ShoppingCartEntity.descriptor)
  .register(CartRows.descriptor)
  .withExtension(ProjectionRuntime())
  .start()

Python

service = Ankka.service().register(ShoppingCartEntity).register(CartRows)

A view over a topic also needs a broker. See Broker topics.

Querying a view in Scala

A view's rows live in a Postgres table, one row per source id, with the row stored as JSON. Queries are real SQL over that JSON, not a query language of ankka's own. Get a handle on a view's rows from the view client:

private def rows = testKit.service.viewClient.forView(CartRows)

The view client is testKit.service.viewClient in a test, clients.viewClient in an HTTP endpoint, and service.viewClient on a started service. The handle offers:

Method Returns
get(key) The row for one source id, or None.
where(condition, limit = 1000) Every row matching a SQL condition.
ordered(condition, order, limit = 1000) Matching rows in a SQL order.
all(limit = 1000) Every row.
count(condition) How many rows match; every row with no condition.

Each has an …Async variant returning a Future, for fanning several queries out at once.

Conditions are built with the sql"…" interpolator and a few helpers that reach into the row's JSON, all from com.thinkmorestupidless.ankka.runtime.SqlSyntax:

// Query by an attribute rather than by key — the reason views exist.
val found = rows.where(jsonText("cartId") ++ sql" = ${"view-q-1"}")
assert(rows.count() >= 1L)
assert(rows.all().nonEmpty)
assert(rows.count(jsonText("cartId") ++ sql" = ${"nope-does-not-exist"}") == 0L)
Helper Renders Use
jsonText("email") payload::jsonb->>'email' Compare a text field.
jsonText("address", "city") payload::jsonb->'address'->>'city' A nested text field.
jsonNumber("total") (payload::jsonb->>'total')::numeric Compare or order numerically.
jsonContains("members", "alice") payload::jsonb->'members' @> '["alice"]' Whether a JSON array contains a value.

Values interpolated into sql"…" are never spliced into the SQL text. sql" = ${email}" renders as = $1 with email bound as a parameter, so user input in a query cannot become SQL injection. Fragments join with ++, which renumbers their parameters. SqlFragment.raw(text) adds SQL with no parameters and is only for text the developer writes, never for input.

An ordered query over the cart rows, returning the first twenty open carts by id, looks like this:

import com.thinkmorestupidless.ankka.runtime.SqlFragment
import com.thinkmorestupidless.ankka.runtime.SqlSyntax.{jsonText, sql}

val openCarts = rows.ordered(
  condition = jsonText("checkedOut") ++ sql" = ${"false"}",
  order = jsonText("cartId") ++ SqlFragment.raw(" DESC"),
  limit = 20
)

jsonText reads every field as text, so a boolean compares against "true" or "false". Use jsonNumber to compare or order a numeric field as a number rather than as text.

The table for a view is named ankka_view_ followed by the component id with every character that is not a letter or digit replaced by _: cart-rows is stored in ankka_view_cart_rows. A query that must be fast at scale needs a Postgres expression index on the same term the query uses, such as ((payload::jsonb->>'cartId')).

Querying a view in Python

A Python service reads a view through the component client, by source id or all at once:

row = await self.client.views.get("cart-rows", cart_id, CartRow)     # one row, or None
rows = await self.client.views.all("cart-rows", CartRow)             # every row, up to 1000

These are the only two queries a view in a Python service answers. SQL conditions over the rows are available only to a Scala service today.

Consistency

A view is eventually consistent with its source. A command's reply is sent once its events are persisted, and the view catches up shortly afterwards, usually within milliseconds and not within any bound. A read of a view immediately after a write may not see the write yet, while a read of the entity always does. Read the entity when the answer must include the caller's own last change; read the view to find things.

Over an event sourced entity, a view is exactly once: the row update and the record of how far the view has read are committed in one transaction, so a crash between them cannot apply a change twice. Over a key value entity or a topic, delivery is at least once, and the handler should give the same row when it sees a change twice. See Consistency and delivery.

In a test, poll for the expected row until a deadline instead of reading once. That is not a workaround for a race; it is the consistency model the view actually has.

Limits

  • A view reads one source and writes one table. Joining two sources into one view is not supported; project each into its own view and combine the results where they are read.
  • A view does not rebuild its rows when its code changes. A changed handler applies to changes from then on.
  • A view over a topic sees only messages published after it started.

See Limitations for the full list.

Testing

In Python, ViewTestKit feeds changes to a view and keeps one row per key, as the sidecar would, with no sidecar:

kit = ViewTestKit.of(CartRows)
kit.on_change("c1", ItemAdded(LineItem("p1", "Pen", 2)))
assert kit.get("c1") == CartRow("c1", {"p1": 2}, False)
assert isinstance(kit.on_delete("c1"), UpdateRow)

In Scala, a view is tested with the integration testkit, which runs the real projection against a real database:

testKit = AnkkaTestKit.start(
  Seq(ShoppingCartEntity.descriptor, CartRows.descriptor, CheckoutNotifier.descriptor),
  Seq(ProjectionRuntime.withPublisher(publisher))
)

See Testing.