Proof of concept. This repo explores how to model messaging contracts with Smithy. Kafka is the first target; the same trait-driven approach should extend to other brokers (AMQP, Pulsar, NATS). Nothing here is stable.
A Smithy trait library for messaging, inspired by
Disney's Alloy. This repo owns the
contract: trait definitions, protocol specs, and validators. Code generators
depend on the bote JAR to implement the protocols.
A reference AsyncAPI generator ships as a separate module
(io.bote:smithy-asyncapi) so the trait library itself has no codegen
dependencies.
Protocols, one per service, picking broker and wire encoding:
| Trait | Broker / mode | Encoding | Status |
|---|---|---|---|
@kafkaJson |
Kafka | JSON | Defined |
@kafkaAvro |
Kafka + Schema Registry | Avro | Defined |
@kafkaProtobuf |
Kafka | Protobuf | Defined |
@redisStreamsJson |
Redis Streams | JSON | Defined |
@redisPubSubJson |
Redis Pub/Sub | JSON | Defined |
Operation traits, one per broker capability, in the broker's own vocabulary (the same principle as Smithy's HTTP and MQTT bindings). Each carries the channel address:
| Produce side | Consume side | Broker |
|---|---|---|
@kafkaProduce(topic:) |
@kafkaConsume(topic:) |
Kafka; compacted declares log compaction |
@redisStreamAdd(stream:) |
@redisStreamRead(stream:) |
Redis Streams; maxLen caps the stream |
@redisPublish(channel:) |
@redisSubscribe(channel:) |
Redis Pub/Sub |
A produce operation takes a @command input and no output. A consume
operation streams @event payloads through a @streaming union in its
output.
Message traits, broker-agnostic:
@commandmarks an instruction the contract owner accepts.@eventmarks a fact the contract owner emits.@replyis reserved. No current protocol supports replies; request-reply needs broker-native plumbing such as AMQP'sreply_to/correlation_id.
Member traits: @kafkaKey marks the Kafka message key, @kafkaHeader
maps a member to a Kafka header. @avroCompatibility declares the Avro
compatibility mode.
Infrastructure (namespace bote.infra): @kafkaTopicConfig declares
partitions, replication, and retention.
bote models the API a contract owner offers to other applications, not both sides of a conversation. The owner defines the commands it accepts, the events it emits, and the channel names. The channel address is part of the API surface, like a URI in a REST contract.
Provisioning is separate. @kafkaTopicConfig lives in bote.infra so a
platform team can attach it from its own model file with apply, without
touching the contract (see examples/kafka/model/infra.smithy). The owner
may also declare it inline; the namespace split only makes separate ownership
possible.
$version: "2"
namespace example.orders.infra
use bote.infra#kafkaTopicConfig
apply example.orders#ConsumeOrderEvents @kafkaTopicConfig(
partitions: 6
replicationFactor: 3
retentionMs: 604800000
)Validators enforce the model:
- A channel address belongs to exactly one service.
- Operations sharing an address must declare identical channel values
(
compacted,maxLen). @kafkaTopicConfigmay appear on at most one operation per topic.- An operation carries exactly one broker operation trait, and it must match the service's protocol.
- Produce inputs must be
@command; consume outputs must stream@eventshapes; produce operations must not declare an output.
The protocol specs pin down serialization so independent code generators interoperate:
-
@kafkaHeadermembers travel only as Kafka headers and are never serialized into the JSON value (like@httpHeaderand the HTTP body). -
The
@kafkaKeymember is serialized both as the Kafka message key and as a field of the value. -
@commandvalues are the bare JSON serialization of their structure. -
@eventvalues carry a discriminator so consumers of a multi-event channel can tell event types apart.@kafkaJsontakes aneventDiscriminationsetting:ENVELOPE(default): the value is wrapped in a single-key object keyed by the@streamingunion member name, the same idiomrestJson1uses for tagged unions:{"placed": {"orderId": "42", ...}}HEADER: the value is bare; abote-typeKafka header carries the member nameNONE: no discriminator; at most one event type per channel (validator-enforced)
The Redis JSON protocols always use the envelope.
@kafkaAvroneeds no discriminator because the schema ID in the Confluent wire format identifies the event type.
For @kafkaProtobuf, the @streaming union maps to a proto message with a
oneof, which is the discriminator. Every payload member must carry an
explicit alloy.proto#protoIndex (validator-enforced): implicit numbering
breaks the wire format when members are reordered or removed. @kafkaHeader
members still travel only as headers; their index is reserved, not
serialized.
One command and one event subscription; the topics are carried by the operation traits.
$version: "2"
namespace example.orders
use bote#command
use bote#event
use bote#kafkaConsume
use bote#kafkaJson
use bote#kafkaKey
use bote#kafkaProduce
@kafkaJson
service OrderService {
operations: [SubmitOrder, ConsumeOrderEvents]
}
@kafkaProduce(topic: "orders.commands")
operation SubmitOrder {
input: SubmitOrderCommand
}
@kafkaConsume(topic: "orders.events")
operation ConsumeOrderEvents {
output := { events: OrderEvents }
}
@command
structure SubmitOrderCommand {
@kafkaKey
orderId: String
customerId: String
}
@event
structure OrderPlaced {
@kafkaKey
orderId: String
customerId: String
}
@streaming
union OrderEvents {
placed: OrderPlaced
}The smithy-asyncapi module is a Smithy build plugin that emits an
AsyncAPI 3.1 document per bote protocol service.
AsyncAPI 3 actions describe the application the document is about, so the
mapping depends on the perspective setting. The default "owner" describes
the contract owner: produce operations become action: receive and consume
operations become action: send. Setting "perspective": "client" flips
both. The document records its side in info.x-bote-perspective so readers
and tooling need not guess.
| bote | AsyncAPI 3.1 |
|---|---|
| protocol service | the document (info, defaultContentType) |
| operation trait address (topic / stream / channel) | a channel |
produce input / consume output |
the channel's and operation's messages |
| broker operation trait | an operation; the action follows the perspective |
| payload structure | a component message plus JSON Schema payload |
@kafkaKey |
the Kafka message binding key |
@kafkaHeader |
the message headers schema |
| HEADER event discrimination | a constant bote-type property in headers |
@kafkaTopicConfig |
Kafka channel binding partitions/replicas/config |
compacted: true |
cleanup.policy: [compact] |
Enable the plugin in a Smithy build:
{
"version": "1.0",
"sources": ["model"],
"plugins": {
"asyncapi": {}
}
}By default one file is written per protocol service, named
<ServiceName>.asyncapi.json. Optional settings: service targets one
service, perspective picks the viewpoint.
"plugins": { "asyncapi": { "service": "examples.kafka.orders#OrderService", "perspective": "owner" } }Two example modules exercise the generator end to end: examples/kafka
(order service and streetlight device over JSON, telemetry over Protobuf)
and examples/redis (Streams chat, Pub/Sub presence). Each generates the owner document in the source
projection and the client document in a client projection. Run
gradle build and inspect
build/smithyprojections/<module>/<projection>/asyncapi/.
To view a generated document in AsyncAPI Studio:
just studio # kafka / StreetlightDevice, owner view
just studio kafka OrderService
just studio redis ChatRoom client # client viewRequires Node; the recipe runs npx @asyncapi/cli start studio <file> and
live-reloads on rebuild.
| Module | Artifact | Contents |
|---|---|---|
| (root) | bote |
trait definitions, protocol specs, validators |
codegen/smithy-asyncapi |
smithy-asyncapi |
the AsyncAPI Smithy build plugin |
examples/kafka |
Kafka example contracts | |
examples/redis |
Redis Streams and Pub/Sub examples |
Requires Java 21. A devenv environment provides the
toolchain, just, and the formatters.
just lists the recipes:
| Recipe | What it does |
|---|---|
just build |
build and validate the models, run the generator and tests |
just studio [module] [service] [perspective] |
build, then open a generated doc in AsyncAPI Studio |
just fmt |
format everything (treefmt, gradle smithyFormat) |
just golden |
regenerate the golden AsyncAPI docs CI diffs against |
just verify-golden |
check generated docs against the golden files |
just publish-local |
publish the JARs to the local Maven repo |
just clean |
clean build outputs |
just rebuild |
clean then build |
Gradle works directly as well: gradle build, gradle publishToMavenLocal.
Published to Maven Central under the io.github.thomaslaich.bote group:
io.github.thomaslaich.bote:bote:<version>
io.github.thomaslaich.bote:smithy-asyncapi:<version>
Releases are cut by publishing a GitHub release with a v<version> tag; the
release workflow builds, signs, and uploads to Maven Central via the Sonatype
Central Portal.
Exploratory. APIs will change. No stability guarantees.