Skip to content

Java SDK

The Java SDK is the library ankka-java. A Java service is hosted by ankka's runtime in the same JVM, as a Scala service is: there is no sidecar and no protocol between your classes and the platform. The runtime owns the journal, sharding, projections, timers and HTTP, and your handlers decide what each command does. This page lists what each component kind is made of. Your first service in Java builds one from the template.

Installing

The SDK is on Maven Central as three artifacts, each published by every ankka release at the release's version, with no Scala suffix:

Artifact Holds
com.thinkmorestupidless:ankka-java the components, effects, clients, endpoints and codec
com.thinkmorestupidless:ankka-java-testkit the component test kits and the whole-service kit, for tests
com.thinkmorestupidless:ankka-java-agent agents, for a service that declares them

Pin the version of the platform you deploy to, and say the same in the descriptor's runtime. The SDK needs JDK 21 or later. Compile with -parameters: an endpoint binds a path parameter to the method parameter of the same name, and without the flag the names are not in the class file. ankka init cart --language java writes a Maven project with both settings.

The SDK's packages carry JSpecify's @NullMarked: every parameter and return is non-null unless marked @Nullable, and a value that may be absent is an Optional.

Codec and types

Every value that crosses a boundary — into the journal, to another component, onto a topic, over HTTP — is encoded by the SDK's record codec, under the rules every ankka language shares, so one journal is readable from all of them; Serialization has the rules. The codec reads a type once, the first time a handler names it, and a type it has no form for stops the service at start, naming the member.

Java type On the wire
a record a JSON object, every component written under its name
a sealed interface (or sealed abstract class) whose cases are records the case's object with "type": "<Case>" first
an enum {"type":"<CONSTANT>"}; a bare string is read too
String, int/Integer, long/Long, double/Double, boolean/Boolean at top level text/plain; inside a record, JSON
BigDecimal a JSON number
Instant, LocalDate, LocalDateTime, UUID, Duration ISO-8601 or canonical strings inside a record; a top-level Duration is its milliseconds as text
byte[] raw bytes at top level; base64 inside a record
Optional<T> null when empty inside a record; at top level, no bytes for empty
List<T>, Set<T>, arrays, Map<String, T> a JSON array, a JSON object
Done, Void the empty done and unit payloads, as a reply

Refused, naming where: float, short, byte and char (use int, long or double, which every SDK reads); a map whose key is not a String; a record component named type, where a sum writes its case; a generic record, or a type variable the codec cannot resolve; and any other class, which needs a record, a sealed interface, an enum or a serializer of your own. A null in a record is refused when the value is written, naming the component (Cart.items[2].name: is null); an absent value is Optional.empty(). Reading is lenient where the encoding is — fields in any order, an unknown field ignored, an absent Optional empty — and strict where it is: a missing field fails, naming it.

A value is stored under its manifest: @Manifest("shopping-cart") on the type, or else the simple name in kebab case (ShoppingCartEvent is shopping-cart-event), which changes if the class is renamed. Name it once a journal holds the type.

The shape every component shares

A component is a class that extends its kind's base class, annotated @ComponentId, with a constructor that takes nothing (an entity's may take its EntityContext). Its handlers are methods annotated with their wire names; each takes one parameter or none and returns an effect built by effects(). A key value entity:

/** A key value entity holding a name. */
@ComponentId("profile")
public class Profile extends KeyValueEntity<Profile.ProfileState> {

  @Manifest("profile")
  public record ProfileState(String name) {}

  @Override
  protected ProfileState emptyState() {
    return new ProfileState("");
  }

  @Command("set")
  public Effect<String> set(String name) {
    if (name.isEmpty()) return effects().error("a name is needed");
    return effects().updateState(new ProfileState(name)).thenReply("done");
  }

  @Query("get")
  public ReadOnlyEffect<String> get() {
    return effects().reply(currentState().name().isEmpty() ? "none" : currentState().name());
  }

  @Command("delete")
  public Effect<String> delete() {
    return effects().deleteEntity().thenReply("done");
  }
}

@Command handlers return Effect; @Query handlers return ReadOnlyEffect, which reply and error build and updateState and persist do not, so a query cannot persist. A handler with two parameters takes a record instead. Components are registered by class: Ankka.service("shop").register(Profile.class), or register(Profile.class, "profile") for a class with no @ComponentId.

Method references and wire names

The string in @Command("add-item") is the wire name: what the platform stores, routes by, and what an in-flight call or a persisted timer addresses. The method's own name is yours, and renaming it changes nothing on the wire. Anywhere a handler is named in code — a call, a step to transition to, a step's settings, a timer's action, a test — it is named by a method reference:

componentClient().forEventSourcedEntity(id).call(ShoppingCart::addItem).invoke(item)
stepEffects().updateState(s).thenTransitionTo(Checkout::charge)
timers().schedule("remind-" + id, Duration.ofMinutes(5), Reminder::remind, id)

The reference is resolved through the service's registry to the annotation's string, once per call site, so the compiler checks the input and reply types and a renamed method still calls the same wire name. A lambda is not a reference: call(c -> c.addItem(item)) fails with a CallerError naming the calling class, and so does a reference to a method with no @Command or @Query, or to a component the service does not register. call(componentId, wireName, input, output) is the escape hatch for a component whose class is not on the classpath; the compiler checks nothing there.

Every declaration is read when the service starts. A handler with no wire name, an empty one, one with the platform's reserved ankka: prefix, two handlers with one wire name, a @Query that returns Effect, a static handler, a method that returns an effect with no annotation, or a type the codec refuses: each is a DeclarationProblem, and start() throws ServiceProblems listing every one, by class and member, before anything listens.

Module system

The SDK reads registered classes by reflection: it constructs components and endpoints, calls their handler methods (which need not be public), and reads and builds records. A service on the class path, as the template builds it, needs nothing for this. A service that declares a module-info.java must open every package that holds a component, an endpoint or a type the codec reads, or the start fails on the first inaccessible one. The SDK's jars declare no module name of their own, so open the packages to everyone: opens com.example.cart;.

Supplying a serializer

For a type the codec refuses, or one whose bytes you decide, hand the service a serializer of your own:

Ankka.service("shop")
    .register(Ledger.class)
    .withSerializer(Money.class, Serializer.of("money", Money::toBytes, Money::fromBytes))

Serializer.of(manifest, write, read) is the whole of it; contentType() is JSON unless the serializer overrides it. Every handler, row, event and message that names the type uses this serializer, in the service and in its test kits. The manifest is what the journal records the bytes under, so keep it stable. Another language reading the same journal must read the same bytes, which is then yours to keep. Serializer.of(Class) answers the SDK's own serializer for a type, for a test or a hand-written one built on it.

Event sourced entity

