Serialization

Nobody should have to think about this for the common case, so the common case needs no configuration:

mq.publisher("orders", "order.placed", Order.class).send(order);   // JSON
mq.consume("orders.new", Order.class, m -> handle(m.payload()));   // decoded

Write one format, read all

A publisher writes one format. A consumer reads every format on the classpath. That asymmetry is deliberate and it is what makes changing format two ordinary releases instead of a flag day:

  1. Deploy consumers that can read the new format. They still read the old one.
  2. Deploy publishers that write it.

The bytes on a queue are a contract with services that are not being redeployed right now. A queue carrying two formats at once is a queue nobody can write a consumer against — so the place to choose is once, where the destination is named.

Choosing a format

mq.publisher("legacy", "order", Order.class).asXml();
mq.publisher("config", "changed", Config.class).asYaml();
mq.publisher("flags", "changed", Flags.class).as(Codecs.byName("toml"));
mq.publisher("files", "file.new", byte[].class).asBytes();
mq.publisher("logs", "line", String.class).asText();
mq.publisher("events", "order", Order.class).as(new AvroCodec(registry));

Each returns a new publisher; the original is untouched. A long-lived object that quietly changes what it writes is worse than one that does not.

Names for Codecs.byName: json, text, bytes, plus xml, yaml, toml when their modules are on the classpath. string is accepted as another name for text, which is what .NET and Ruby call the same codec; text stays the canonical name and is the only one Codecs.names() lists.

Add the module for anything beyond JSON:

Format Artifact
JSON (default) acemq-amqp-codec-json
XML acemq-amqp-codec-xml
YAML acemq-amqp-codec-yaml
TOML acemq-amqp-codec-toml
Avro acemq-amqp-codec-avro
Protobuf acemq-amqp-codec-protobuf

Codecs are found by ServiceLoader, so adding the dependency is the whole installation. Asking for a format whose module is absent fails immediately, by name, rather than at the first message.

YAML or TOML

Both are for the same audience: a message a person reads and edits as well as a program. They are text, larger than JSON and slower to parse, so neither belongs on a high-volume path — they earn their place where somebody actually looks at the message.

Between them:

YAML Nested structures. Familiar from Kubernetes and CI files
TOML Flat-ish configuration. One way to write a string, no significant indentation, and country = NO is an error rather than a boolean

TOML's rigidity is the point. YAML has a dozen ways to write the same thing and a famous habit of turning NO into false; if a person edits the message and a machine acts on it, fewer ways to be wrong is worth more than expressiveness.

A TOML document is a table. The top level has to be an object — a list, a string or a number has no TOML representation, and the codec refuses rather than writing something back. That refusal exists because Jackson does not: given a list it writes = ['a', 'b'], a key-less assignment that its own parser then rejects. Deeply nested payloads read poorly in TOML too; where the message is a tree rather than a table, JSON is the honest answer.

Avro and Protobuf

Neither can be built without a schema, so neither has a no-argument shortcut — asAvro() would only be able to fail:

Codec avro = new AvroCodec(schemaRegistry, OrderPlaced.getClassSchema());
mq.publisher("events", "order.placed", OrderPlaced.class).as(avro);

Avro uses the Confluent wire framing — a zero byte, a four-byte big-endian schema id, then the body — so consumers written against other Confluent-compatible tooling can read it.

What each accepts on the read side

A codec writes one content type and reads several. The write side is a choice; the read side is whatever the tool that wrote the message happened to say, and refusing a label that means exactly the same bytes turns a readable message into a poison one.

