Skip to content

Your first service in Scala

This tutorial creates a service from ankka's template, runs its tests, starts it on your machine, and changes it. It takes about fifteen minutes once the tools are installed. You need the JDK, sbt, Docker and the ankka CLI from Install the tools.

Create the service

ankka init expands the service template with sbt new:

ankka init orders
cd orders

sbt new thinkmorestupidless/ankka.g8 --name=orders does the same without the CLI. The name becomes the project's directory, the image name, the descriptor's name and, once the service is exposed, part of its hostname. It must be lowercase letters, digits and hyphens, starting with a letter, and at most 63 characters.

What the template made

The stub domain is an item with a name and a count. Every file is named after your project, and the shape is the shape of any ankka service:

orders/
├── build.sbt                      ankka version, dependencies, image packaging, the `schema` task
├── docker-compose.yml             Postgres for running locally
├── service.json                   the descriptor `ankka services apply` takes
├── README.md                      the rest of the journey: image, deploy, expose, upgrade
└── src/
    ├── main/scala/<package>/
    │   ├── Main.scala             the whole service definition: what is registered
    │   ├── domain/Item.scala      plain data and rules, no ankka types
    │   ├── application/
    │   │   ├── ItemEntity.scala   an event sourced entity: one instance per item id
    │   │   └── ItemRows.scala     a view: one row per item, for listing
    │   └── api/ItemEndpoint.scala the HTTP routes
    └── test/scala/<package>/
        ├── ItemEntitySuite.scala        the entity with no runtime at all
        ├── ItemHttpSuite.scala          the routes over a real runtime and Postgres
        └── ItemIntegrationSuite.scala   the service end to end

Main.scala is the complete inventory. Registration is explicit, so a component you forget to register fails at startup rather than at its first request:

@main def run(): Unit =
  val service = Ankka.service
    .register(ItemEntity.descriptor)
    .register(ItemRows.descriptor)
    .withExtension(ProjectionRuntime())
    .withExtension(HttpServer.of(clients => ItemEndpoint(clients.componentClient, clients.viewClient)))
    .start()

  sys.addShutdownHook(service.terminate())
  scala.concurrent.Await
    .result(service.whenTerminated, scala.concurrent.duration.Duration.Inf): Unit

ProjectionRuntime is what runs views. Without it every listing stays empty while every write succeeds.

The entity decides what happens to an item. Each handler returns an effect and performs no I/O:

final class ItemEntity(context: EventSourcedEntityContext) extends EventSourcedEntity[Item, ItemEvent]:

  private val itemId: String = context.entityId

  def emptyState: Item = Item.empty(itemId)

  def applyEvent(event: ItemEvent): Item = event match
    case ItemAdded(name, count) => currentState.onAdded(name, count)

  def addItem(request: AddItem): Effect[Done] =
    if request.count <= 0 then
      effects.error(s"count must be greater than zero, was ${request.count}")
    else effects.persist(ItemAdded(request.name, request.count)).thenReply(_ => Done)

  def getItem: ReadOnlyEffect[Item] = effects.reply(currentState)

object ItemEntity
    extends EventSourcedEntity.Companion[ItemEntity, Item, ItemEvent](
      componentId = ComponentId("item"),
      stateSerializer = Codecs.serializer[Item]("item"),
      eventSerializer = Codecs.serializer[ItemEvent]("item-event")
    ):

  given Serializer[AddItem] = Codecs.serializer[AddItem]("add-item")

  def create(context: EventSourcedEntityContext) = new ItemEntity(context)

  val addItem = command("add-item")(_.addItem)
  val getItem = query("get-item")(_.getItem)

The strings "add-item" and "get-item" are wire names: the names the platform routes and stores by. Rename the Scala methods freely; changing a wire name is a protocol change. Handlers and wire names explains why.

Run the tests

sbt test

ItemEntitySuite runs the entity with no actor system, cluster or database, so it takes milliseconds. The other two suites start a throwaway Postgres in Docker and run the real runtime against it. The first run downloads dependencies and the Postgres image, so it is slower than the ones after it.

Run it

The service keeps its journal, view rows and timers in Postgres. The database schema comes out of the ankka-runtime library, so extract it first and then start Postgres with it:

sbt schema                # writes ankka's schema into target/ddl
docker compose up -d      # Postgres on 5432, initialised from target/ddl
sbt run                   # the service, HTTP on :9000

In another terminal:

curl -XPOST localhost:9000/items/i1 -H 'content-type: application/json' -d '{"name":"Widget","count":2}'
# (HTTP 204, no body)