Part API
Base class EventSourcedEntity<S, E>
Annotations @ComponentId; @Command, @Query on handlers; optionally @SnapshotEvery(n) (100 by default, 0 never) and @Replicated
Must define applyEvent(S state, E event), the one place state changes; it reads nothing but its arguments
May define emptyState(), null unless overridden
In a handler currentState(), context() (entityId(), componentId(), componentClient(), metadata(key), sequenceNumber(), attribution(), installation(), isHome(), forwardedFrom()); in applyEvent, eventContext()
Effects effects().persist(e), persist(e1, e2), persist(e1, e2, e3), persistAll(list), deleteEntity(), then .thenReply(state -> r), .thenReply(r), .thenReplyState(), .thenDone(), .thenNoReply(), .thenDelete(), .expireAfter(d); effects().reply(r), error(message), error(problem), noReply()
Replication on a @Replicated entity, handOverTo(installation), replicateTo(installation), stopReplicatingTo(installation), as effects or after persist

All the events of one effect are persisted together, before the reply. error(message) refuses with 400; error(Problem.conflict(...)), Problem.notFound(...) and the rest refuse with the problem's status.

A refusal is returned, never thrown. A handler that throws fails its command as a fault: nothing is persisted, the caller receives 500 with internal error rather than your message, and a trace shows a fault rather than a refusal. Where a handler would throw because a rule is broken — the cart is checked out, the quantity is not positive — return effects().error(...) instead, with the Problem whose status says why. The same holds for a key value entity and a workflow's commands. See Event sourced entities and Error codes.

The shopping cart's entity:

/** A customer's cart, one per cart id. Its handlers' wire names are the Scala cart's. */
@ComponentId("shopping-cart")
public class ShoppingCart extends EventSourcedEntity<Cart, CartEvent> {

  @Override
  protected Cart emptyState() {
    return Cart.empty(context().entityId());
  }

  @Command("add-item")
  public Effect<Done> addItem(LineItem item) {
    if (currentState().checkedOut()) return effects().error(checkedOut());
    if (item.quantity() <= 0)
      return effects().error("quantity must be greater than zero, was " + item.quantity());
    return effects().persist(new CartEvent.ItemAdded(item)).thenDone();
  }

  @Command("remove-item")
  public Effect<Done> removeItem(String productId) {
    if (currentState().checkedOut()) return effects().error(checkedOut());
    if (!currentState().contains(productId))
      return effects().error(Problem.notFound("cart does not contain '" + productId + "'"));
    return effects().persist(new CartEvent.ItemRemoved(productId)).thenDone();
  }

  @Command("checkout")
  public Effect<Cart> checkout() {
    if (currentState().checkedOut()) return effects().error(checkedOut());
    if (currentState().isEmpty()) return effects().error("cannot check out an empty cart");
    return effects().persist(new CartEvent.CheckedOut()).thenReplyState();
  }

  @Command("discard")
  public Effect<Done> discard() {
    if (currentState().checkedOut()) return effects().error(checkedOut());
    return effects().persist(new CartEvent.Discarded()).thenDelete().thenDone();
  }

  @Query("get-cart")
  public ReadOnlyEffect<Cart> getCart() {
    return effects().reply(currentState());
  }

  @Query("total-quantity")
  public ReadOnlyEffect<Integer> totalQuantity() {
    return effects().reply(currentState().totalQuantity());
  }

  @Override
  protected Cart applyEvent(Cart state, CartEvent event) {
    return switch (event) {
      case CartEvent.ItemAdded added -> state.addItem(added.item());
      case CartEvent.ItemRemoved removed -> state.removeItem(removed.productId());
      case CartEvent.CheckedOut ignored -> state.onCheckedOut();
      case CartEvent.Discarded ignored -> state;
    };
  }

  private static Problem checkedOut() {
    return Problem.conflict("cart is already checked out");
  }
}

Key value entity

Part API
Base class KeyValueEntity<S>
May define emptyState(), null unless overridden
Effects effects().updateState(s), deleteEntity(), then .thenReply(...), .thenReplyState(), .thenDone(), .thenNoReply(), .expireAfter(d); effects().reply(r), error(...), noReply()

See Key value entities.

View

Part API
Base class View<Src, Row>
Must define source(), a ChangeSource; onChange(Src change) returning a ViewEffect<Row>
May define onDelete(), which deletes the row by default; version(), whose raising rebuilds the view into a fresh table; parallelism(), four by default
Sources ChangeSource.of(Entity.class), an entity's events or state; ofWorkflow(Workflow.class), a workflow's state; ofTopic(topic, Type.class) or ofTopic(project, topic, Type.class), then .fromEarliest(), .fromLatest(), .from(instant), .withContract(contract), .onBroker(name), .parallel()
In a handler rowState(), the row held for the source id, as an Optional; updateContext() (subject(), sequenceNumber(), localOrigin(), installation(), standing())
Effects effects().updateRow(row), deleteRow(), ignore()
Declared queries public static final ViewQuery fields: ViewQuery.declare(name, sql), .watched(); ViewQuery.tableOf(View.class) names the table
Querying viewClient().forView(View.class): get(key), all(), all(limit), count(), ask(query, values), askStream(...), watch(query, values), watchRow(key), and …Async forms

A view over an entity, from the shopping cart:

@ComponentId("cart-rows")
public class CartRows extends View<CartEvent, CartRows.CartRow> {

  @Manifest("cart-row")
  public record CartRow(
      String cartId,
      Map<String, Integer> quantities,
      boolean checkedOut,
      Map<String, String> names) {}

  @Override
  protected ChangeSource<CartEvent> source() {
    return ChangeSource.of(ShoppingCart.class);
  }

  @Override
  protected ViewEffect<CartRow> onChange(CartEvent event) {
    var current =
        rowState().orElse(new CartRow(updateContext().subject(), Map.of(), false, Map.of()));
    var quantities = new HashMap<>(current.quantities());
    var names = new HashMap<>(current.names());
    return switch (event) {
      case CartEvent.ItemAdded added -> {
        quantities.merge(added.item().productId(), added.item().quantity(), Integer::sum);
        names.put(added.item().productId(), added.item().name());
        yield effects()
            .updateRow(new CartRow(current.cartId(), quantities, current.checkedOut(), names));
      }
      case CartEvent.ItemRemoved removed -> {
        quantities.remove(removed.productId());
        names.remove(removed.productId());
        yield effects()
            .updateRow(new CartRow(current.cartId(), quantities, current.checkedOut(), names));
      }
      case CartEvent.CheckedOut ignored ->
          effects()
              .updateRow(
                  new CartRow(current.cartId(), current.quantities(), true, current.names()));
      // The deletion that follows removes the row: a discarded cart leaves the listing.
      case CartEvent.Discarded ignored -> effects().ignore();
    };
  }
}

A declared query is SQL over the view's table, each value written :name, checked when the service starts. A recursive one, and one declared watchable, which selects each row's key beside the row:

/** Every row under the row {@code row}, to any depth, by key: the same in every language. */
public static final ViewQuery UNDER =
    ViewQuery.declare(
        "under",
        "WITH RECURSIVE below AS (\n"
            + "  SELECT row_key, payload FROM "
            + TABLE
            + " WHERE payload::jsonb->>'under' = :row\n"
            + "  UNION\n"
            + "  SELECT n.row_key, n.payload FROM "
            + TABLE
            + " n JOIN below b ON n.payload::jsonb->>'under' = b.row_key\n"
            + ")\n"
            + "SELECT payload FROM below ORDER BY row_key");

