Building a Microservice

This guide walks through building a microservice that exercises the 6 user-facing features of the Connector - functional endpoints, web handlers, outbound events, inbound event sinks, configuration properties, and tickers. The other 2 features (workflow tasks and workflow definitions) are covered in Building Agentic Workflows.

The example throughout is an inventory microservice that exposes its stock to other services, fires events when stock runs low, listens for order events to decrement stock, and refreshes from a warehouse system on a schedule.

Step 1: Create the Microservice

Start by asking the agent to scaffold a new microservice:

Create a new microservice “Inventory” with hostname “inventory.example”.

The agent uses a skill to lay down the directory structure, generate the boilerplate, and register the microservice in main/main.go. See My First Microservice for what the scaffolded layout looks like.

Step 2: Add a Functional Endpoint

Functional endpoints are typed RPC functions other microservices call. They are the primary surface for service-to-service communication.

Add a functional endpoint “GetStock” that takes a sku string and returns count int. Read from an in-memory map.

Each functional endpoint receives ctx context.Context followed by typed input arguments and returns typed output arguments plus an err. The framework marshals inputs from the path, query string, or JSON body, and marshals outputs as JSON.

func (svc *Service) GetStock(ctx context.Context, sku string) (count int, err error) {
    svc.stockMu.RLock()
    defer svc.stockMu.RUnlock()
    return svc.stock[sku], nil
}

The route defaults to the function name in kebab-case and may include path arguments:

# manifest.yaml Representation
functions:
  GetStock:
    signature: GetStock(sku string) (count int)
    description: GetStock returns the current count for a SKU.
    method: GET
    route: /stock/{sku}

Upstream microservices reach the endpoint through a generated client stub:

count, err := inventoryapi.NewClient(svc).GetStock(ctx, "WIDGET-1")

The endpoint can also be exercised in isolation through the generated Executor in the API package:

t.Run("known_sku", func(t *testing.T) {
    assert := testarossa.For(t)
    count, err := exec.GetStock(ctx, "WIDGET-1")
    assert.Expect(count, 42, err, nil)
})

Step 3: Add a Web Handler

Web handlers give the microservice direct access to the http.ResponseWriter for HTML pages, file downloads, or any response that needs full control of the HTTP envelope.

Add a web handler “Dashboard” at /dashboard that renders an HTML table of all current stock.
# manifest.yaml Representation
webs:
  Dashboard:
    signature: Dashboard()
    description: Dashboard renders the inventory dashboard page.
    method: GET
    route: //dashboard
func (svc *Service) Dashboard(w http.ResponseWriter, r *http.Request) (err error) {
    svc.stockMu.RLock()
    rows := make([]item, 0, len(svc.stock))
    for sku, count := range svc.stock {
        rows = append(rows, item{SKU: sku, Count: count})
    }
    svc.stockMu.RUnlock()
    return svc.WriteResTemplate(w, "dashboard.html", rows)
}

Web handlers can take path arguments and have full access to request headers, query parameters, body and form data through r.PathValue, r.URL.Query(), etc.

Step 4: Fire Outbound Events

Outbound events are fire-and-forget messages a microservice broadcasts to notify others of something that happened, without knowing who (if anyone) is listening.

Add an outbound event “OnStockLow” that takes sku string and count int. Fire it when stock for a sku drops below the LowStockThreshold config.
# manifest.yaml Representation
outboundEvents:
  OnStockLow:
    signature: OnStockLow(sku string, count int)
    description: OnStockLow fires when stock for a SKU drops below threshold.
    method: POST
    route: :417/on-stock-low

The microservice fires the event using the generated multicast trigger. Outbound events use port :417 by default to differentiate them from regular endpoints on :443.

func (svc *Service) decrementStock(ctx context.Context, sku string, n int) error {
    svc.stockMu.Lock()
    svc.stock[sku] -= n
    count := svc.stock[sku]
    svc.stockMu.Unlock()

    if count < svc.LowStockThreshold() {
        for range inventoryapi.NewMulticastTrigger(svc).OnStockLow(ctx, sku, count) {
        }
    }
    return nil
}

The for range loop iterates over subscribers’ acknowledgements - useful when the producer wants to know which subscribers received the event. Producers that don’t care about subscribers can ignore the loop body.

Step 5: Subscribe to Inbound Events

Inbound event sinks are the other side of outbound events: a sink subscribes to events emitted by another microservice. The sink’s signature must match the source event.

