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:
- picks the read format from the request's
content-typeheader, and - picks the write format from the request's
acceptheader (falling back to the read format, then to JSON).
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-coreon top of@benzene/avro— it frames Avro bytes in the Confluent wire format and registers each type's schema under a subject.
Troubleshooting
No Avro schema is registered for 'X'— the classXwas serialized without a schema. Register one withregisterAvroSchema(X, schema)oruseAvro(app, (o) => o.registerSchema(X, schema)). There is no reflection fallback in the port.AvroSerializer requires the target class to deserialize— you calleddeserializeFromBytes(bytes)without the message class. Avro needs the class to resolve the schema under type erasure; pass it:deserializeFromBytes(bytes, X).- XML rooted at
<Object>— you serialized a plain object literal.XmlSerializernames the root afterconstructor.name; pass a class instance (as every Benzene message type is) or an explicitrootName. - Responses stay JSON even though the format is registered — content negotiation reads the request's
acceptheader for the write format andcontent-typefor the read format. SendAccept: application/xml(orapplication/msgpack/application/avro) to get that format back; with no matching header the negotiator falls back to JSON. - Binary body looks like gibberish — MessagePack and Avro bodies are Base64-armored to survive the
string-bodied transports. Base64-decode the body before handing it to
@msgpack/msgpackoravscdirectly.
See Also
- Message Handlers — how the request/response mappers deserialize the body and pick a serializer per request.
- Message Results — the result envelope and status the payload rides alongside.
- Common Middleware — the
useXml/useMessagePackreference entries. - Validation — the parallel adapter-per-library pattern for request validation.
- Schema Registry Integration — central schema registration for Avro payloads.
- Porting conventions — how the C# serializer shapes map to TypeScript.