/** Every row, by key, watchable: the same statement in every language. */
public static final ViewQuery ALL_ROWS =
    ViewQuery.declare("all-rows", "SELECT row_key, payload FROM " + TABLE + " ORDER BY row_key")
        .watched();

A view over a topic starts at the earliest message the broker holds unless its source says otherwise. See Views.

Keyed view

A view that reads several sources and names its rows by key. Each source is a method annotated @Source(Entity.class), or @Source(topic = "name"), taking the change, or the change and its KeyedChange<Row>; @Source(value = Entity.class, deleted = true) handles that source's deletions.

Part API
Base class KeyedView<Row>
In a handler change.subject(), change.sequenceNumber(), change.standing(), change.rows().get(key), change.rows().ask(query, values)
Effects effects().updateRow(key, row), deleteRow(key), updateRows(map), deleteRows(keys), ignore(), then .and(other)
Querying viewClient().forKeyedView(View.class), as a view's
@ComponentId("joined-rows")
public class JoinedRows extends KeyedView<JoinedRows.JoinedRow> {

  /** A row the left writes under the key it names, holding a right entity's id. */
  @Manifest("joined-row")
  public record JoinedRow(String key, String holding, List<String> notes) {
    JoinedRow noting(String who) {
      var more = new ArrayList<>(notes);
      more.add(who);
      return new JoinedRow(key, holding, List.copyOf(more));
    }
  }

  /** The rows holding one right entity, by key: the same statement in every language. */
  public static final ViewQuery OF_RIGHT =
      ViewQuery.declare(
          "of-right",
          "SELECT payload FROM "
              + ViewQuery.tableOf(JoinedRows.class)
              + " WHERE payload::jsonb->>'holding' = :holding ORDER BY row_key");

  /** The left names a row {@code key|holding}, and writes it from what it held, noting itself. */
  @Source(JoinedLeft.class)
  public KeyedViewEffect<JoinedRow> onLeft(Noted event, KeyedChange<JoinedRow> change) {
    var parts = event.text().split("\\|", 2);
    var key = parts[0];
    var holding = parts[1];
    var held = change.rows().get(key).map(JoinedRow::notes).orElse(List.of());
    return effects().updateRow(key, new JoinedRow(key, holding, held).noting("left"));
  }

  /** The right finds every row holding it by asking the view's own query, and notes itself. */
  @Source(JoinedRight.class)
  public KeyedViewEffect<JoinedRow> onRight(Noted event, KeyedChange<JoinedRow> change) {
    var theirs = change.rows().ask(OF_RIGHT, Map.of("holding", change.subject()));
    var rows = new LinkedHashMap<String, JoinedRow>();
    for (var row : theirs) rows.put(row.key(), row.noting("right"));
    return effects().updateRows(rows);
  }
}

Consumer

Part API
Base class Consumer<Src, Out>; Out is Void for one that publishes nothing
Must define source(), a ChangeSource; onChange(Src change) returning a ConsumerEffect<Out>
May define onDelete(), which ignores by default; publishes(), an Optional<Publication> (Publication.to(topic), to(project, topic), then .withContract(contract), .onBroker(name)); mirroredChanges(); version(); parallelism()
In a handler messageContext() (subject(), sequenceNumber(), localOrigin(), installation(), standing()), componentClient(), services(), secrets(), storage(), erasures()
Effects effects().publish(message), publish(message, key), publishAll(messages), publishMessages(outgoings), done(), ignore()
One of several messages Outgoing.of(payload), then .withKey(key) and .withMetadata(name, value)
@ComponentId("checkout-fanout")
public class CheckoutFanout extends Consumer<CartEvent, Fanned> {

  @Override
  protected ChangeSource<CartEvent> source() {
    return ChangeSource.of(ShoppingCartEntity.class);
  }

  @Override
  protected Optional<Publication> publishes() {
    return Optional.of(Publication.to("conformance-fanout"));
  }

  @Override
  protected ConsumerEffect<Fanned> onChange(CartEvent event) {
    return switch (event) {
      case CartEvent.ItemAdded ignored -> effects().publishAll(List.of());
      case CartEvent.ItemRemoved ignored -> effects().publish(new Fanned(0));
      case CartEvent.CheckedOut ignored ->
          effects()
              .publishMessages(
                  List.of(
                      Outgoing.of(new Fanned(1)),
                      Outgoing.of(new Fanned(2)).withKey("second:" + messageContext().subject()),
                      Outgoing.of(new Fanned(3)).withMetadata("x-n", "3")));
      case CartEvent.Discarded ignored -> effects().ignore();
    };
  }
}

Delivery is at least once, and a handler that throws has the change delivered again, so what it does must be safe to do twice. A consumer over a topic must say where it starts. A consumer that publishes needs a broker. See Consumers.

Graph consumer

A consumer that publishes its source as graph deltas, keyed for a compacted topic. It is registered as any component is, and can publish nothing but deltas.

Part API
Base class GraphConsumer<Src>
Must define source(), topic(), onChange(Src change) returning a GraphEffect
May define onDelete(), which ignores by default; version(); parallelism()
Elements graph().node(id, labels, properties), edge(id, type, from, to, properties), tombstoneNode(id), tombstoneEdge(id, type, from, to); then .at(version) to state one at a version other than the change's sequence number
Effects effects().publish(elements...), publish(list), done(), ignore()
Reading deltas GraphDelta.read(value), GraphDelta.read(key, value), GraphDelta.nodeKey(id), edgeKey(id), SCHEMA_NAME

A property's value is a String, Boolean, Integer, Long or Double, or a non-empty List of one of those; anything else, or an element the graph rules refuse, is an IllegalArgumentException where it is built.

@ComponentId("cart-graph")
public class CartGraph extends GraphConsumer<CartEvent> {

  @Override
  protected ChangeSource<CartEvent> source() {
    return ChangeSource.of(ShoppingCartEntity.class);
  }

  @Override
  protected String topic() {
    return "conformance-graph";
  }

  private GraphElement cart(String id, boolean checkedOut) {
    return graph()
        .node("cart:" + id, List.of("Cart"), Map.of("cartId", id, "checkedOut", checkedOut));
  }

  @Override
  protected GraphEffect onChange(CartEvent event) {
    var id = messageContext().subject();
    return switch (event) {
      case CartEvent.ItemAdded ignored -> effects().publish(cart(id, false));
      case CartEvent.ItemRemoved ignored -> effects().publish(cart(id, false));
      case CartEvent.CheckedOut ignored ->
          effects()
              .publish(
                  cart(id, true),
                  graph().node("checkout:" + id, List.of("Checkout"), Map.of("cartId", id)),
                  graph()
                      .edge("checked-out:" + id, "CHECKED_OUT", "cart:" + id, "checkout:" + id,
                          Map.of()));
      case CartEvent.Discarded ignored -> effects().ignore();
    };
  }

