Skip to content

Hono Integration

@event-driven-io/emmett-honojs provides Hono adapters for exposing commands and read models through a Web API. Use it when you already have a Hono API, prefer Hono for a new service, or want a Fetch-native HTTP boundary with a small integration layer.

Routes remain ordinary Hono routes. A command route reads and validates the request, obtains any external data the decision needs, calls the command handler, and chooses a response. A query route reads its parameters, queries the read store, and returns the matching model. The integration supplies repeated HTTP boundary work around both kinds of route.

Nothing in the application setup or response handling requires event sourcing. Use the command-result, ETag, and event-backed testing helpers only where those concerns are part of the API.

What the Integration Adds

ConcernWhat the integration providesWhat remains in the route or application
Hono setupFeature-route registration, generated ETags, and Problem Details errors.Authentication, tracing, and application-specific routes.
HTTP responsesHelpers for status, body, Location, ETag, and Problem Details responses.Choosing the response that represents each outcome.
Command resultsMapping returned events to success or failure responses.Deciding whether business failures are returned or thrown
Optimistic concurrencyReading If-Match and returning a stream version as an ETag.Choosing which writes require a client-supplied version.
API testsGiven/when/then specifications built on Hono's in-process request API and an event store.The initial events or requests and the outcomes to assert

The integration does not dispatch commands, build projections, choose a database, or replace Hono middleware. Those choices stay explicit in the feature that defines each route.

Installation

bash
npm install @event-driven-io/emmett-honojs

Set Up Hono

A Hono bootstrap for this kind of API usually repeats the same setup: create the application, mount feature routes, and define shared error handling. getApplication collects those application-level concerns in one call. It reduces bootstrap code, but it does not hide the routes, commands, queries, or dependencies that make up the API.

The shared error handling is useful once more than one route can fail. Without it, command and query routes each need to catch errors, choose an HTTP status, and maintain their own error-body shape. The default Problem Details error handler translates thrown errors in one place and gives clients the same standard response format across the API.

None of that setup is required. An existing application can receive the same defaults through configureApplication, or retain complete control by composing the smaller exports itself.

Choose the level that matches the application:

Starting pointUseResult
A new Web APIgetApplication(options)Creates Hono and applies the default HTTP setup.
An existing Hono applicationconfigureApplication(application, options)Adds the default HTTP setup to the provided application.
An application with its own middleware planregisterWebApi(application, apiSetups)Adds only the feature routes, leaving middleware, tracing, ETags, and errors untouched.

Start a New Hono API

getApplication(options) is the shortest path when the API does not already have a Hono bootstrap:

ts
const application = getApplication({
  apis: [
    (router) => {
      router.post('/echo', async (context) => {
        const body: unknown = await context.req.json();
        return context.json({ body });
      });
    },
  ],
});

It replaces repeated application, route, ETag, and error-handler registration with the following defaults:

  1. Adds Hono's etag() middleware, which generates response-body ETags when a route has not set one explicitly.
  2. Registers the supplied feature APIs.
  3. Serialises thrown errors from those APIs in the standard Problem Details format, unless disabled.

Hono reads JSON, query string values, and route parameters through context.req. The integration does not install Express-style body parsers.

Add the Defaults to an Existing Hono API

configureApplication(application, options) applies the same setup without creating the application. Existing infrastructure routes and middleware stay on the provided app. Here /health remains available beside the configured API stack:

ts
const existingApplication = new Hono();

existingApplication.get('/health', (context) => {
  return context.json({ status: 'ok' });
});

const configuredApplication: Hono = configureApplication(
  existingApplication,
  {
    apis: [shoppingCarts],
  },
);

configureApplication calls application.onError when Problem Details handling is enabled. Hono has one effective application error handler, so set disableProblemDetailsMiddleware: true and call application.onError yourself when the application already owns that contract:

ts
const application = new Hono();
configureApplication(application, {
  apis: [asyncErrorApi],
  disableProblemDetailsMiddleware: true,
});
application.onError((error, context) => {
  return context.json(
    { detail: error instanceof Error ? error.message : 'Unknown error' },
    422,
  );
});

Compose Only What the Application Needs