Codec Writes Also reads
JsonCodec application/json text/json, any application/*+json
ProtobufCodec application/x-protobuf application/protobuf, application/vnd.google.protobuf, any *+protobuf
AvroCodec.of(...) avro/binary any avro/*, and avro types that are not the registered one
AvroCodec.registered(...) application/vnd.acemq.avro that one only

application/vnd.google.protobuf is what Google's own tooling and most schema registries write, so a message labelled that way is ordinary rather than exotic.

text/json is a legacy alias that is never correct to write and is written all the same, by older .NET stacks and a good deal of PHP. Go, Python and Ruby accept it, so Java does too — a read set that differs by language means a message one library reads and the library beside it dead-letters. The write side is unchanged.

The two Avro framings are the exception, and they stay separate: a registered message begins with five bytes a fixed-schema codec would read as the start of the first field, which does not throw — it produces a record whose every value is wrong. Each accepts only its own framing.

Say what the bytes are

A fixed-schema AvroCodec reads the content type before it looks at the bytes. Told avro/binary, it decodes the body as it stands. Told application/vnd.acemq.avro, it refuses, because that is the other framing.

Only when nothing useful was said does it fall back to guessing from the first byte, and a leading zero is then read as Confluent framing. That guess is a last resort because it cannot be made accurate: an Avro body begins with a zero byte whenever its first field encodes to zero — an empty string, a 0, a false, the first branch of a union — all ordinary values. Publishing with a content type set is what removes the ambiguity, and every publisher in this library sets one.

The schema registry

Avro's bytes and Protobuf's carry no account of what they are. A reader has to already hold the schema the writer used, and what travels in the message is a small integer standing for it. Something has to remember which integer means which schema.

JdbcSchemaRegistry registry = new JdbcSchemaRegistry(dataSource);
registry.createSchemaIfAbsent();

Identifiers have to be stable forever. A message published today may be read next year by a consumer looking up the schema it was written with. A registry that hands out fresh identifiers on restart makes every message written before the restart unreadable, and does it silently — the bytes still parse as a schema, just not the right one.

InMemorySchemaRegistry Tests and single-process demos. Forgets everything on restart
JdbcSchemaRegistry A table, in acemq-amqp-patterns. Survives restarts and is shared between replicas

JdbcSchemaRegistry caches both directions in memory and never invalidates, because neither answer can change: an identifier stands for one schema forever, and a schema keeps the identifier it was given. Registration is idempotent by content — the same schema text registered from eight replicas at once yields one row and one identifier.

It is not Confluent's registry and does not try to be: no compatibility checking, no versioning UI, no REST API. It remembers which integer means which schema, which is the part the wire format depends on. Where a Confluent registry is already running, point the codec at that instead.

createSchemaIfAbsent() is for development. In production the table belongs in whatever migration tool already owns the schema.

Schema resolution

Handed the writer's schema and the reader's, Avro reconciles the two: a field the writer added that the reader does not declare is skipped, and a field the writer omitted is filled in from the reader's default. Handed only the writer's, there is nothing to reconcile, and the record arrives in the shape it was written.

So there is one rule, and it is the same rule in all five AceMQ libraries:

Resolution happens when the library has a reader schema to resolve onto.

What differs between the languages is where a reader schema comes from, and therefore how often there is one. Nothing about the bytes differs.

Library Resolves Where its reader schema comes from
Go When asked A Go struct carries no schema, so there is nothing to resolve onto until the caller passes avro.ReaderSchema(...)
Java Sometimes Resolution needs a reader schema, and there are two ways to supply one: AvroCodec.registered(registry, readerSchema), or a generated SpecificRecord as the decode target, which carries its schema with it. A GenericRecord through a plain registry codec supplies neither, so the reader schema is the writer's and nothing resolves
.NET By default The codec is constructed with a schema, so there is always one to resolve onto unless the caller declines it: Registered(registry, schema, readerSchema) reads against a different schema than it writes, and WithoutReaderSchema() leaves the codec none at all
Python By default The codec is constructed with a schema, and that schema is the reader schema unless reader_schema= names another. A fixed-schema AvroCodec(schema) has no registry to learn a writer schema from, so there is nothing per message to resolve
Ruby By default registered(...) is constructed with a schema, and that schema is the reader schema unless reader_schema: names another. AvroCodec.of fixes one schema for the codec's whole life, reads what it writes, and resolves nothing

This is not an inconsistency waiting to be flattened. A library that resolves and a library that does not are both right about the same bytes — they are answering different questions, because only one of them was told what the reader expects.

The case that bites is a field the writer removed that the reader declares with a default. With resolution, the field arrives carrying that default. Without it, the field is simply absent: a missing key, whatever the language calls one. A consumer written against the reader schema then reads a value that was never on the wire, or fails to read a field it is sure it declared, and which of those happens is decided entirely by whether a reader schema was in play.

The other direction is the one people expect to be dangerous and is not. A field the writer added that the reader does not declare is skipped under resolution and present without it, and either way the fields the reader does declare come back correct — the unknown field does not shift the ones after it.

Both cases are pinned, with the bytes, in acemq-amqp-test/src/test/resources/fixtures/avro-resolution-fixtures.json, which every AceMQ library carries a copy of. It records the decoded value under each behaviour, as resolved and writerShape, and which library lands on which.

Asking for resolution in Java

// Resolves: every message is reconciled with the schema this consumer holds.
Codec codec = AvroCodec.registered(registry, OrderPlaced.getClassSchema());

// Does not resolve: the record arrives in the shape the producer wrote it.
Codec asWritten = AvroCodec.registered(registry);

Reach for the first unless there is a reason not to. Being able to redeploy a producer without its consumers is the whole point of putting a schema id on the front of the message, and resolution is the half of that which happens on the read side.

A generated class can also be the decode target directly:

OrderPlaced order = AvroCodec.registered(registry)
        .decode(body, OrderPlaced.class);

The class carries its own schema, so that schema is the reader schema and the resolution is the same one described above — you simply do not have to name it twice. Both routes give the same record; use whichever reads better where you are. Naming the schema explicitly is still the only option when the reader schema is not a generated class.

(Through the 0.7 line this threw a ClassCastException: the resolution happened, but a registry codec always built a generic reader and the result could not be handed back as the class asked for. On those versions, name the schema.)

Your own format

public final class CsvCodec implements Codec {
    public byte[] encode(Object payload) { ... }
    public <T> T decode(byte[] body, Class<T> type) { ... }
    public String contentType() { return "text/csv"; }
    public boolean canDecode(String contentType) { ... }
}

mq.publisher("reports", "daily", Report.class).as(new CsvCodec());

Register it for the read side by publishing a CodecProvider through ServiceLoader, and consumers will accept it alongside the built-ins.