  @Override
  protected GraphEffect onDelete() {
    return effects().publish(graph().tombstoneNode("cart:" + messageContext().subject()));
  }
}

See Publish a graph.

Workflow

Part API
Base class Workflow<S>
Annotations @Command, @Query on commands; @Step("name") on steps, each taking one input or none and returning a StepEffect
May define emptyState(); settings(), a WorkflowSettings
Command effects effects().updateState(s), transitionTo(Step::ref), transitionTo(Step::ref, input), delete(), then .transitionTo(...), .thenReply(r), .thenReplyState(), .thenDone(), .thenNoReply(); effects().reply(r), error(...)
Step effects stepEffects().updateState(s), then .thenTransitionTo(Step::ref), .thenTransitionTo(Step::ref, input), .thenPause(), .thenPause(duration, Step::onTimeout), .thenEnd(), .thenFail(message); or the same directly on stepEffects()
Settings WorkflowSettings.builder().timeout(d).defaultStepTimeout(d).stepTimeout(Step::ref, d).defaultRecovery(r).stepRecovery(Step::ref, r).build()
Recovery RecoverStrategy.fail(), maxRetries(n), then .failoverTo(Step::ref)
In a step currentState(), context().componentClient(), services(), secrets(), storage()
@ComponentId("checkout")
public class Checkout extends Workflow<Checkout.CheckoutState> {

  @Manifest("checkout")
  public record CheckoutState(String id, String mode, String status) {
    CheckoutState withStatus(String next) {
      return new CheckoutState(id, mode, next);
    }
  }

  private static final Set<String> DECLINED = Set.of("fail", "abort", "drop", "doomed");

  @Override
  protected CheckoutState emptyState() {
    return new CheckoutState(context().entityId(), "", "new");
  }

  @Override
  protected WorkflowSettings settings() {
    return WorkflowSettings.builder()
        .defaultStepTimeout(Duration.ofSeconds(10))
        .stepRecovery(Checkout::charge, RecoverStrategy.maxRetries(1).failoverTo(Checkout::compensate))
        .build();
  }

  @Command("start")
  public Effect<String> start(String mode) {
    if (!currentState().status().equals("new"))
      return effects().error(Problem.conflict("already started"));
    return effects()
        .updateState(new CheckoutState(currentState().id(), mode, "reserving"))
        .transitionTo(Checkout::reserve)
        .thenReply("started");
  }

  @Step("reserve")
  public StepEffect reserve() {
    // A client call from a step: what the cart holds.
    context()
        .componentClient()
        .forEventSourcedEntity(currentState().id())
        .call(ShoppingCartEntity::totalQuantity)
        .invoke();
    var reserved = stepEffects().updateState(currentState().withStatus("reserved"));
    return currentState().mode().equals("pause")
        ? reserved.thenTransitionTo(Checkout::waitStep)
        : reserved.thenTransitionTo(Checkout::charge);
  }

  @Step("wait")
  public StepEffect waitStep() {
    return stepEffects()
        .updateState(currentState().withStatus("waiting"))
        .thenPause(Duration.ofMillis(1500), Checkout::charge);
  }

  @Step("charge")
  public StepEffect charge() {
    if (DECLINED.contains(currentState().mode())) throw new RuntimeException("payment declined");
    return stepEffects().updateState(currentState().withStatus("charged")).thenEnd();
  }

  @Step("compensate")
  public StepEffect compensate() {
    return switch (currentState().mode()) {
      case "abort" ->
          stepEffects()
              .updateState(currentState().withStatus("aborted"))
              .thenFail("payment declined");
      case "drop" -> stepEffects().thenFail("payment declined");
      case "doomed" -> throw new RuntimeException("compensation failed too");
      default -> stepEffects().updateState(currentState().withStatus("compensated")).thenEnd();
    };
  }

  @Query("status")
  public ReadOnlyEffect<String> status() {
    return effects().reply(currentState().status());
  }
}

A step that throws, or runs out of time, is retried as its recovery says, then failed over or failed; a step that returns thenFail(...) ends the workflow failed. Every transition is journaled, so a workflow resumes where it was after a restart. A step names the step that follows by method reference, so renaming the method leaves the journal's step name, the @Step value, alone. See Workflows.

Timed action and timers

Part API
Base class TimedAction
Annotations @Action("name") on each handler, taking one input or none and returning a TimedActionEffect
In a handler context() (timerName(), previousAttempts(), dueTime(), clock(), timers(), componentClient(), services(), secrets(), storage())
Effects effects().done(), error(message), error(problem)
Scheduling timers().schedule(name, delay, Action::ref, input), schedule(name, delay, Action::ref), scheduleRecurring(name, delay, period, Action::ref[, input]), cancel(name), exists(name)
/** A timed action that records on the conformance entity it is given. */
@ComponentId("reminder")
public class Reminder extends TimedAction {

  @Action("remind")
  public TimedActionEffect remind(String id) {
    record(id, "reminded");
    return effects().done();
  }

  /** Records the due time it was run for, as the runtime told it. */
  @Action("tick")
  public TimedActionEffect tick(String id) {
    record(id, "due:" + context().dueTime().toEpochMilli());
    return effects().done();
  }

  private void record(String id, String what) {
    context()
        .componentClient()
        .forEventSourcedEntity(id)
        .call(ConformanceEntity::record)
        .invoke(what);
  }
}

timers() is on an endpoint, a timed action's context and the running service, and is refused in a service that registers no timed action. Timers are kept in the database, so they survive a restart; a name is one timer, and scheduling it again replaces what it would call and when. A timer is delivered at least once, and error(...) or an exception has it retried; dueTime() is the same for every attempt at one due time. See Timers.

Agent

Agents, autonomous agents, judgments and blueprints are in ankka-java-agent, package com.thinkmorestupidless.ankka.agent.javasdk. A service that declares any of them gives the service the agents module, Agents.module(); one that registers an agent without it refuses to start, naming the module.