curl localhost:9000/items/i1
# {"id":"i1","name":"Widget","count":2}

curl localhost:9000/items/
# [{"id":"i1","name":"Widget","count":2}]

curl -XPOST localhost:9000/items/i2 -H 'content-type: application/json' -d '{"name":"Widget","count":0}'
# 400: count must be greater than zero, was 0

Stop the service with ctrl-c and start it again: the item is still there. Its state was never stored, only its events, and the entity rebuilt itself by replaying them.

Postgres applies its init directory only to an empty volume. After anything changes the schema, such as an ankka upgrade, recreate it with docker compose down -v, sbt schema and docker compose up -d.

Watch it in the local console

ankka local console       # http://localhost:9889

The console finds every ankka service running on your machine by itself. For this one it shows the two registered components, a form for each HTTP route, and a trace of every request the service has served. Send POST /items/i1 again from the form and open its trace:

POST /{id}                     41 ms
├── item#add-item              1.2 ms
└── unattributed               40 ms   (97%)

The entity took a millisecond. The unattributed time is what the platform could not assign to a component, and here it is mostly the journal write. The console can also read an item's state, through the entity's own declared query get-item. It refuses to run add-item, because a command can persist and the console only runs queries. The local console covers the rest.

Change the domain

Add a way to rename an item. The change touches each layer once, which is the usual shape of a change to an ankka service.

In domain/Item.scala, a new event and the rule for applying it:

final case class Item(id: String, name: String, count: Int):
  def onAdded(name: String, count: Int): Item =
    copy(name = name, count = this.count + count)

  def onRenamed(name: String): Item = copy(name = name)

enum ItemEvent:
  case ItemAdded(name: String, count: Int)
  case Renamed(name: String)

In application/ItemEntity.scala, apply the event, add the handler and declare it under a wire name:

  def applyEvent(event: ItemEvent): Item = event match
    case ItemAdded(name, count) => currentState.onAdded(name, count)
    case Renamed(name)          => currentState.onRenamed(name)

  def rename(request: Rename): Effect[Done] =
    if request.name.isBlank then effects.error("name must not be blank")
    else effects.persist(Renamed(request.name)).thenReply(_ => Done)

final case class Rename(name: String)

object ItemEntity
    extends EventSourcedEntity.Companion[ItemEntity, Item, ItemEvent](
      componentId = ComponentId("item"),
      stateSerializer = Codecs.serializer[Item]("item"),
      eventSerializer = Codecs.serializer[ItemEvent]("item-event")
    ):

  given Serializer[AddItem] = Codecs.serializer[AddItem]("add-item")
  given Serializer[Rename]  = Codecs.serializer[Rename]("rename")

  def create(context: EventSourcedEntityContext) = new ItemEntity(context)

  val addItem = command("add-item")(_.addItem)
  val rename  = command("rename")(_.rename)
  val getItem = query("get-item")(_.getItem)

The given serializers are declared before the handlers because object initialisation runs in order, and command needs the serializer for its argument.

In application/ItemRows.scala, keep the view's row in step. The compiler reports the missing case if you forget this, because the match over ItemEvent is no longer exhaustive:

    event match
      case ItemAdded(name, count) =>
        effects.updateRow(current.copy(name = name, count = current.count + count))
      case Renamed(name) =>
        effects.updateRow(current.copy(name = name))

In api/ItemEndpoint.scala, a route. Add a codec for the body beside the others, and the route:

  private given JsonValueCodec[Rename] = Codecs.make[Rename]

  postBody("/{id}/name") { (id: String, request: Rename) =>
    item(id).call(ItemEntity.rename).invoke(request)
  }

Import Rename alongside AddItem at the top of the file. Then add a test to ItemEntitySuite:

  test("renaming records the new name") {
    val kit    = newKit
    val _      = kit.call(ItemEntity.addItem)(AddItem("Widget", 1))
    val result = kit.call(ItemEntity.rename)(Rename("Sprocket"))
    assertEquals(result.events, Vector(Renamed("Sprocket")))
    assertEquals(kit.currentState.name, "Sprocket")
  }

Run sbt test, restart the service, and rename the item:

curl -XPOST localhost:9000/items/i1/name -H 'content-type: application/json' -d '{"name":"Sprocket"}'
curl localhost:9000/items/i1
# {"id":"i1","name":"Sprocket","count":2}

The item's earlier events are still in the journal. Adding a new event type is always safe, because no stored event has to change. Serialization and evolution covers the changes that are not.

Next

Deploy it to a local platform: build its image, apply its descriptor, and reach it over HTTPS.