Use registerWebApi(application, apiSetups) when the application already has a deliberate Hono stack. It creates and mounts the router for the supplied APIs without adding generated ETags or replacing error handling:

ts
const application = new Hono();

application.get('/health', (context) => {
  return context.json({ status: 'ok' });
});
application.use('*', async (context, next) => {
  if (context.req.header('authorization') !== 'Bearer valid-token') {
    return context.body(null, 401);
  }
  await next();
  return;
});

registerWebApi(application, [echoBodyApi, asyncErrorApi]);

application.onError((error, context) => {
  return context.json(
    { detail: error instanceof Error ? error.message : 'Unknown error' },
    500,
  );
});

The example keeps /health outside authentication, then places authentication before the API router. The application error handler remains caller-owned.

The application can opt into the remaining pieces independently:

ExportWhat it adds
registerWebApiOne router containing the supplied feature APIs.
defaultErrorToProblemDetailsMappingDefault translation from an error with errorCode to Problem Details.
Response helpersRoute helpers that return a Fetch Response with status, body, and headers.
ETag helpersExplicit If-Match parsing and response ETags for optimistic concurrency.

Start the HTTP Server

startAPI(application, options?) starts the Hono application through @hono/node-server. Omitting the options uses port 3000.

Application Options

getApplication and configureApplication accept ApplicationOptions. Omitted boolean options are treated as false. For disableProblemDetailsMiddleware, that means Problem Details handling is installed unless the option is set to true.

OptionBehaviour
apisRequired. Each setup registers routes on one shared Hono router, which is then mounted on the application.
mapErrorOptional error mapper tried before the default Problem Details mapping.
disableProblemDetailsMiddlewareDefault false. Registers a Hono onError handler for Problem Details. Set to true when the application owns error handling.

Define the Web API

Keep Routes with Their Feature

A WebApiSetup is a function that receives a Hono router. A feature can export one setup containing its related command and query endpoints, while closing over the event store, read store, and other dependencies it needs. The application bootstrap composes those setups through apis without taking ownership of the endpoints themselves.

The command and query examples below are both ordinary Hono routes registered inside a setup. The response helpers receive the active Hono context and return a Fetch Response. The route remains responsible for reading the request, calling the application dependency it needs, and choosing the outcome.

All setups receive the same router in array order. The integration mounts that router once.

Handle a Command

Command routes keep the complete application flow visible. This endpoint validates request data, obtains the current product price, builds a command, invokes the command handler, and returns 204 No Content with the next stream version as an ETag:

ts
router.post(
  '/clients/:clientId/shopping-carts/:shoppingCartId/product-items',
  async (context: AddProductItemContext) => {
    const shoppingCartId = assertNotEmptyString(
      context.req.param('shoppingCartId'),
    );
    const body = await context.req.json();
    const productItem: ProductItem = {
      productId: assertNotEmptyString(body.productId),
      quantity: assertPositiveNumber(body.quantity),
    };
    const unitPrice = dummyPriceProvider(productItem.productId);

    const result = await handle(
      eventStore,
      shoppingCartId,
      {
        type: 'AddProductItemToShoppingCart',
        data: {
          shoppingCartId,
          productItem: { ...productItem, unitPrice },
        },
      },
      {
        expectedStreamVersion: assertUnsignedBigInt(
          getETagValueFromIfMatch(context),
        ),
      },
    );

    return NoContent({
      context,
      eTag: toWeakETag(result.nextExpectedStreamVersion),
    });
  },
);

The integration does not infer the command or decision from the route. For handler setup, decisions, retries, and idempotence, see Command Handling.

Query a Read Model

Query routes use the same Hono and response abstractions without a command handler. This API queries a read model, returns 404 when the model is absent, and returns the model with 200 when found:

ts
type ShoppingCartDetails = {
  shoppingCartId: string;
  status: 'Opened' | 'Confirmed';
  productItemsCount: number;
};

const shoppingCartDetails = new Map<string, ShoppingCartDetails>([
  [
    'cart-1',
    {
      shoppingCartId: 'cart-1',
      status: 'Opened',
      productItemsCount: 2,
    },
  ],
]);