Part API
Base class Agent
Annotations @ComponentId; @Command on a handler answering AgentEffect<R>, @Stream on one answering StreamEffect
May override settings(): AgentSettings.defaults().role(r).maxToolCallSteps(n).mcpServer(s).resultGuardrail(g)
In a handler sessionId(), componentClient(), services(), secrets(), storage()
Effects effects().systemMessage(t), .userMessage(t), .withContext(t), .model(p), .memory(m), .tools(t...), .guardrails(g...), .maxToolCallSteps(n), then .thenReply(), .thenReply(Type.class), .thenStream(); effects().error(message), error(problem), effects().judgment()
Tools an object, or a class with a constructor taking nothing, whose methods carry @Tool(value, description), optionally requiresApproval = true and approvalWithin = "PT10M"; each parameter is an argument, described by @Describe, and a lone record parameter's components are the arguments; FunctionTool.of(name, description, run) for a tool built in code
MCP servers McpServer.named(n), McpServer.at(n, url), McpServer.service(n, service, path), then .header(name, "ANKKA_MCP_…"), .requiresApproval(), .requiresApproval(within)
Guardrails Guardrail.maxInputLength(n), Guardrail.forbidding(name, regex), Guardrail.judged(name), or implement checkInput, checkOutput or checkResult, each answering the reason for a refusal or nothing
Memory Memory.session() (the default), Memory.none(), then .readLast(n), .readOnly(), .writeOnly()
Module Agents.module().withModelProvider(p[, timeout]), .withCompaction(maxHistoryBytes, keepRecentMessages[, summariser]), given to withModule
Calling AgentClients.forAgent(componentClient, sessionId).call(Agent::handler).invoke(input); .stream(Agent::handler, input), a Flow.Publisher<String>; .ask(Agent::handler).invoke(input) answers AgentOutcome.Answered(value) or AgentOutcome.AwaitingApproval(requests); .decide(Agent::handler, Decision.approved(id, by)) or Decision.refused(id, by, note); .decideStream(...) for a turn that began as a stream; .approvals(); call throws ApprovalAwaited when the turn waits for a person

The cart's helper reads a cart through a tool and refunds one only once a person approves:

/** An agent whose tool reads a cart, one that refunds only once approved, and a guarded handler. */
@ComponentId("cart-helper")
public class CartHelper extends Agent {

  /** What the refund tool refunded. */
  public static final List<String> REFUNDED = new CopyOnWriteArrayList<>();

  public record CartQuery(@Describe("the cart to read") String cartId) {}

  public record Summary(String cartId, int items) {}

  public static final class Tools {
    private final ComponentClient client;

    Tools(ComponentClient client) {
      this.client = client;
    }

    @Tool(value = "cart_items", description = "How many items a cart holds")
    public int items(CartQuery query) {
      return client
          .forEventSourcedEntity(query.cartId())
          .call(ShoppingCart::getCart)
          .invoke()
          .items()
          .size();
    }

    @Tool(value = "refund", description = "Refunds a cart", requiresApproval = true)
    public String refund(CartQuery query) {
      REFUNDED.add(query.cartId());
      return "refunded " + query.cartId();
    }
  }

  @Command("ask")
  public AgentEffect<String> ask(String question) {
    return effects()
        .systemMessage("You help with carts.")
        .userMessage(question)
        .tools(new Tools(componentClient()))
        .thenReply();
  }

  @Command("summarise")
  public AgentEffect<Summary> summarise(String cartId) {
    return effects().userMessage("Summarise cart " + cartId).thenReply(Summary.class);
  }

  @Command("short")
  public AgentEffect<String> shortQuestion(String question) {
    return effects()
        .userMessage(question)
        .guardrails(Guardrail.maxInputLength(10))
        .thenReply();
  }
}

A tool runs after the handler has returned, so a tool that calls other components is given the client when it is built, as Tools is here. The service gives the module its default model; an effect that names a model with .model(p) is answered by that one instead:

/** The cart, the agent that helps with it and the one that answers questions about it. */
public static ServiceBuilder service(ModelProvider model) {
  return Ankka.service("shop")
      .withModule(Agents.module().withModelProvider(model))
      .register(ShoppingCart.class)
      .register(CartHelper.class)
      .register(CartAnswerer.class);
}

A turn is called by the agent's session id and the handler's method reference:

/** One turn of the session {@code session}: the model's answer, its tools run on the way. */
public static String ask(ComponentClient client, String session, String question) {
  return AgentClients.forAgent(client, session).call(CartHelper::ask).invoke(question);
}

AgentTestKit runs one turn with no runtime against a scripted model, the tool's calls answered by a TestClient:

var model =
    new TestModelProvider()
        .expectToolCall("cart_items", "{\"cartId\":\"c-1\"}")
        .expectText("Cart c-1 holds 2 lines");
var cart =
    TestClient.of(ShoppingCart.class)
        .stub(
            ShoppingCart::getCart,
            () ->
                new CartState(
                    "c-1", List.of(new LineItem("p", "Pen", 2), new LineItem("q", "Pad", 1)), false));
var kit = AgentTestKit.of(CartHelper.class, model, "s-1", cart);

var result = kit.call(CartHelper::ask, "How full is c-1?");
// result.reply() is "Cart c-1 holds 2 lines"; model.lastRequest() holds what the tool answered

TestModelProvider scripts the model: expectText(t), expectToolCall(tool, argumentsJson[, callId]), expectRefusal(reason), whenUserAsks(substring, text), and for an autonomous agent expectCompleteTask(resultJson), expectCompleteTaskText(t) and expectFailTask(reason); it records requests(), lastRequest() and callCount(), and a script that runs out fails the turn, naming the script. An AgentResult has reply(), isError(), error(), problem(), isAwaiting(), awaiting() and messages(), the session's length after the turn. See Agents.

Autonomous agent

Part API
Task type Task.named(wireName).describedAs(d), a text result; .resultConformsTo(Record.class); .rule(name, result -> RuleResult.accepted()) or RuleResult.rejected(reason)
Base class AutonomousAgent, with @ComponentId; define definition(), override tools() (a list of tool objects)
Definition AutonomousDefinition.describedAs(d).instructions(t).guardrails(g...).model(p).mcpServers(s...).resultGuardrails(g...).accepts(type[, maxIterationsPerTask])
In a tool componentClient(), instanceId(), services(), secrets(), storage(), AutonomousAgent.currentTask()
Tasks AgentClients.tasks(client).create(type, instructions).withId(id).attach(name, contentType, content).attachReference(name, contentType, uri).dependsOn(ids...).create()
A task AgentClients.forTask(client, id).get(), .get(type), .await(type, timeout), .cancel(reason); a TaskSnapshot has ended(), result(), reason(), instanceId() and json(), the whole record
An instance AgentClients.forAutonomousAgent(client, Agent.class, instanceId).assign(ids...), answering an AssignResult; .suspend(), .resume(), .terminate(), .state(), .notifications(), .decide(decision)
One task AgentClients.runSingleTask(client, Agent.class, type, instructions), answering the task's id

A task type's result is a record, whose components are the schema the model is shown:

/** The kinds of work the cart answerer accepts. */
public final class CartTasks {

  private CartTasks() {}

  /** An answer, and what the agent looked at to reach it. */
  public record Answer(String answer, List<String> sources) {}

  /**
   * {@code "answer"} is the wire name, written into every task of this type: renaming the field
   * changes nothing stored.
   */
  public static final TaskType<Answer> ANSWER =
      Task.named("answer")
          .describedAs("Answer a question about a shopping cart, citing what you looked at")
          .resultConformsTo(Answer.class)
          .rule(
              "cites-sources",
              answer ->
                  answer.sources().isEmpty()
                      ? RuleResult.rejected("say which tools you used in sources")
                      : RuleResult.accepted());
}
/**
 * Answers questions about carts, on its own: it is handed a task and works it, iteration by
 * iteration, until it completes it with an {@code Answer} that cites its sources.
 *
 * <p>Its tools read the cart entity through the component client. A tool may run more than once
 * for one request of the model — after a crash the last recorded request's tools run again — and
 * these only read, so that costs nothing.
 */
