Getting Started: Benzene on Azure Functions
Benzene runs on Azure Functions (the v4 isolated-worker programming model), hosting one set of message handlers across multiple triggers — HTTP, Service Bus, and Event Hub — through a single middleware pipeline. This guide starts from an empty folder and ends with a Function App serving HTTP requests, then adds the async messaging triggers so you can see how one handler works across trigger types without changing a line of it.
If you're brand new to Benzene, read Getting Started first — it builds the same kind of service locally on Express in about five minutes. The message handler you write there runs unchanged on Azure Functions; only the entry point differs, and that's what this guide covers.
TypeScript port. This is the TypeScript port of Benzene. It mirrors the .NET library's shape as closely as the language allows; where the two differ, the README's Porting conventions explain why. You write one
StartUpclass (the platform-neutralBenzeneStartUpcontract) and boot it with the one-linernew AzureFunctionHost(StartUp).httpFunction— the exact Azure counterpart of AWS'snew AwsLambdaHost(StartUp).lambdaHandler, and what the runnableexamples/azure-functionsuses. (The .NET isolated-workerIHostBuilderregistration —UseBenzene<TStartUp>— has no direct port;AzureFunctionHostfills that role.)
Prerequisites
- Node.js 22+ and npm
- Any editor
- The Azure Functions Core Tools and an Azure subscription — only if you want to run or deploy a real Function App. Everything up to that point is ordinary TypeScript you can build locally.
The core idea in 30 seconds
Benzene separates what your service does from how it's invoked:
- A message handler contains your logic. It receives a typed request, returns a typed result, and knows nothing about Azure Functions, HTTP, or queues.
- Each handler is mapped to a topic — a stable string like
order:place— via the@messagedecorator, and (for HTTP) to a method and path via@httpEndpoint. - A transport pipeline turns an incoming trigger payload into a message, routes it to the matching handler by topic, and turns the result back into a trigger-native response.
On Azure Functions you build that pipeline once at module load, then export a small function callback per trigger that dispatches the trigger's payload into it. The handler itself is identical to the one you'd host on Express or AWS Lambda. See Message Handlers and Middleware for the full picture.
1. Create the project
mkdir orders-functions && cd orders-functions
npm init -y
npm pkg set type=module
Setting type=module makes this an ES-module project, which Benzene's packages require — and it's the
shape the @azure/functions v4 model expects.
2. Install the packages
npm install @benzene/azure-function-core @benzene/azure-function-http \
@benzene/azure-function-service-bus @benzene/azure-function-event-hub \
@benzene/core-message-handlers @benzene/http @benzene/results \
@benzene/abstractions @benzene/abstractions-message-handlers
npm install @azure/functions @azure/service-bus @azure/event-hubs
npm install --save-dev typescript
Each Azure trigger has its own transport package:
@benzene/azure-function-core— theAzureFunctionHostthat boots yourStartUp, and theuseAzureFunctionsselector you wire triggers on insideconfigure.@benzene/azure-function-http— the HTTP transport (useAzureHttp) and itshandleHttpRequestdispatch helper.@benzene/azure-function-service-bus— the Service Bus transport (useServiceBus) andhandleServiceBusMessages.@benzene/azure-function-event-hub— the Event Hub transport (useEventHub/useBenzeneMessage) andhandleEventHub.
@benzene/core-message-handlers brings the message-handler infrastructure (addBenzene,
useMessageHandlers, the @message decorator); @benzene/http adds the httpEndpoint helper;
@benzene/results provides BenzeneResult. The @azure/* packages are the trigger runtime and its
message types.
3. Write a message handler
Create src/handlers.ts. This is where your logic lives — the file you'd carry over verbatim if you
later moved to Express or AWS Lambda:
import { IBenzeneResultOf } from '@benzene/abstractions';
import { IMessageHandler } from '@benzene/abstractions-message-handlers';
import { message } from '@benzene/core-message-handlers';
import { httpEndpoint } from '@benzene/http';
import { BenzeneResult } from '@benzene/results';
// Payloads are classes, not interfaces: the runtime recovers the erased request type from its
// constructor (for topic/schema keying), which an interface can't provide.
export class PlaceOrder {
customerId?: string;
}
export class OrderConfirmation {
orderId?: string;
}
@httpEndpoint('POST', '/orders')
@message('order:place', { requestType: PlaceOrder, responseType: OrderConfirmation })
export class PlaceOrderHandler implements IMessageHandler<PlaceOrder, OrderConfirmation> {
handleAsync(request: PlaceOrder): Promise<IBenzeneResultOf<OrderConfirmation>> {
const confirmation = new OrderConfirmation();
confirmation.orderId = `order-${request.customerId ?? 'anon'}`;
return Promise.resolve(BenzeneResult.created(confirmation));
}
}
Two decorators do the wiring:
@message('order:place', …)maps the handler to its topic. Every Benzene transport routes by topic, so this identifier stays constant across HTTP, Service Bus, and Event Hub. TherequestType/responseTypegive the runtime the concrete classes it needs (TypeScript erases generics, so they can't be inferred).@httpEndpoint('POST', '/orders')maps an HTTP method and path onto that same topic, so the same handler answers both an HTTP request and a direct topic-routed message from a messaging trigger.
BenzeneResult.created(...) is the success case that maps to HTTP 201; use BenzeneResult.ok(...) for
200. The result carries success/failure status alongside the payload — see
Message Result.
Request binding. Benzene binds the JSON request body onto your request object, so a
POSTwith{"customerId":"acme"}populatesrequest.customerId. Unlike .NET, the TypeScript port does not bind path/query segments onto a bodyless request, so this guide uses aPOSTbody rather than aGET /hello/{name}. Read values a client sends in the body.
4. Write the composition root (StartUp)
Create src/startUp.ts — the single place your service is wired. It implements the platform-neutral
BenzeneStartUp contract (the same shape on every cloud): configureServices registers the service
graph, and configure wires the transport pipeline on the unified IBenzeneApplicationBuilder, selecting
Azure inside it with useAzureFunctions(app, az => …):
import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { BenzeneConfiguration, BenzeneStartUp, IBenzeneApplicationBuilder } from '@benzene/abstractions-middleware';
import { addBenzene, useMessageHandlers } from '@benzene/core-message-handlers';
import { useAzureFunctions } from '@benzene/azure-function-core';
import { useAzureHttp } from '@benzene/azure-function-http';
import { PlaceOrderHandler } from './handlers';
export class HttpStartUp implements BenzeneStartUp {
configureServices(services: IBenzeneServiceContainer, _config: BenzeneConfiguration): void {
// Register your application services here. `addBenzene` pulls in the serializer and message-handler
// infrastructure every transport needs.
addBenzene(services);
}
configure(app: IBenzeneApplicationBuilder, _config: BenzeneConfiguration): void {
useAzureFunctions(app, (az) => useAzureHttp(az, (http) => useMessageHandlers(http, PlaceOrderHandler)));
}
}
useAzureFunctions(app, az => …)is the Azure counterpart of AWS'suseAwsLambda(app, aws => …): it hands you the Azure trigger builder and no-ops on any other platform, so the SAMEStartUpis portable.- Inside it,
useAzureHttp(az, http => …)inserts the HTTP transport anduseMessageHandlers(http, …)routes a matched request to its handler. Pass every handler class you want served.
One StartUp (and host) per trigger. Under TypeScript's type erasure two transports can't share one container, so each trigger gets its own
StartUpand its ownAzureFunctionHost— exactly what the steps below do. This mirrors the per-function Lambda default described in the README.
5. Boot the host
Create src/functions.ts. This is the only file that knows it's running on Azure Functions: it boots one
AzureFunctionHost per trigger (once, at module load) and exports the native-trigger handler the runtime
registers. For HTTP that's .httpFunction; importing @benzene/azure-function-http lights the getter up
(the same import your StartUp already needs for useAzureHttp):
import { AzureFunctionHost } from '@benzene/azure-function-core';
import '@benzene/azure-function-http';
import { HttpStartUp } from './startUp';
/** HTTP trigger (request/response): `POST /orders` returns an order confirmation. */
export const placeOrderHttp = new AzureFunctionHost(HttpStartUp).httpFunction;
new AzureFunctionHost(HttpStartUp).httpFunction is the one-liner boot — the Azure counterpart of AWS's
new AwsLambdaHost(StartUp).lambdaHandler. It boots the SAME HttpStartUp a component test boots, so
what you test is what deploys. (Prefer a free function over the getter? The host also exposes its built
app: handleHttpRequest(host.app, request) does the same thing.)
6. Register with the Functions host
The @azure/functions v4 runtime discovers your triggers from app.* registrations. Create
src/registrations.ts — the module the Functions host loads, which binds each callback to a real
trigger:
import { app } from '@azure/functions';
import { placeOrderHttp } from './functions';
// The getter is already an `@azure/functions` HTTP handler, so it drops straight into `handler`.
app.http('placeOrder', {
methods: ['POST'],
authLevel: 'anonymous',
route: 'orders',
handler: placeOrderHttp,
});
app.http(...) registers with the @azure/functions runtime on import, so this module is loaded by the
host — not by your other code. Keeping it separate from functions.ts means the trigger getters stay plain
exports while this file owns the runtime bindings.
7. Add the messaging triggers
The whole point of Benzene is that a handler doesn't care which trigger delivered its message. Add an event consumer that reacts to placed orders — the same shape, a different topic:
// add to src/handlers.ts
export class OrderPlaced {
orderId?: string;
}
export class WarehouseAck {
accepted?: boolean;
}
@message('order:placed', { requestType: OrderPlaced, responseType: WarehouseAck })
export class NotifyWarehouseHandler implements IMessageHandler<OrderPlaced, WarehouseAck> {
handleAsync(request: OrderPlaced): Promise<IBenzeneResultOf<WarehouseAck>> {
// ... notify the warehouse
const ack = new WarehouseAck();
ack.accepted = true;
return Promise.resolve(BenzeneResult.ok(ack));
}
}
NotifyWarehouseHandler has no @httpEndpoint — it's reached only by its topic, order:placed, over
whichever messaging trigger delivers it. Add a StartUp per messaging trigger to src/startUp.ts (each
the same shape as HttpStartUp, only the transport verb differs):
// add to src/startUp.ts
import { useServiceBus } from '@benzene/azure-function-service-bus';
import { useBenzeneMessage, useEventHub } from '@benzene/azure-function-event-hub';
import { NotifyWarehouseHandler } from './handlers';
/** Service Bus trigger (batched): each message routes by its `topic` application property. */
export class ServiceBusStartUp implements BenzeneStartUp {
configureServices(services: IBenzeneServiceContainer, _config: BenzeneConfiguration): void {
addBenzene(services);
}
configure(app: IBenzeneApplicationBuilder, _config: BenzeneConfiguration): void {
useAzureFunctions(app, (az) => useServiceBus(az, (sb) => useMessageHandlers(sb, NotifyWarehouseHandler)));
}
}
/** Event Hub trigger (batched): each event carries a serialized BenzeneMessage envelope; route on its topic. */
export class EventHubStartUp implements BenzeneStartUp {
configureServices(services: IBenzeneServiceContainer, _config: BenzeneConfiguration): void {
addBenzene(services);
}
configure(app: IBenzeneApplicationBuilder, _config: BenzeneConfiguration): void {
useAzureFunctions(app, (az) =>
useEventHub(az, (eh) => useBenzeneMessage(eh, (msg) => useMessageHandlers(msg, NotifyWarehouseHandler))),
);
}
}
Then boot each in src/functions.ts — one AzureFunctionHost per trigger, exposing that trigger's
native getter:
import '@benzene/azure-function-service-bus';
import '@benzene/azure-function-event-hub';
import { EventHubStartUp, ServiceBusStartUp } from './startUp';
/** Service Bus trigger (batched): each message routes by its `topic` application property. */
export const orderPlacedServiceBus = new AzureFunctionHost(ServiceBusStartUp).serviceBusFunction;
/** Event Hub trigger (batched): each event routes by its embedded topic. */
export const orderPlacedEventHub = new AzureFunctionHost(EventHubStartUp).eventHubFunction;
Two things to note:
- Service Bus resolves the topic from each message's
topicapplication property, then routes it to the matching handler..serviceBusFunctionaccepts a single message or a batch. - Event Hub is shaped differently: events carry a serialized
BenzeneMessageenvelope, so you wrap the inner handlers inuseBenzeneMessage, which deserializes each event and routes on the envelope's own topic..eventHubFunctionlikewise takes a batch.
Then bind both to real triggers in src/registrations.ts:
import { app, InvocationContext } from '@azure/functions';
import type { ServiceBusReceivedMessage } from '@azure/service-bus';
import type { ReceivedEventData } from '@azure/event-hubs';
import { orderPlacedEventHub, orderPlacedServiceBus, placeOrderHttp } from './functions';
// app.http('placeOrder', { ... }) as in step 6
app.serviceBusQueue('orderPlacedServiceBus', {
connection: 'ServiceBusConnection',
queueName: 'orders',
cardinality: 'many', // batched: the handler receives an array of messages
handler: (messages: unknown, _context: InvocationContext) =>
orderPlacedServiceBus(messages as ServiceBusReceivedMessage[]),
});
app.eventHub('orderPlacedEventHub', {
connection: 'EventHubConnection',
eventHubName: 'orders',
cardinality: 'many',
handler: (events: unknown, _context: InvocationContext) =>
orderPlacedEventHub(events as ReceivedEventData[]),
});
connection names an app setting holding the Service Bus / Event Hubs connection string (or the
identity-based settings). Adding a trigger is a wiring change, not a rewrite — the handlers in
handlers.ts never changed.
Supported triggers
Each trigger is a transport package with a use… function you call inside configure, plus the
AzureFunctionHost getter your registrations.ts binds to a real trigger.
| Azure trigger | Transport function | Host getter | Package |
|---|---|---|---|
| HTTP | useAzureHttp |
.httpFunction |
@benzene/azure-function-http |
| Service Bus | useServiceBus |
.serviceBusFunction |
@benzene/azure-function-service-bus |
| Event Hub | useEventHub / useBenzeneMessage |
.eventHubFunction |
@benzene/azure-function-event-hub |
The examples/azure-functions project hosts one order domain on all three
triggers — the handlers identical in shape to the AWS Lambda example's, proving the same handler runs on
both clouds unchanged.
Configuration
AzureFunctionHost builds the pipeline once, on cold start. Register your own services inside your
StartUp's configureServices; the natural source of configuration in a Function App is its application
settings via process.env:
export class HttpStartUp implements BenzeneStartUp {
configureServices(services: IBenzeneServiceContainer, _config: BenzeneConfiguration): void {
addBenzene(services);
services.addSingletonInstance(OrdersConfig, { queueName: process.env.ORDERS_QUEUE ?? 'orders' });
}
configure(app: IBenzeneApplicationBuilder, _config: BenzeneConfiguration): void {
useAzureFunctions(app, (az) => useAzureHttp(az, (http) => useMessageHandlers(http, PlaceOrderHandler)));
}
}
BenzeneConfigurationis a small key/value lookup (config.get('ORDERS_QUEUE')); a component test layers overrides on top with.withConfiguration(...). The full .NETIConfigurationprovider model is not ported — readprocess.env(or your own loader) directly for now.
Troubleshooting
Handler never called / 404 from the HTTP trigger. Check that @httpEndpoint('METHOD', '/path')
matches the request exactly (method and route), that the app.http(...) route matches the endpoint
path, and that the handler class was passed to useMessageHandlers(...).
Service Bus message never routes to a handler. The Service Bus transport resolves the topic from the
message's topic application property, not the body. Confirm the producer sets it, and that a handler
exists with a matching @message('...') topic.
Event Hub event never routes to a handler. Event Hub expects a serialized BenzeneMessage envelope
and routes on the envelope's topic. Make sure the producer sends a Benzene message and that the inner
handlers are wrapped in useBenzeneMessage.
Two transports collide in one entry point. Under type erasure two transports can't share a single
container. Give each trigger its own StartUp and its own AzureFunctionHost (as the steps above do)
rather than adding two use… transports to one configure.
See Also
- Getting Started — build the same handler locally on Express first
- AWS Lambda Setup — the same handlers, hosted on AWS
- Message Handlers — the handler contract, topics, and
@message/@httpEndpoint - Message Result —
BenzeneResult.ok/.createdand the result envelope - Middleware and Common Middleware — what else composes into the pipeline
- Testing Benzene — testing handlers and pipelines end-to-end
examples/azure-functions— one domain on three Azure triggers