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 StartUp class (the platform-neutral BenzeneStartUp contract) and boot it with the one-liner new AzureFunctionHost(StartUp).httpFunction — the exact Azure counterpart of AWS's new AwsLambdaHost(StartUp).lambdaHandler, and what the runnable examples/azure-functions uses. (The .NET isolated-worker IHostBuilder registration — UseBenzene<TStartUp> — has no direct port; AzureFunctionHost fills that role.)

Prerequisites

The core idea in 30 seconds

Benzene separates what your service does from how it's invoked:

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/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:

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 POST with {"customerId":"acme"} populates request.customerId. Unlike .NET, the TypeScript port does not bind path/query segments onto a bodyless request, so this guide uses a POST body rather than a GET /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)));
  }
}

One StartUp (and host) per trigger. Under TypeScript's type erasure two transports can't share one container, so each trigger gets its own StartUp and its own AzureFunctionHost — 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:

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)));
  }
}

BenzeneConfiguration is a small key/value lookup (config.get('ORDERS_QUEUE')); a component test layers overrides on top with .withConfiguration(...). The full .NET IConfiguration provider model is not ported — read process.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