@ComponentId("cart-answerer")
public class CartAnswerer extends AutonomousAgent {

  public static final class CartTools {
    private final ComponentClient client;

    CartTools(ComponentClient client) {
      this.client = client;
    }

    private CartState cart(String cartId) {
      return client.forEventSourcedEntity(cartId).call(ShoppingCart::getCart).invoke();
    }

    @Tool(value = "cart_contents", description = "Lists what is in a cart.")
    public String contents(@Describe("The id of the cart.") String cartId) {
      var items = cart(cartId).items();
      if (items.isEmpty()) return "cart " + cartId + " is empty";
      return items.stream()
          .map(i -> i.quantity() + " x " + i.name())
          .collect(Collectors.joining(", "));
    }

    @Tool(value = "cart_total", description = "Counts the items in a cart.")
    public String total(@Describe("The id of the cart.") String cartId) {
      int total = cart(cartId).items().stream().mapToInt(LineItem::quantity).sum();
      return "cart " + cartId + " holds " + total + " items";
    }
  }

  @Override
  protected AutonomousDefinition definition() {
    return AutonomousDefinition.describedAs("Answers questions about shopping carts")
        .instructions("Look carts up rather than guessing. Be brief.")
        .guardrails(Guardrail.maxInputLength(2000))
        .accepts(CartTasks.ANSWER, 5);
  }

  @Override
  protected List<Object> tools() {
    return List.of(new CartTools(componentClient()));
  }
}

The agents module brings the platform's own components an autonomous agent needs, so registering the agent is all a service does. Running a task answers its id at once; anyone holding the id reads its record:

/** Starts the work and answers at once, with the task's id. */
public static String startAnswer(ComponentClient client, String question) {
  return AgentClients.runSingleTask(client, CartAnswerer.class, CartTasks.ANSWER, question);
}

/** The task's record once it has ended, its result read as an {@code Answer}. */
public static TaskSnapshot<CartTasks.Answer> answer(ComponentClient client, String taskId) {
  return AgentClients.forTask(client, taskId).await(CartTasks.ANSWER, Duration.ofMinutes(1));
}

In the whole-service kit the scripted model is the module's default, and a task is worked to its end:

var model =
    new TestModelProvider()
        .expectToolCall("cart_total", "{\"cartId\":\"c-1\"}")
        .expectCompleteTask(
            "{\"answer\":\"Cart c-1 holds 3 items.\",\"sources\":[\"cart_total\"]}");
try (var kit = AnkkaTestKit.start(service(model))) {
  var client = kit.componentClient();
  client.forEventSourcedEntity("c-1").call(ShoppingCart::addItem).invoke(new LineItem("p", "Pen", 3));

  var taskId = startAnswer(client, "How many items are in cart c-1?");
  return answer(client, taskId).result().orElseThrow();
}

See Autonomous agents.

Judgments

Part API
Questions Question.choice(id, instructions, ChoiceOption.of(value, key, description)...), Question.score(id, instructions, levels...), Question.yesNo(id, instructions).describing(yes, no)
Answers judgment.answer(question): a ChoiceAnswer(choice, probabilities, confidence), a ScoreAnswer(score, probabilities, confidence) with level(), or a YesNoAnswer(probability); judgment.model()
Effect effects().judgment().state(text) or .state(value), .question(q...), .provider(p), then .thenReply() or .thenReply(judgment -> value)
Module Agents.module().withJudgments(provider[, timeout]), five seconds by default
Providers JudgmentProviders.jev(), JudgmentProviders.jev(model), reading TYPESAFE_API_KEY and TYPESAFE_BASE_URL
Guardrails Guardrail.judged(name).onInput(rules...).onOutput(rules...).onResult(rules...).provider(p); rules Refusal.ifYes(q, atLeast), Refusal.ifChosen(q, minConfidence, options...), Refusal.ifScore(q, atLeast), Refusal.when(q, answer -> refuse)

Questions are values with wire ids, declared once and asked from any handler:

public static final ChoiceQuestion<Tone> TONE =
    Question.choice(
        "tone",
        "The tone of the text",
        ChoiceOption.of(Tone.POLITE, "polite", "Courteous"),
        ChoiceOption.of(Tone.RUDE, "rude", "Discourteous"));

public static final ScoreQuestion QUALITY =
    Question.score("quality", "How well written the text is", "poor", "fair", "good");

public static final YesNoQuestion ABUSIVE =
    Question.yesNo("abusive", "Is the text abusive?").describing("it is", "it is not");
@Command("verdict")
public AgentEffect<Verdict> verdict(String text) {
  return effects()
      .judgment()
      .state(text)
      .question(TONE, QUALITY, ABUSIVE)
      .thenReply(
          j ->
              new Verdict(
                  j.answer(TONE).choice().name(),
                  j.answer(QUALITY).level(),
                  j.answer(ABUSIVE).probability() >= 0.5,
                  j.model()));
}

@Command("guarded")
public AgentEffect<String> guarded(String text) {
  return effects()
      .userMessage(text)
      .guardrails(Guardrail.judged("abuse").onInput(Refusal.ifYes(ABUSIVE, 0.5)))
      .thenReply();
}

See Judgments.

Blueprints

Part API
Blueprint Blueprint.named(n).input(shape).worker(w).step(s).schedule(schedule).runBudget(modelCalls).timeLimit(d); Blueprint.fromJson(json), Blueprint.fromResource(path); .json(), .digest()
Shapes Shape.string(), number(), integer(), bool(), any(), arr(items), obj(fields), enumOf(values...), fromJson(schema)
Workers Worker.named(n).instructions(t).model(name).tools(names...).guardrails(names...).budget(modelCalls)
Steps Step.named(n), then one of .ask(worker), .work(worker), .judge(questions...), .call(handler), .forEach(worker, over), .gather(workers...), .gather(worker, times), .critique(drafter, verdict, rounds); with .reads(names...), .result(shape), .each(read[, limit, keepGoing]), .overWorkers(workers...), .times(n), .until(verdict, rounds)
Schedules Schedule.weekly(day, time, zone), Schedule.everyHours(n, zone), Schedule.everyDays(n, zone), then .perMissedPeriod()
Registry BlueprintRegistry.empty().tools(...).mcpServers(...).model(name, p).guardrail(name, g).questions(q...).handlers(BlueprintHandler.of(name, (run, inputJson) -> resultJson)...).carrying(blueprints...), given as Agents.module().withBlueprints(context -> registry)
Registering AgentClients.blueprints(client).register(blueprint) or .register(json), .versions(name), .current(name)
Runs AgentClients.runs(client).start(blueprint, inputJson, runId), .get(runId), .await(runId, timeout), .cancel(runId); a RunSnapshot has ended() and stepNamed(name)

