Serialization & Media Formats

How a Benzene message's payload becomes bytes on the wire, and how you add and choose alternative formats.

Overview

A Benzene message carries a body that must be serialized to send and deserialized to hand to a handler. The transport-neutral request/response mappers drive this: on the way in the raw body is deserialized to the handler's request type, and on the way out the handler's result payload is serialized back onto the response (see Request mapping & content negotiation).

Which serializer runs is decided per request by the media-format negotiator. Every transport registers addMediaFormatNegotiation, which installs a MediaFormatNegotiator<TContext> seeded with the built-in JSON format and any additional IMediaFormat<TContext> you register. For each message the negotiator:

JSON is the default — it ships in @benzene/core-message-handlers (JsonMediaFormat over JsonSerializer) and is always present, so you never install anything to use it. When no header matches a registered format, the negotiator falls back to JSON. Adding a format is opt-in: install its package and register it into a pipeline, and content negotiation starts routing matching requests to it while everything else stays JSON.

Because a message's status is a plain string and the envelope is transport-neutral, the payload format is negotiated independently of the status/HTTP mapping (see Message Results).

The three adapter packages

Like the validation adapters, the alternative serializers are adapters over popular JavaScript libraries, one package per library, each mirroring its .NET counterpart's shape. Each package ships an IMediaFormat<TContext> (extending AcceptHeaderMediaFormatBase) plus an ISerializer, and a pair of registration free functions (add* for a container, use* for a pipeline builder — C# extension methods become free functions taking the builder first, per the porting conventions).

Package Adapts (real dependency) Content type Register on a pipeline
@benzene/xml fast-xml-parser application/xml useXml(app)
@benzene/messagepack @msgpack/msgpack application/msgpack useMessagePack(app)
@benzene/avro avsc application/avro useAvro(app)

Each adapter takes its underlying library as a real runtime dependency — that is the whole point of an adapter package. Install whichever you need:

npm install @benzene/xml          # over fast-xml-parser
# or
npm install @benzene/messagepack  # over @msgpack/msgpack
# or
npm install @benzene/avro         # over avsc

Prerequisite: Node 22+. You can install more than one and register them on the same pipeline — the negotiator then picks whichever matches each request's headers, JSON included.

XML — @benzene/xml

@benzene/xml adapts fast-xml-parser. Registering it adds XmlMediaFormat<TContext> (content type application/xml) alongside the default JSON format, so a request whose content-type is application/xml is read as XML, and a request whose accept asks for application/xml is written as XML.

