Getting Started on Azure Functions
This guide takes the greet handler from Getting Started and deploys it to
Azure Functions as a custom handler. Read
Getting Started first — this guide assumes you already know how a handler, the
three-phase App, and the ApplicationBuilder fit together, and only covers what changes when the
host is Azure Functions.
Azure has no native Go worker, so a Go function runs as an Azure Functions
custom handler: your
program is a plain HTTP server, and the Functions host forwards each trigger invocation to it over a
small JSON envelope (Data/Metadata in, Outputs/ReturnValue out). The azurefunctions package
adapts that envelope to a Benzene pipeline,
so your handlers stay identical to the ones you'd run behind plain HTTP, AWS Lambda, or anywhere else.
The complete, runnable version of everything below is
examples/azure-functions-helloworld.
Prerequisites
- Go (the version in the repo's
go.mod) - Azure Functions Core Tools v4
— provides
funcfor running locally and deploying - An Azure subscription and the Azure CLI, if you want to deploy
1. The handler
Business logic lives in a handler, exactly as in Getting Started — nothing
about it is Azure-specific, which is the point. It takes a request, returns a
Result[T], and knows nothing about the
transport that invoked it:
type greetRequest struct {
Name string `json:"name"`
}
type greetResponse struct {
Greeting string `json:"greeting"`
}
func greetHandler(_ context.Context, req greetRequest) benzene.Result[greetResponse] {
if req.Name == "" {
return benzene.BadRequest[greetResponse]("name is required")
}
return benzene.Ok(greetResponse{Greeting: "Hello, " + req.Name + "!"})
}
benzene.Ok and benzene.BadRequest return the Benzene status the binding maps to a real HTTP
status code — Ok → 200, BadRequest → 400 — via the
status mapping the azurefunctions binding
applies for you.
2. The app
The composition root is the ordinary three-phase benzene.App — the same shape every Benzene host
boots from. ConfigureServices registers the handler under a
topic; Configure builds the pipeline with
RouterMiddleware, which dispatches each request to the handler registered for its topic:
func newApp() benzene.App[struct{}] {
return benzene.App[struct{}]{
GetConfiguration: func() struct{} { return struct{}{} },
ConfigureServices: func(registry *benzene.Registry, _ *benzene.Container, _ struct{}) {
if err := benzene.Register(registry, benzene.NewTopic("greet"), benzene.Handler[greetRequest, greetResponse](greetHandler)); err != nil {
log.Fatalf("register greet handler: %v", err)
}
},
Configure: func(builder *benzene.ApplicationBuilder, _ struct{}) {
builder.UsePipeline(benzene.NewPipeline(benzene.RouterMiddleware(builder.Registry)))
},
}
}
3. Wire up the custom handler
This is the only Azure-specific code. azurefunctions.Handler takes the built
*benzene.ApplicationBuilder and an HTTP route table, and returns an http.Handler that speaks the
custom-handler Data/Metadata envelope:
func routes() []httpbinding.Route {
return []httpbinding.Route{{Method: http.MethodPost, Path: "/Greet", Topic: benzene.NewTopic("greet")}}
}
func newHandler(builder *benzene.ApplicationBuilder) http.Handler {
return azurefunctions.Handler(builder, routes())
}
The route table is the same httpbinding.Route type the plain-HTTP binding uses (method + path +
topic), so azurefunctions.Handler mirrors httpbinding.Handler's shape — an explicit route table
and real HTTP status codes — rather than inventing a new contract.
One subtlety worth internalizing: Route.Path here is /Greet, the local invocation path
the Functions host uses to call your process — by default /<FunctionName>, the name of that
function's folder (see Greet/function.json). This is independent of the public route
("greet") declared in function.json, which is the URL your users hit. The host maps the public
route to the function, then calls your handler on the local path; azurefunctions.Handler routes on
that local path.
Path parameters captured by a {param} route template arrive on the request as route-<name> wire
headers, the same convention httpbinding uses.
4. main
main reads the port the Functions host assigns via FUNCTIONS_CUSTOMHANDLER_PORT (the custom-handler
analogue of Cloud Run's PORT), boots the app, and serves the handler:
func portFromEnv() string {
if port := os.Getenv("FUNCTIONS_CUSTOMHANDLER_PORT"); port != "" {
return port
}
return "8080"
}
func main() {
handler := newHandler(newApp().Run())
port := portFromEnv()
log.Printf("azure-functions-helloworld listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, handler))
}
newApp().Run() runs the three phases (GetConfiguration → ConfigureServices → Configure) once
and returns the built *benzene.ApplicationBuilder — the same lifecycle described in
Getting Started, unchanged by the host.
5. The Functions host files
A custom handler needs three configuration files alongside your compiled binary.
host.json — tells the Functions host to run your binary and use the JSON-envelope mode (the mode
the azurefunctions package adapts):
{
"version": "2.0",
"customHandler": {
"description": {
"defaultExecutablePath": "handler",
"workingDirectory": "",
"arguments": []
},
"enableForwardingHttpRequest": false
}
}
Greet/function.json — the Greet function: an HTTP trigger bound to the input name req (the key
azurefunctions.Handler reads the trigger data from), with the public route greet:
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req",
"methods": ["post"],
"route": "greet"
},
{
"type": "http",
"direction": "out",
"name": "res"
}
]
}
local.settings.json — local func start settings; the key line is
FUNCTIONS_WORKER_RUNTIME=custom:
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "",
"FUNCTIONS_WORKER_RUNTIME": "custom"
}
}
6. Run it locally
Build the binary named in host.json (handler), then start the host:
cd examples/azure-functions-helloworld
go build -o handler .
func start
func start sets FUNCTIONS_CUSTOMHANDLER_PORT, launches your handler binary, and forwards
requests to it. Hit the public route (Azure prefixes HTTP routes with /api by default):
curl -X POST "http://localhost:7071/api/greet" -d '{"name":"World"}'
# {"greeting":"Hello, World!"}
curl -X POST "http://localhost:7071/api/greet" -d '{"name":""}'
# 400 Bad Request
7. Test it — without the host
You don't need func start (or the network) to test the binding. The benzenetest package boots
your real app and pushes a native custom-handler invocation straight through azurefunctions.Handler,
reading the framework-mapped status back out of the Outputs["res"] envelope:
func newTestHost() *benzenetest.Host {
return benzenetest.NewHost(newApp(), benzenetest.WithRoutes(routes()...))
}
func TestGreet(t *testing.T) {
resp := benzenetest.SendAzureHTTP(t, newTestHost(), http.MethodPost, "/Greet", greetRequest{Name: "World"}, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("res.StatusCode = %d, want 200; body = %s", resp.StatusCode, resp.Body)
}
// resp.Body is {"greeting":"Hello, World!"}
}
benzenetest.SendAzureHTTP is the Azure analogue of SendAPIGateway — it builds the exact
Data/Metadata JSON the Functions host sends and asserts the outer HTTP 200 / Outputs.res.statusCode
split that the real host relies on (see Supported triggers for why that split
matters). SendAzureQueue and SendCosmosChangeFeed do the same for the other two triggers.
Run the suite the usual way:
go test ./examples/azure-functions-helloworld/...
8. Deploy
Build a Linux binary (the Functions host runs Linux), then zip-deploy the directory with func:
cd examples/azure-functions-helloworld
GOOS=linux GOARCH=amd64 go build -o handler .
func azure functionapp publish <your-function-app-name> --custom
func ... --custom zip-deploys host.json, Greet/, and the handler binary as-is (it excludes
local.settings.json) — no container required. This assumes an existing Function App created with
FUNCTIONS_WORKER_RUNTIME=custom; create one with the Azure CLI or the Portal first.
Once deployed, hit the printed URL at /api/greet. Azure Functions also supports
deploying a custom handler as a Linux container
if you have OS-level dependencies; the example's README explains why it doesn't ship a Dockerfile for
that path.
The two custom-handler modes
host.json above sets enableForwardingHttpRequest: false (the default). In this mode Azure
forwards the structured Data/Metadata envelope, which is what azurefunctions.Handler adapts —
and what gives you access to trigger metadata and (for the non-HTTP triggers below) message
properties.
Setting it to true switches Azure to forward the raw HTTP request/response instead. In that
mode you skip the azurefunctions package entirely and pass httpbinding.Handler straight to
http.ListenAndServe (reading FUNCTIONS_CUSTOMHANDLER_PORT in place of PORT) — functionally
equivalent for a pure-HTTP function, one less package in the graph, at the cost of the envelope's
structured metadata. Raw forwarding only exists for HTTP triggers, so the queue and Cosmos triggers
below require the default (false) mode.
Supported triggers
The azurefunctions package implements three trigger shapes today. Each is a separate
http.Handler you mount on that function's local invocation path; a single custom handler can host
several at once by mounting them on an http.ServeMux, one per function path.
HTTP — azurefunctions.Handler
Covered in full above. Topic-routed through an httpbinding.Route table, real HTTP status codes,
route-<name> headers for path parameters. This is the
HTTP transport binding over the
custom-handler envelope.
The HTTP handler always answers the Functions host with an outer HTTP 200; the real result
travels inside Outputs.res.statusCode. A non-200 outer status would tell the host that the custom
handler process failed, not that your application returned an error — so an application-level 400 or
404 rides in the inner status, exactly as the host expects.
Queue Storage & Service Bus — azurefunctions.QueueHandler
mux.Handle("/GreetQueue", azurefunctions.QueueHandler(builder, "queueItem"))
QueueHandler adapts queue-shaped triggers — Azure Storage Queue and Service Bus
queue/topic — which share the same Data/Metadata invocation envelope. dataName is the trigger
binding's name from that function's function.json (e.g. "queueItem" for a Storage Queue,
"mySbMsg" for Service Bus); the message is read from Data[dataName].
Topic resolution follows the same order as the AWS SQS/SNS bindings:
- a
topicentry inMetadata.UserProperties(Service Bus application properties — the native per-message attribute channel; the remaining string properties become wire headers), else - the message body parsed as a full
wire.Requestenvelope (the only option on Storage Queues, which carry no per-message attributes), else - an empty topic, which
RouterMiddlewaremaps to a validation error — the message is failed, never silently dropped.
Unlike the HTTP handler, a non-success dispatch answers the host with outer HTTP 500. On a
queue-shaped trigger, a non-2xx custom-handler response is how the invocation is marked failed, which
hands the message to the platform's own retry machinery — Storage Queue redelivery up to
maxDequeueCount then the poison queue; Service Bus abandon/redelivery then the dead-letter queue.
This is the Azure counterpart of the AWS bindings' returned error / batch-item-failure.
Cosmos DB Change Feed — azurefunctions.CosmosHandler
mux.Handle("/OrdersChanged", azurefunctions.CosmosHandler(builder, benzene.NewTopic("orders:changed"), "documents"))
CosmosHandler adapts the Cosmos DB Change Feed trigger. The Functions host owns the change-feed
connection and lease container and forwards each delivered batch of changed documents under
Data[dataName] (a JSON array), so your handler never opens a Cosmos connection itself.
This binding is fan-in, not topic-routed: the whole batch of changed documents is one pipeline
invocation — not one per document — dispatched to the single topic you name in the call. The handler
receives the batch as its request, idiomatically a slice: benzene.Handler[[]OrderDocument, TRes].
Checkpointing is batch-level (the change feed has no per-document resume token) and happens on a
successful return only, so CosmosHandler uses the same outer-status convention as QueueHandler: a
successful dispatch answers outer HTTP 200 (the host advances the lease past this batch) and any
non-success dispatch answers outer HTTP 500 (the host does not checkpoint and redelivers the whole
batch). Design the handler to be idempotent across a redelivered batch.
Other triggers
Other Azure trigger types — Timer, Blob Storage, Event Grid, Event Hubs, Kafka — are not
implemented in the Go port. They follow the same Data/Metadata envelope, so a new adapter is the
QueueHandler pattern with a different payload interpretation; nothing in the design blocks them, but
the package does not ship them today.
This is narrower than the .NET port, whose Azure Functions guide covers HTTP, Event Hubs, Kafka, Service Bus, Cosmos DB Change Feed, Queue Storage, Blob Storage, Event Grid, and Timer. That breadth rides on .NET's isolated-worker model and a source generator; the Go port uses the custom-handler HTTP contract and ships the three trigger shapes above.
See also
- Getting Started — the handler,
App, and pipeline this guide builds on examples/azure-functions-helloworld— the complete, runnable example, including the CI deploy workflow- Core concepts — topics, results, pipeline, and the fan-in shape the Cosmos trigger uses
- Wire contracts — topic resolution and the status vocabulary the bindings map
- Transport bindings — the HTTP binding the custom-handler HTTP adapter mirrors