Your first service in Go¶
This tutorial writes a shopping cart in Go, runs it on your machine with go run, tests it, and looks at
it in the local console. You need Go 1.26 or later, Docker, and the ankka-sidecar image from
Install the tools. No JVM.
A Go service runs as its own process. ankka's runtime runs beside it as a sidecar: a container that owns everything stateful and distributed: the journal, sharding, projections, timers, HTTP and the agent loop. Your process owns the decisions: given this command and this state, what should happen. The two talk over gRPC on loopback, and the SDK hides that entirely. Services in other languages explains the model.
Create the project¶
This page builds the project one file at a time, so that each part is explained. To start from a
complete project instead, run ankka init cart --language go: it writes an entity, a view, an endpoint,
tests at both levels, a docker-compose.yml that starts Postgres and the sidecar, a Dockerfile, the
descriptor and GitHub workflows, and its README says how to run each. To follow this page, start empty:
mkdir cart && cd cart
go mod init example.com/cart
go get github.com/thinkmorestupidless/ankka-go@v0.14.0 # the version of the platform you will deploy to
The module has two packages: ankka, which a service imports, and ankka/testkit, which only its tests
import. The test kit's container dependencies reach a test binary and never the service's executable.
An entity¶
An event sourced entity keeps its state as the fold of the events it persisted. Create cart.go:
import (
"context"
ankka "github.com/thinkmorestupidless/ankka-go"
)
type LineItem struct {
ProductID string `json:"productId"`
Name string `json:"name"`
Quantity int `json:"quantity"`
}
type ShoppingCart struct {
CartID string `json:"cartId"`
Items []LineItem `json:"items"`
CheckedOut bool `json:"checkedOut"`
}
// An event is one of a closed set of types, each written under its discriminator.
type Event interface{ cartEvent() }
type ItemAdded struct {
Item LineItem `json:"item"`
}
type CheckedOut struct{}
func (ItemAdded) cartEvent() {}
func (CheckedOut) cartEvent() {}
var events = ankka.Cases[Event](
ankka.Case[Event, ItemAdded]("ItemAdded"),
ankka.Case[Event, CheckedOut]("CheckedOut"),
)
var ShoppingCartEntity = ankka.EventSourced[ShoppingCart, Event]("shopping-cart", events, empty, apply,
ankka.Command("add-item", addItem),
ankka.Command("checkout", checkout),
ankka.Query("get-cart", func(_ context.Context, cart ShoppingCart, _ ankka.Unit) (ankka.ReadOnlyEffect[ShoppingCart], error) {
return ankka.Reply(cart), nil
}),
ankka.StateCodec(ankka.CodecFor[ShoppingCart](ankka.Manifest("shopping-cart"))),
ankka.EventCodec(ankka.CodecFor[Event](ankka.Manifest("shopping-cart-event"))),
)
func empty(cartID string) ShoppingCart { return ShoppingCart{CartID: cartID, Items: []LineItem{}} }
func apply(cart ShoppingCart, event Event) ShoppingCart {
switch e := event.(type) {
case ItemAdded:
cart.Items = append(cart.Items, e.Item)
case CheckedOut:
cart.CheckedOut = true
}
return cart
}
type effect[R any] = ankka.Effect[ShoppingCart, Event, R]
func addItem(_ context.Context, cart ShoppingCart, item LineItem) (effect[ankka.Done], error) {
if cart.CheckedOut {
return effect[ankka.Done]{}, ankka.Refuse(ankka.Conflict, "cart is already checked out")
}
if item.Quantity <= 0 {
return effect[ankka.Done]{}, ankka.Refusef(ankka.BadRequest, "quantity must be greater than zero, was %d", item.Quantity)
}
return ankka.Persist[ShoppingCart, Event, ankka.Done](ItemAdded{Item: item}).
ThenReply(func(ShoppingCart) ankka.Done { return ankka.Done{} }), nil
}
func checkout(_ context.Context, cart ShoppingCart, _ ankka.Unit) (effect[ShoppingCart], error) {
if cart.CheckedOut {
return effect[ShoppingCart]{}, ankka.Refuse(ankka.Conflict, "cart is already checked out")
}
return ankka.Persist[ShoppingCart, Event, ShoppingCart](CheckedOut{}).ThenReplyState(), nil
}
Five things in this file are true of every ankka component in Go:
- A struct's
jsontags are the stored format. The codec writesproductIdexactly as tagged. A Scala, Java, Kotlin, Python, TypeScript or Rust service reading the same journal expects the same names, so keep them once data exists. - An event is one of a closed set.
Eventis an interface only this package's types satisfy, andankka.Casesnames each case's discriminator, which is what the journal holds beside it. Start with a sum type even with one event, so a second one later does not change how the first is stored. - A handler returns an effect.
Persist(...).ThenReply(...)is a description of what should happen. The handler performs no I/O, which is why it can be tested with nothing running. A refusal is an ordinary returned error, made withankka.Refuseorankka.Refusef, and persists nothing. - The wire name is the first argument of
CommandandQuery."add-item"is what the platform stores and routes by. Renaming the Go functionaddItemchanges nothing; renaming"add-item"is a protocol change. - A query cannot persist. A query's handler returns
ankka.ReadOnlyEffect[R], which nothing that persists can be. Return one that persists and the file does not compile.
An endpoint¶
An endpoint is the service's HTTP edge. Create api.go:
import (
"context"
ankka "github.com/thinkmorestupidless/ankka-go"
)
var ShoppingCartEndpoint = ankka.NewEndpoint("/carts", ankka.AllowAll(),
ankka.Post("/{cartId}/items", func(ctx context.Context, req *ankka.Request, item LineItem) (ankka.Done, error) {
return ankka.Invoke[ankka.Done](ctx, "shopping-cart", req.Param("cartId"), "add-item", item)
}),
ankka.Post("/{cartId}/checkout", func(ctx context.Context, req *ankka.Request, _ ankka.Unit) (ShoppingCart, error) {
return ankka.Invoke[ShoppingCart](ctx, "shopping-cart", req.Param("cartId"), "checkout", ankka.Unit{})
}),
ankka.Get("/{cartId}", func(ctx context.Context, req *ankka.Request) (ShoppingCart, error) {
return ankka.Invoke[ShoppingCart](ctx, "shopping-cart", req.Param("cartId"), "get-cart", ankka.Unit{})
}),
)
A route names its template and the function that runs it; a Post route's function takes the decoded
body as its third argument, and its return value is encoded as the reply. ankka.Done answers 204, and a
refusal from the entity answers the status its code maps to. Your process never binds an HTTP port: the
sidecar serves the routes you declared, applies the endpoint's access rule, and forwards each request.
The access rule is a required argument, because an access rule nobody chose is not a default worth
having. ankka.AllowAll() is fine on your machine; decide it deliberately before the service is exposed,
as HTTP endpoints shows. The context.Context a route is handed carries the
request, which is what makes the entity call appear as a child of the request in a trace.
Run it¶
Register both components and start. Create main.go:
import (
"context"
"fmt"
"os"
ankka "github.com/thinkmorestupidless/ankka-go"
)
func service() *ankka.Service { return ankka.NewService(ShoppingCartEntity, ShoppingCartEndpoint) }
func main() {
if err := service().Start(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Registration is explicit: a component that is not registered does not exist. Start serves the protocol
on port 9010 on loopback, where the sidecar finds it, and refuses to start, naming every problem, when a
component is declared wrongly.
Start Postgres and the sidecar from the root of the ankka repository, then your process:
docker compose --profile polyglot up -d # in the ankka repository: Postgres and the sidecar
go run . # in your project: your process, on 9010
In another terminal:
curl -X POST localhost:9000/carts/c1/items -H 'content-type: application/json' \
-d '{"productId":"p1","name":"Pen","quantity":2}'
curl localhost:9000/carts/c1
# {"cartId":"c1","items":[{"productId":"p1","name":"Pen","quantity":2}],"checkedOut":false}
Stop both and start both again, then read the cart: it is still there. The journal belongs to the sidecar and lives in Postgres, under the same records a Scala service writes.
Test it without the sidecar¶
The unit test kits run a component with no sidecar and no network. Create cart_test.go:
var pen = LineItem{ProductID: "p1", Name: "Pen", Quantity: 2}
func TestAddingAnItemPersistsOneEvent(t *testing.T) {
kit := testkit.EventSourced(ShoppingCartEntity, testkit.EntityID("c1"))
result := kit.Command(context.Background(), "add-item", pen)
if len(result.Events()) != 1 || result.Events()[0] != (ItemAdded{Item: pen}) {
t.Fatalf("add-item persisted %v", result.Events())
}
cart := kit.Query(context.Background(), "get-cart", ankka.Unit{}).Reply().(ShoppingCart)
if len(cart.Items) != 1 || cart.Items[0] != pen {
t.Fatalf("the cart holds %v", cart.Items)
}
}
func TestARefusedCommandPersistsNothing(t *testing.T) {
kit := testkit.EventSourced(ShoppingCartEntity, testkit.EntityID("c1"))
result := kit.Command(context.Background(), "add-item", LineItem{ProductID: "p1", Name: "Pen"})
if result.Refusal() == nil || result.Refusal().Code != ankka.BadRequest || len(result.Events()) != 0 {
t.Fatalf("a quantity of zero answered %+v", result)
}
}
go test ./...
These take milliseconds. Inputs, events, state and replies still round-trip through your codecs, so a
shape the codec cannot express fails here rather than on first deployment. The kits need nothing beyond the
standard testing package.
Test it through the real sidecar¶
The integration test kit starts Postgres and the sidecar image in Docker, serves your components to it,
and gives you an HTTP client for your routes. Add to cart_test.go:
func TestACartSurvivesARestart(t *testing.T) {
kit := testkit.StartT(t, service()) // skipped unless ANKKA_SLOW=1
ctx := t.Context()
if _, err := kit.HTTP().Post(ctx, "/carts/c1/items", pen); err != nil {
t.Fatal(err)
}
if err := kit.Restart(ctx); err != nil { // a new sidecar, the same database
t.Fatal(err)
}
cart, err := testkit.GetJSON[ShoppingCart](ctx, kit.HTTP(), "/carts/c1")
if err != nil || len(cart.Items) != 1 || cart.Items[0].Name != "Pen" {
t.Fatalf("after a restart the cart is %+v (%v)", cart, err)
}
}
ANKKA_SLOW=1 go test ./...
StartT skips the test unless ANKKA_SLOW=1 is set, so go test ./... stays fast and needs no Docker,
and it stops the containers when the test ends. Restart replaces the sidecar and keeps the database,
which is how a test proves the state is durable rather than cached. The sidecar is the one published with
the SDK's version; ANKKA_SIDECAR_IMAGE names another.
Watch it in the local console¶
ankka local console # http://localhost:9889
The sidecar is a local ankka service like any other, so the console lists it: its components, a form per
route, and a trace of each request. The trace of POST /{cartId}/items shows the endpoint and the
shopping-cart#add-item call beneath it. The console reads a cart's state through the entity's declared
query get-cart, and refuses to run a command. The local console covers
the rest.
Next¶
Deploy to a local platform. A Go service is deployed exactly as a Scala one is, with
two extra fields in its descriptor: "hosting": "process" and the protocol version its SDK speaks. The
image holds only your executable: build it with CGO_ENABLED=0 go build in a golang image and copy it
onto gcr.io/distroless/static-debian12:nonroot, which has no shell and no Go toolchain.
The Go SDK is the reference for everything else.