Add an inbound event sink “OnOrderPlaced” from the orders microservice. The signature is OnOrderPlaced(orderID string, items []LineItem). For each line item, decrement stock by item.Quantity.
# manifest.yaml Representation
inboundEvents:
  OnOrderPlaced:
    signature: OnOrderPlaced(orderID string, items []LineItem)
    description: OnOrderPlaced decrements stock for each line item in the order.
    source: github.com/example/orders
func (svc *Service) OnOrderPlaced(ctx context.Context, orderID string, items []LineItem) (err error) {
    for _, item := range items {
        if err := svc.decrementStock(ctx, item.SKU, item.Quantity); err != nil {
            svc.LogError(ctx, "decrement failed", "sku", item.SKU, "err", err)
        }
    }
    return nil
}

If two source microservices emit events with the same name, the sink can rename its handler to disambiguate:

inboundEvents:
  OnOrderPlacedRetail:
    signature: OnOrderPlacedRetail(orderID string, items []LineItem)
    event: OnOrderPlaced
    source: github.com/example/retail
  OnOrderPlacedWholesale:
    signature: OnOrderPlacedWholesale(orderID string, items []LineItem)
    event: OnOrderPlaced
    source: github.com/example/wholesale

Step 6: Add Configuration Properties

Configuration properties are runtime settings the microservice declares for itself. Their values are managed externally and delivered by the configurator core microservice.

Add a config property “LowStockThreshold” of type int with a default of 10 and validation [1, 1000]. Add an OnChanged callback that logs the new value.
# manifest.yaml Representation
configs:
  LowStockThreshold:
    description: Stock count at or below this triggers OnStockLow.
    type: int
    validation: int [1,1000]
    default: 10
// Generated accessor, used directly by handler code.
threshold := svc.LowStockThreshold()

// Optional change callback, invoked when the value is updated at runtime.
func (svc *Service) OnChangedLowStockThreshold(ctx context.Context) (err error) {
    svc.LogInfo(ctx, "low-stock threshold updated", "value", svc.LowStockThreshold())
    return nil
}

Values are set per-microservice in config.yaml using the microservice’s hostname as the key:

inventory.example:
  LowStockThreshold: 25

Supported types are string, bool, int, float64 and time.Duration. Validation rules are enforced by the configurator before a new value is accepted.

Step 7: Add a Ticker

Tickers trigger a recurring operation on a periodic basis - useful for polling, cleanup jobs, cache refreshes, and other scheduled work.

Add a ticker “RefreshFromWarehouse” that runs every 15 minutes and replaces the stock map with a fresh snapshot from https://warehouse.example.com/stock.json.
# manifest.yaml Representation
tickers:
  RefreshFromWarehouse:
    description: RefreshFromWarehouse pulls a fresh stock snapshot.
    interval: 15m
func (svc *Service) RefreshFromWarehouse(ctx context.Context) (err error) {
    resp, err := svc.http.Get("https://warehouse.example.com/stock.json")
    if err != nil {
        return errors.Trace(err)
    }
    defer resp.Body.Close()

    var snapshot map[string]int
    if err := json.NewDecoder(resp.Body).Decode(&snapshot); err != nil {
        return errors.Trace(err)
    }

    svc.stockMu.Lock()
    svc.stock = snapshot
    svc.stockMu.Unlock()
    return nil
}

The ctx passed to the ticker is canceled when the microservice shuts down, so a long-running iteration terminates gracefully on a graceful shutdown signal.

Run and Test

Run the application from the project’s main/ directory:

cd main
go run main.go

The microservice is reachable through the HTTP ingress:

  • GET http://localhost:8080/inventory.example/stock/{sku} - the functional endpoint
  • GET http://localhost:8080/inventory.example/dashboard - the web handler

The ticker fires every 15 minutes. Outbound events on OnStockLow are delivered to any subscriber on the bus.

For full end-to-end verification, the integration test harness spins up the inventory microservice along with mocked downstream dependencies in a single Go test:

func TestInventory_DecrementsOnOrder(t *testing.T) {
    assert := testarossa.For(t)

    // Spin up inventory plus a mock orders microservice in one process.
    InitInventory()
    orders := InitOrdersMock()

    // Fire OnOrderPlaced from the mock and assert the stock dropped.
    err := orders.PublishOnOrderPlaced(ctx, "ORDER-1", []LineItem{
        {SKU: "WIDGET-1", Quantity: 3},
    })
    assert.NoError(err)

    count, err := inventoryapi.NewClient(orders).GetStock(ctx, "WIDGET-1")
    assert.Expect(count, 39, err, nil)
}

Further Reading