const shoppingCartDetailsApi: WebApiSetup = (router) => {
  router.get('/shopping-carts/:shoppingCartId', (context: Context) => {
    const shoppingCartId = assertNotEmptyString(
      context.req.param('shoppingCartId'),
    );
    const result = shoppingCartDetails.get(shoppingCartId);

    return result === undefined
      ? NotFound({
          context,
          problemDetails: `Shopping cart ${shoppingCartId} was not found`,
        })
      : OK({ context, body: result });
  });
};

The read store is an application choice rather than a Hono integration requirement. See Read Models for the query and projection setup.

Return HTTP Responses

Each expected outcome returns a Fetch Response: OK or NotFound for a query, and NoContent after the command completes. Each helper captures the status, body, and headers to send. The route remains responsible for choosing it.

The built-in response helpers are:

HelperStatusTypical use
OK200Return a representation, with optional Location and ETag headers.
Created201Return a newly created resource or ID and set its Location.
Accepted202Accept work that continues elsewhere and identify its status resource.
NoContent204Complete a command without returning a body, optionally with an ETag.
HttpResponseCustomUse another successful or application-specific status.
BadRequest400Return Problem Details for a request the API cannot accept.
Forbidden403Return Problem Details when the requested operation is not allowed.
NotFound404Return Problem Details when the requested resource is absent.
Conflict409Return Problem Details when the request conflicts with the current state.
PreconditionFailed412Return Problem Details when a condition such as If-Match is not satisfied.
HttpProblemCustomReturn Problem Details with another error status.

Created({ context, createdId }) appends createdId to the request URL for the Location header and sends { id: createdId, ...body }. Its url form supplies Location directly. Accepted requires location; the other success helpers accept it optionally.

Problem responses accept either problem: ProblemDocument or problemDetails: string. The latter creates a ProblemDocument with the helper's status code and the supplied string as detail.

Return Consistent Errors

Successful routes choose their response explicitly. Thrown errors need a separate path: validation can fail, a decision can reject an invalid transition, a queried dependency can be unavailable, or a read model can be missing unexpectedly. Handling each case inside every route repeats try/catch, status selection, and response-body construction, and makes it easy for endpoints to drift into different error formats.

getApplication and configureApplication therefore register a Hono onError handler by default. The handler returns application/problem+json using the Problem Details standard (RFC 9457). Clients can handle the same fields, including status, title, and detail, instead of learning a different error shape for each endpoint. For example, an IllegalStateError becomes:

json
{
  "type": "about:blank",
  "title": "Forbidden",
  "status": 403,
  "detail": "Cannot confirm an empty shopping cart"
}

The default mapping uses a numeric errorCode from 100 through 599 as the HTTP status. Errors without a valid errorCode become 500 responses with the error message as detail. Emmett's built-in validation, illegal-state, not-found, and concurrency errors carry status codes 400, 403, 404, and 412 respectively.

Pass mapError(error) to translate an application-specific error into a ProblemDocument. Returning undefined delegates to the default mapping:

ts
class CardDeclinedError extends Error {
  constructor(public readonly code: string) {
    super(`Card declined: ${code}`);
  }
}

const checkoutApi: WebApiSetup = (router) =>
  router.post('/charges', () => {
    throw new CardDeclinedError('insufficient_funds');
  });

const checkoutApplication = getApplication({
  apis: [checkoutApi],
  mapError: (error) =>
    error instanceof CardDeclinedError
      ? new ProblemDocument({
          type: 'https://errors.example.com/card-declined',
          status: 402,
          title: 'Card Declined',
          detail: error.message,
        })
      : undefined,
});

When the application already has an error contract, set disableProblemDetailsMiddleware: true and register the replacement with application.onError.

For deciding whether business logic should throw or return a failure event, see Error Handling.

Map Returned Events to HTTP Responses

A command handler can return a business failure event without appending it, for example through rejectOn or stopOn. Because nothing was thrown, Hono error handling does not see that outcome. ResponseFromEvents(options) maps it explicitly to the same Response returned by other Hono routes:

ts
application.get('/success', (context) =>
  ResponseFromEvents({
    context,
    events: successfulResult,
    success: (result) =>
      NoContent({
        context,
        eTag: toWeakETag(result.nextExpectedStreamVersion),
      }),
  }),
);
application.get('/failure', (context) =>
  ResponseFromEvents({
    context,
    events: rejectedResult,
    failure: (event) =>
      event.type === 'ProductItemOutOfStock'
        ? Conflict({
            context,
            problemDetails: `Only ${event.data.availableQuantity} items are available`,
          })
        : undefined,
  }),
);
OptionTypeBehaviour
contextContextSupplies the Hono context used to create the response.
eventsEvent[] | { events: Event[] }Supplies the events or complete command-handler result.
successnumber | (source => number | Response)Selects the success response. Defaults to 204.
failure(event, source) => number | Response | undefinedChecks events from newest to oldest. The first defined result selects the failure response.

sendResponseFromEvents(context, options) performs the same selection and returns the response immediately.

Carry Stream Versions over HTTP

An update based on stale state must not silently overwrite a newer stream version. The ETag helpers carry the optimistic concurrency check through HTTP:

  1. A successful response returns the current stream version as an ETag.
  2. The client sends that ETag in If-Match with its next update.
  3. The route passes the value to the command handler as expectedStreamVersion.
  4. If another request changed the stream first, the default error mapping returns 412 Precondition Failed.

This endpoint reads the version the client last saw and returns the version produced by the successful command:

ts
router.post(
  '/clients/:clientId/shopping-carts/:shoppingCartId/product-items',
  async (context: AddProductItemContext) => {
    const shoppingCartId = assertNotEmptyString(
      context.req.param('shoppingCartId'),
    );
    const body = await context.req.json();
    const productItem: ProductItem = {
      productId: assertNotEmptyString(body.productId),
      quantity: assertPositiveNumber(body.quantity),
    };
    const unitPrice = dummyPriceProvider(productItem.productId);

    const result = await handle(
      eventStore,
      shoppingCartId,
      {
        type: 'AddProductItemToShoppingCart',
        data: {
          shoppingCartId,
          productItem: { ...productItem, unitPrice },
        },
      },
      {
        expectedStreamVersion: assertUnsignedBigInt(
          getETagValueFromIfMatch(context),
        ),
      },
    );

    return NoContent({
      context,
      eTag: toWeakETag(result.nextExpectedStreamVersion),
    });
  },
);

getETagValueFromIfMatch reports a missing If-Match header as a 412 error. getWeakETagValue does the same for a value that does not match WeakETagRegex. A version conflict raised by the event store follows the same Problem Details path.

getApplication and configureApplication add Hono's generated etag() middleware by default. An explicit stream-version ETag from a response helper takes precedence over a generated response-body ETag. Use registerWebApi when the application should mount routes without adding generated ETags.

ETag Functions and Types

ExportType or resultBehaviour
HeaderNamesHeader-name constantsContains if-match, if-not-match, and etag.
ETagBranded stringRepresents an ETag value.
WeakETagBranded `W/${string}`Represents a weak ETag value.
WeakETagRegexRegExpMatches the weak ETag format accepted by getWeakETagValue.
ETagErrorsString enumContains invalid-format and missing-header error identifiers.
toWeakETag(value)WeakETagFormats a number, bigint, or string as a weak ETag.
isWeakETag(etag)Type guardTests an ETag with WeakETagRegex.
getWeakETagValue(etag)stringReturns the value inside a weak ETag or throws a concurrency EmmettError.
getETagFromIfMatch(context)ETagReads If-Match or throws a concurrency EmmettError when the header is absent.
getETagFromIfNotMatch(context)ETagReads if-not-match or throws a concurrency EmmettError when the header is absent.
getETagValueFromIfMatch(context)stringUnwraps a weak If-Match value and returns any other ETag value unchanged.
setETag(context, etag)voidSets the response ETag header.

Test the Web API

Unit tests cover a decision or query function on its own. An HTTP-level test covers the boundary around it: request mapping, middleware, error serialisation, response headers, and access to the configured stores.

The package provides two Hono request-based specifications:

SpecificationStarting stateAssertionsUse it for
ApiSpecificationEvents written directly to one or more streams.The HTTP response and newly appended events.An event-backed integration test of a command endpoint.
ApiE2ESpecificationRequests sent in order through the application.The final HTTP response.A black-box flow where setup passes through the same public API as the action.

Start from Recorded Events

ApiSpecification.for({ getEventStore, getApplication }) creates a fresh event store and application for each specification invocation:

ts
const apiSpecification = ApiSpecification.for({
  getEventStore: () => getInMemoryEventStore(),
  getApplication: (eventStore) =>
    getApplication({
      apis: [shoppingCartApi(eventStore)],
    }),
});

Streams passed to the given phase are written before the request. The specification wraps the event store so the assertion can check both the response observed by the caller and the events appended by that request:

ts
void it('checks an HTTP response and newly appended events', () => {
  const clientId = 'client-123';
  const shoppingCartId = `shopping_cart:${clientId}:current`;
  const productItem = { productId: 'product-123', quantity: 2 };

  return apiSpecification(
    existingStream<ShoppingCartEvent>(shoppingCartId, [
      {
        type: 'ShoppingCartOpened',
        data: {
          shoppingCartId,
          clientId,
          openedAt: new Date('2024-01-01T00:00:00Z'),
        },
      },
    ]),
  )
    .when((request) =>
      request
        .post(
          `/clients/${clientId}/shopping-carts/${shoppingCartId}/product-items`,
        )
        .set({ [HeaderNames.IF_MATCH]: toWeakETag(1) })
        .send(productItem),
    )
    .then([
      expectResponse(204, { headers: { etag: toWeakETag(2) } }),
      expectNewEvents(shoppingCartId, [
        {
          type: 'ProductItemAddedToShoppingCart',
          data: {
            shoppingCartId,
            productItem: { ...productItem, unitPrice: 100 },
          },
        },
      ]),
    ]);
});

Start from Previous Requests

ApiE2ESpecification.for({ getEventStore?, getApplication }) prepares state through requests instead of writing directly to the event store. It sends the given requests in order, sends the request under test, and checks the final response. getEventStore defaults to getInMemoryEventStore.

ts
const apiE2ESpecification = ApiE2ESpecification.for({
  getEventStore: () => getInMemoryEventStore(),
  getApplication: (eventStore) =>
    getApplication({
      apis: [shoppingCartApi(eventStore)],
    }),
});

This example opens a cart, adds a product, and then confirms it through the public API:

ts
void it('prepares state through HTTP requests', () => {
  const clientId = 'client-123';
  const productItem = { productId: 'product-123', quantity: 2 };

  return apiE2ESpecification(
    (request) =>
      request.post(`/clients/${clientId}/shopping-carts/`).send(productItem),
    (request) =>
      request
        .post(`/clients/${clientId}/shopping-carts/${clientId}/product-items`)
        .set({ [HeaderNames.IF_MATCH]: toWeakETag(1) })
        .send(productItem),
  )
    .when((request) =>
      request
        .post(`/clients/${clientId}/shopping-carts/${clientId}/confirm`)
        .set({ [HeaderNames.IF_MATCH]: toWeakETag(2) }),
    )
    .then([expectResponse(204, { headers: { etag: toWeakETag(3) } })]);
});

Assertion Helpers

ExportBehaviour
existingStreamCreates a [streamId, events] tuple that ApiSpecification writes before the request.
expectCreates a [streamId, events] tuple to match against events appended during the request.
expectNewEventsAn equivalent, more explicit name for expect.
expectResponseChecks the exact status code and, when supplied, matches the specified body and header fields.
expectErrorCalls expectResponse with an error status and optional Problem Details fields.
TestRequestA function receiving a Hono test agent and returning its configured request. It is used for given and when requests.

ApiSpecification.then accepts a response assertion, expected event streams, or a response assertion followed by expected event streams. ApiE2ESpecification.then accepts one response assertion in a one-element array.

For choosing between decision, API, and infrastructure-backed tests, see Testing.

Compatibility

The package peer dependencies are Hono ^4.11.7 and @hono/node-server ^1.19.9. startAPI uses the Node server adapter. The application object remains a Hono application, so tests can call application.request(...) or use the package testing utilities without opening a network port.

Type Sources

See Also