A blueprint and the registry that carries it, a worker listing titles and a handler keeping them:

public static final Blueprint KEEPER =
    Blueprint.named("keeper")
        .input(Shape.obj(Map.of("topic", Shape.string())))
        .worker(Worker.named("lister").instructions("List titles on the topic.").budget(2))
        .step(Step.named("titles").ask("lister").reads("input").result(Shape.arr(Shape.string())))
        .step(Step.named("keep").call("keep").reads("titles").result(Shape.integer()));

public static BlueprintRegistry registry(BlueprintContext context) {
  return BlueprintRegistry.empty()
      .handlers(
          BlueprintHandler.of(
              "keep",
              (run, input) -> {
                KEPT.add(run.blueprint() + "/" + run.step() + ":" + input);
                return "2";
              }))
      .carrying(KEEPER);
}

The registry is built once when the service starts, from a BlueprintContext holding the clients a handler needs; Agents.module().withBlueprints(Keeper::registry) gives it to the service. A run is started by the blueprint's name with its input as JSON:

public static RunsRef.RunSnapshot keep(ComponentClient client, String runId, String topic) {
  var runs = AgentClients.runs(client);
  runs.start("keeper", "{\"topic\":\"" + topic + "\"}", runId);
  return runs.await(runId, Duration.ofMinutes(1));
}

See Blueprints.

Model providers

Built with What it is
ModelProviders.anthropic(model) Anthropic's API, with ANTHROPIC_API_KEY; named anthropic
ModelProviders.openAi(model) OpenAI's API, with OPENAI_API_KEY; named openai
ModelProviders.preset(name, preset, model) a preset that needs no key, such as ollama or lmstudio
ModelProviders.preset(name, preset, model, key) any preset, with its key given
ModelProviders.at(name, protocol, url, model, key) anthropic or openai protocol at any address, the key an Optional
ModelProviders.installation(model) a model the installation's model server serves, reached with the service's certificate

A provider built from the environment whose key is not set still exists, under its name, and every call to it fails naming the variable. Each refuses what it was given with an IllegalArgumentException naming the problem, under the same rules a Scala service and the sidecar apply. See Model providers.

HTTP endpoint

Part API
Base class HttpEndpoint, registered with withEndpoint(Endpoint.class)
Must define acl()
Routes @Get(template), @Post, @Put, @Delete, @Patch; @Sse(template) returning a Flow.Publisher<SseEvent> or a Stream<SseEvent>; @Socket(template) taking a WebSocket; @StreamBody(template), whose body is read as a stream
Parameters a parameter named as a template segment, or @Path(name); @QueryParam(name), @Header(name), @Body (at most one)
Answers a value, 200 with it; Done, 204; a Response (ok, created, noContent, status, html, bytes, redirect, problem, then .withStatus(n), .header(name, value))
Errors throw new ProblemException(Problem.notFound(...)); a CommandException from a call answers its problem's status
ACL Acl.allowAll(), denyAll(), allowIf(request -> …), authenticate(request -> AuthDecision.allow(principal)), allowCallers("orders"), allowCallers(Callers.internet(), Callers.service("orders"), …), anyOf(...), clientKeys(), oidc()
In a handler request() (method(), path(), query(name), queryAll(name), requiredQuery(name), flag(name), header(name), headers()), caller(), principal(), componentClient(), viewClient(), services(), secrets(), storage(), erasures(), timers(), clientKeys()

acl() is abstract, so an endpoint without one does not compile. One instance serves every request; the request is readable on the handler's own thread only, so read what you need before handing work to another. A refusal from an entity passes through the endpoint to its caller with its status, with no handling of its own. See HTTP endpoints.

An MCP endpoint, McpEndpoint, declares tools in its constructor — a tool that is an entity's handler named by reference, or one with a handler of its own — and is registered with withMcpEndpoint:

/**
 * The cart as an MCP server at {@code /mcp}: a customer's assistant reads a cart, adds to it and
 * checks it out through these tools rather than the cart's routes. Two tools are the entity's own
 * handlers, declared by reference; one has a handler of its own, because its arguments are not the
 * command's input.
 */
public class CartMcpEndpoint extends McpEndpoint {

  public record AddItemArgs(
      @Describe("The cart's id") String cartId,
      @Describe("The product's id") String productId,
      @Describe("How many; greater than zero") int quantity) {}

  public CartMcpEndpoint() {
    tool("get_cart")
        .describedAs("Reads one cart: its lines, and whether it is checked out")
        .query(ShoppingCart::getCart, "cartId");

    tool("add_item")
        .describedAs("Adds a quantity of a product to a cart")
        .arguments(AddItemArgs.class)
        .handle(
            args ->
                componentClient()
                    .forEventSourcedEntity(args.cartId())
                    .call(ShoppingCart::addItem)
                    .invoke(new LineItem(args.productId(), args.productId(), args.quantity())));

    tool("checkout")
        .describedAs("Checks a cart out, answering what it held")
        .command(ShoppingCart::checkout, "cartId");
  }

  // A request carrying one of the cart's client keys, as the key's subject (CartKeysEndpoint).
  @Override
  protected Acl acl() {
    return Acl.clientKeys();
  }
}

A gRPC endpoint, GrpcEndpoint, declares its methods in its constructor against the descriptors grpc-java's protoc plugin generates, and is registered with withGrpcEndpoint. See MCP endpoints and gRPC endpoints.

Calling components

Call Does
componentClient().forEventSourcedEntity(id), forKeyValueEntity(id), forWorkflow(id) one instance to call
.call(Component::handler).invoke(input), .invoke() calls a handler and waits for its reply
.invokeAsync(input) the same, returning a CompletionStage at once
.withTimeout(d), .withMetadata(key, value) the same call, waiting at most d, or carrying a metadata entry
forWorkflow(id).awaitEnd(Workflow.class, timeout), awaitEndAsync, awaitEndEvents, callThenAwaitEnd(Workflow::ref, timeout), lifecycle(Workflow.class) waits for a workflow's end and answers its final state; a failed or deleted workflow is a CommandException with 500 and a workflowFailure(), one still running when the time is up 504
viewClient().forView(View.class) a view's rows, as in View

invoke parks the calling virtual thread, so a blocking call is the ordinary form: endpoints, steps, consumers and timed actions all run on virtual threads. A refusal is a CommandException whose problem() is the refusal's. An entity's handler has a component client in context(), but no service client, secret store or storage: a blocking call on an entity's single-writer path holds every other command to it. See Calling components.

Configuration

A Java service reads the same ANKKA_* variables a Scala service reads (Runtime configuration), and a variable its descriptor's env sets reaches the JVM's environment, read with System.getenv. withHttp(host, port) and withGrpc(host, port) on the builder replace where the configuration says to serve.

Secret store

The service's secret store, for a value it must keep and never record (Secrets a service keeps): secrets().put(name, value), get(name) (an Optional<String>) and delete(name), on an endpoint, a consumer, a workflow, a timed action's context and the running service. An entity has none. Where the service has no store, every call is a CommandException with 503.