Because fast-xml-parser is shape-based (not reflection-driven like .NET's System.Xml.Serialization.XmlSerializer), XmlSerializer roots the document at the payload's runtime constructor.name — so pass a class instance, as every Benzene message type is, to get a meaningful root element. Element text is kept as strings so values round-trip faithfully ('007' stays '007', not 7).

import express from 'express';
import { IBenzeneResultOf } from '@benzene/abstractions';
import { IMessageHandler } from '@benzene/abstractions-message-handlers';
import { message, useMessageHandlers } from '@benzene/core-message-handlers';
import { BenzeneResult } from '@benzene/results';
import { benzene } from '@benzene/express';
import { useXml } from '@benzene/xml';

class CreateOrder {
  orderId: string | undefined;
}
class OrderCreated {
  orderId: string | undefined;
}

@message('order:create', { requestType: CreateOrder, responseType: OrderCreated })
export class CreateOrderHandler implements IMessageHandler<CreateOrder, OrderCreated> {
  handleAsync(request: CreateOrder): Promise<IBenzeneResultOf<OrderCreated>> {
    const payload = new OrderCreated();
    payload.orderId = request.orderId;
    return Promise.resolve(BenzeneResult.created(payload));
  }
}

const app = express();
app.use(
  benzene((pipeline) => {
    useXml(pipeline);                              // register the XML media format
    useMessageHandlers(pipeline, CreateOrderHandler);
  }),
);
app.listen(3000);

Now the same handler serves both formats. A request with Content-Type: application/json reads and writes JSON (the default); a request with Content-Type: application/xml reads <CreateOrder><orderId>…</orderId></CreateOrder> and, if it also sends Accept: application/xml, gets XML back. Nothing about the handler changes — content negotiation is entirely at the media-format seam.

useXml(pipeline) calls addXml when the pipeline builds, which registers the shared XmlSerializer and adds XmlMediaFormat as an IMediaFormat. See Common Middleware → useXml for the signature.

MessagePack — @benzene/messagepack

@benzene/messagepack adapts @msgpack/msgpack. Registering it adds MessagePackMediaFormat<TContext> (content type application/msgpack) alongside JSON, negotiated the same way — content-type/accept: application/msgpack.

MessagePack is a genuinely binary format, but every Benzene transport carries its body as a string. So MessagePackSerializer Base64-armors the MessagePack bytes on the string path (serialize produces Base64 text, deserialize consumes it), letting binary MessagePack flow unchanged through the existing string-bodied pipeline. A client sending or receiving MessagePack must Base64-decode/encode the body accordingly. Like JSON — and unlike Avro — MessagePack is schemaless: any plain object serializes by its own shape, so there is nothing to register per type.

import express from 'express';
import { useMessageHandlers } from '@benzene/core-message-handlers';
import { benzene } from '@benzene/express';
import { useMessagePack } from '@benzene/messagepack';
import { CreateOrderHandler } from './CreateOrderHandler.js';

const app = express();
app.use(
  benzene((pipeline) => {
    useMessagePack(pipeline);                      // register the MessagePack media format
    useMessageHandlers(pipeline, CreateOrderHandler);
  }),
);
app.listen(3000);

A request with Content-Type: application/msgpack and a Base64-encoded MessagePack body is now decoded to CreateOrder; if it sends Accept: application/msgpack, the response payload comes back as Base64 MessagePack. See Common Middleware → useMessagePack for the signature.

Avro — @benzene/avro

@benzene/avro adapts avsc. Registering it adds AvroMediaFormat<TContext> (content type application/avro) alongside JSON. Avro is genuine binary too, so AvroSerializer Base64-armors the bytes on the string path exactly like MessagePack.

Avro requires a registered schema

Avro is schema-based: every serialized type needs an Avro schema. In .NET, Benzene.Avro can reflect over a CLR type's properties to generate one. TypeScript erases types at runtime, so there is no reflection fallback in the port — you must register a schema for every message class Avro serializes. An unregistered type throws at (de)serialize time. This mirrors the way the validation adapters recover "which validator for TRequest" and the way @benzene/avro's registry is keyed by the message class rather than an erased type.

Register a schema on the process-wide global registry with registerAvroSchema(MessageClass, schema), passing either a compiled avro.Type or a plain Avro schema object ({ type: 'record', … }, compiled lazily on first use). getAvroSchema(MessageClass) looks the compiled avro.Type back up.

import express from 'express';
import * as avro from 'avsc';
import { useMessageHandlers } from '@benzene/core-message-handlers';
import { benzene } from '@benzene/express';
import { registerAvroSchema, useAvro } from '@benzene/avro';
import { CreateOrder, OrderCreated, CreateOrderHandler } from './CreateOrderHandler.js';

// Every class Avro (de)serializes needs a registered schema — there is no reflection fallback.
registerAvroSchema(
  CreateOrder,
  { type: 'record', name: 'CreateOrder', fields: [{ name: 'orderId', type: 'string' }] },
);
registerAvroSchema(
  OrderCreated,
  avro.Type.forSchema({ type: 'record', name: 'OrderCreated', fields: [{ name: 'orderId', type: 'string' }] }),
);

const app = express();
app.use(
  benzene((pipeline) => {
    useAvro(pipeline);                             // register the Avro media format
    useMessageHandlers(pipeline, CreateOrderHandler);
  }),
);
app.listen(3000);

Instead of the global registry you can scope schemas to a single pipeline by passing a configure callback to useAvro (or addAvro), which builds an AvroOptions whose registerSchema you call. An options-scoped schema wins over a global one for the same class:

useAvro(pipeline, (options) => {
  options.registerSchema(CreateOrder, {
    type: 'record',
    name: 'CreateOrder',
    fields: [{ name: 'orderId', type: 'string' }],
  });
});

Schema resolution is done by AvroSchemaResolver (the default IAvroSchemaResolver), which checks the options-scoped AvroSchemaRegistry first, then AvroSchemaRegistry.global, then throws with a message telling you to register the schema. For tests, construct an isolated AvroSchemaRegistry instance so registrations don't leak into global discovery.

Note that because the schema is keyed by class, deserializing bare Avro bytes needs the target class: AvroSerializer.deserialize(text, MessageClass) / deserializeFromBytes(bytes, MessageClass). Inside the pipeline the request/response mappers supply that class from the handler's @message metadata, so you rarely call the serializer directly.

Central schema registration. To publish Avro schemas to a Confluent-style registry so other services (including non-Benzene ones) can resolve the exact writer schema, layer @benzene/schema-registry-core on top of @benzene/avro — it frames Avro bytes in the Confluent wire format and registers each type's schema under a subject.

Troubleshooting

See Also