Personal data

A record component of type Personal<T> is a personal field, held in every store encrypted under its data subject's key and read back as Personal.Erased once the data subject is erased (Personal data). Personal.of(DataSubject.of("player/8c1f"), value) makes one; a sealed switch over Present and Erased, or orElse(other), reads it. A field read from another project's topic is written again under that project's key, as long as the handler keeps the Personal it read rather than making a new one.

withErasureHandler(handler) on the builder registers the service's erasure handler, an ErasureHandler (com.thinkmorestupidless.ankka.javasdk.erasure) given an ErasureContext and the DataSubject and answering an ErasureReport; bucket.erase(subject) erases the objects under the subject's prefix and answers what ErasureReport.of takes. erasures().ask(subject) on an endpoint or a consumer, and ctx.erasures() in the handler, asks for an erasure as the service; ask(subject, ErasureAsk.inProject(p) .withNotBefore(date).withReason(r).withCorrelation(c)) says more. A refusal is a CommandException: 403 without the project's erasure grant, 503 where no control plane is reachable.

Calling other services

A handler calls a route of another service by that service's name, and the runtime makes the call as this service, so the other service's access rules can admit it by name (Calling other services). services().of(name) is a service of the same project and of(project, name) one of another that has granted this one; an endpoint, a workflow, a consumer and a timed action have services(), an entity does not.

var client = services().of(service);
try {
  if (request().query("mode").filter("typed"::equals).isPresent())
    return new ServiceCallRecord("response", 200, "", client.getText(path, headers), "", "");
  var answer =
      client.request(
          method,
          path,
          body.isEmpty() ? null : body.getBytes(StandardCharsets.UTF_8),
          body.isEmpty() ? null : request().header("Content-Type").orElse(null),
          headers);
  var named =
      answer.headers().stream()
          .filter(h -> h.getKey().equalsIgnoreCase("x-answer"))
          .map(Map.Entry::getValue)
          .findFirst()
          .orElse("");
  return new ServiceCallRecord(
      "response", answer.status(), answer.contentType(), answer.text(), named, "");
} catch (ServiceCallFailed e) {
  return new ServiceCallRecord("failed", e.status(), "", e.body(), "", "");
} catch (ServiceUnresolvable e) {
  return ServiceCallRecord.raised("unresolvable", e.getMessage());
} catch (ServiceIdentityMismatch e) {
  return ServiceCallRecord.raised("mismatch", e.getMessage());
} catch (ServiceUnanswered e) {
  return ServiceCallRecord.raised("unanswered", e.getMessage());
} catch (CommandException e) {
  return ServiceCallRecord.raised("refused", e.problem().detail());
}

A ServiceClient has get(path, Reply.class), post and put, which write and read bodies with the record codec, getText(path), delete(path), and the raw request(method, path, body, contentType), whose body and content type may be null for none and which answers a ServiceResponse — status, content type, body and headers — for every status. A call fails with:

Exception When Was anything sent
ServiceUnresolvable no address is known for the name no
ServiceIdentityMismatch what answered is not the service asked for no
ServiceUnanswered the connection failed, or no answer came in time perhaps
ServiceCallFailed the service answered outside 2xx, to a typed call yes, and it answered
CommandException the runtime refused the request no

The first three extend ServiceUnreachable. In a test, Settings.withScriptedService(name) plays another service.

Running a service

public static ServiceBuilder service() {
  return Ankka.service("shopping-cart")
      .register(ShoppingCart.class)
      .register(CartRows.class)
      .register(CheckoutNotifier.class)
      .register(CheckoutsSeen.class)
      .withEndpoint(CartEndpoint.class)
      .withEndpoint(CheckoutsSeenEndpoint.class)
      .withEndpoint(CartKeysEndpoint.class)
      .withMcpEndpoint(CartMcpEndpoint.class);
}

Ankka.service(name) begins a definition; register, withEndpoint, withMcpEndpoint, withGrpcEndpoint, withGrpcClients, withSerializer and withErasureHandler add to it; nothing is read or started until start(), which reads every class, throws ServiceProblems for any declaration problem, and answers an AnkkaService. That has awaitReady(), whenTerminated(), terminate(), close(), httpAddress() and the clients — componentClient(), viewClient(), timers(), secrets(), storage(). A main that starts it and joins whenTerminated() is the whole of a deployed service's entry point; the template builds its image with Jib.

Testing

Kit Drives
EventSourcedTestKit.of(Entity.class[, id]) One entity, no runtime: call(Entity::handler[, input]) answers a Result with reply(), isError(), error(), problem(), events(), state(); state(), events(), deleted(), restart(); withEvents(...), home(...) and follower(...) for a recovered or replicated copy
KeyValueEntityTestKit.of(Entity.class[, id]) One key value entity
WorkflowTestKit.of(Workflow.class[, id[, client]]) One workflow: each command runs its steps until the workflow pauses, ends or fails; timeOut() fires a pause's timeout; StepResult has steps(), lifecycle(), pausedFor(), onTimeout()
ViewTestKit.of(View.class) A view: change(subject, value), deleted(subject), row(subject), rows(); declared queries are checked as at start
KeyedViewTestKit.of(View.class) A keyed view: change(Source.class, subject, value), change(topic, subject, value), deleted(...), and answering(query, …) for each declared query a handler asks, since nothing runs SQL
ConsumerTestKit.of(Consumer.class[, client]) A consumer: onMessage(...), onDelete(subject), each answering what it would publish (payloads(), messages(), recordKeys()); ConsumerTestKit.graph(...) for a graph consumer, answering GraphDeltas
AgentTestKit.of(Agent.class, model[, sessionId, client]) One turn of an agent, no runtime, against a TestModelProvider: call(Agent::handler, input) answers an AgentResult; the next turn reads what the last recorded
TestClient.of(Component.class...) The component client a kit hands a component: .stub(Entity::handler, input -> reply), .refuse(Entity::handler, problem); a call nobody stubbed fails, naming it
AnkkaTestKit.start(Main.service()[, settings]) The whole service against a throwaway Postgres in Docker: http(), httpClient(), componentClient(), viewClient(), timers(), restartService(), published(topic), timerRuns(), eventually(within, assertion), socket(path), asCaller(caller); installations(names...) for a replicated group

Every unit kit crosses the component's own serializers and reduces effects with the function the runtime uses, so a kit and a running service cannot disagree about what an effect means. The whole-service kit holds a broker in memory, so a consumer that publishes runs without one. See Testing.

Developing the SDK

From the root of the ankka repository:

sbt sdkJava/test testkitJava/test                     # the SDK and its kits
sbt shoppingCartJava/test                             # the Scala cart's features against the Java cart
sbt -Dankka.conformance.reference=java 'sidecar/testOnly *.ConformanceSuite'   # the conformance suite, against the Java reference

The Java reference service the conformance suite drives is in modules/sdk-java/src/test/java, package …javasdk.reference. See Adding a language SDK.