Skip to content

Maven Central GitHub

ZenWave AsyncAPI Generator

The ZenWave AsyncAPI Generator solves a long-standing issue in event-driven Java applications:

👉 keeping message models and channel contracts fully aligned with their AsyncAPI specification.

It provides build-time read-only code generation from AsyncAPI files sourced from their canonical locations: local files, classpath resources, or authenticated remote URLs.

This approach eliminates API drift by enforcing the AsyncAPI file as the single source of truth for message schemas, channel definitions, and producer or consumer interfaces.

All generated classes are strongly typed and annotated for validation, and any breaking change in the specification results in a compile-time error.

This model aligns with the proven pattern used by the OpenAPI Maven Generator plugin, which solved API drift for OpenAPI in the Java ecosystem, generating non-editable interfaces and DTOs, and ensures contract consistency across consumers and producers.

You just need to focus on implementing your business logic, and configuring either Spring Cloud Streams or Spring Kafka for message transport.

A complete working example is available in the zenwave-playground repository: asyncapi-shopping-cart.

  • Complete Producer and Consumer implementations for Java and Spring Boot with thin wrappers around:
    • Spring Cloud Stream for multi-broker support
    • Spring Kafka for native Kafka integrations
  • JSON DTOs generated via jsonschema2pojo library
  • Avro DTOs generated via Apache Avro library
  • AsyncAPI v2 and v3
  • Local files, classpath files, and authenticated remote URLs
  • DTO generation for JSON Schema and Avro
  • Automatic Avro schema ordering for Avro versions prior to 1.12.0
  • Strongly typed header objects from AsyncAPI message definitions
  • Transactional Outbox pattern using Spring Modulith
  • Role reversal to generate only the side you need (provider or client)
  • Operation-level filtering for selective interface generation
  • Automatic header mapping at runtime through the x-runtime-expression extension

Use these essential configuration options to get the generator running quickly:

  • inputSpec: Path or URL for the AsyncAPI specification
  • role: provider (default) or client
  • templates: SpringCloudStream (default) or SpringKafka
  • modelPackage: Java package for generated DTOs (required for JSON Schema)
  • producerApiPackage: Java package for producer interfaces (provider role)
  • consumerApiPackage: Java package for consumer interfaces (client role)
  • avroCompilerProperties.imports: Comma-separated Avro files or folders
  • operationIds: Filter to generate only selected operations
  • authentication: Credentials for authenticated remote AsyncAPI files

Advanced customization is available through:

  • avroCompilerProperties.xxx: Settings passed to the underlying Avro compiler
  • jsonschema2pojo.xxx: Settings passed to jsonschema2pojo

Why ZenWave doesn’t source Spring Cloud Stream / Spring Kafka config from AsyncAPI

Section titled “Why ZenWave doesn’t source Spring Cloud Stream / Spring Kafka config from AsyncAPI”

ZenWaveSDK provides all the building blocks to prevent API drift, keeping your source code aligned with your AsyncAPI specification. But it does not attempt to auto-configure Spring Cloud Stream or Spring Kafka from the AsyncAPI file.

While configuring Spring Cloud Stream or Spring Kafka from AsyncAPI would be convenient, it is not currently feasible.

AsyncAPI, even with specific bindings like Kafka bindings, does not yet provide enough information to fully configure Spring Kafka or Spring Cloud Stream:

  • no standard fields for acks, transaction.id, idempotence, and similar settings
  • Avro SerDes configuration is too ambiguous to infer automatically
  • Kafka bindings define clientId and consumerGroupId per operation, although these are typically application-wide
  • you can configure a schema registry endpoint but not compatibility modes (BACKWARD, FORWARD, etc.) for subjects or topics

This is why ZenWave does not attempt full auto-configuration from AsyncAPI. The specification is not expressive enough for these frameworks.

Still, this is a one-time operation that belongs in your Spring configuration files, where it can be controlled explicitly, versioned with your application, and tuned independently from your AsyncAPI contract.

jbang zw -p AsyncAPIGeneratorPlugin \
  authentication[0].key=API_KEY \
  authentication[0].value=API_KEY_VALUE \
  apiFile=https://raw.githubusercontent.com/ZenWave360/zenwave-playground/refs/heads/main/examples/asyncapi-shopping-cart/apis/asyncapi.yml \
  consumerApiPackage=io.example.api.consumer \
  producerApiPackage=io.example.api.producer \
  modelPackage=io.example.api.model \
  avroCompilerProperties.imports="\
    https://raw.githubusercontent.com/ZenWave360/zenwave-playground/refs/heads/main/examples/asyncapi-shopping-cart/apis/avro/Item.avsc,\
    https://raw.githubusercontent.com/ZenWave360/zenwave-playground/refs/heads/main/examples/asyncapi-shopping-cart/apis/avro/ShoppingCart.avsc" \
  targetFolder=target/generated-sources

Use apiOverlayFiles to patch the AsyncAPI source before dereferencing and allOf merge.

jbang zw -p AsyncAPIGeneratorPlugin \
  apiFile=src/main/resources/asyncapi.yml \
  apiOverlayFiles=src/main/resources/asyncapi-overlay.yml \
  modelPackage=io.example.api.model \
  producerApiPackage=io.example.api.producer \
  consumerApiPackage=io.example.api.consumer \
  targetFolder=target/generated-sources

Overlay files are applied in order. This is intended for local files and file-backed classpath: resources.

<properties>
    <asyncapiPrefix>classpath:io/example/asyncapi/shoppingcart/apis</asyncapiPrefix>
    <asyncapi.inputSpec>${asyncapiPrefix}/asyncapi.yml</asyncapi.inputSpec>
    <asyncapi.avro.imports>
        ${asyncapiPrefix}/avro/
    </asyncapi.avro.imports>

    <zenwave.asyncapiGenerator.templates>SpringKafka</zenwave.asyncapiGenerator.templates>

    <asyncApiProducerApiPackage>${basePackage}.events</asyncApiProducerApiPackage>
    <asyncApiConsumerApiPackage>${basePackage}.commands</asyncApiConsumerApiPackage>
</properties>
<plugin>
    <groupId>io.zenwave360.sdk</groupId>
    <artifactId>zenwave-sdk-maven-plugin</artifactId>
    <version>${zenwave.version}</version>
    <configuration>
        <inputSpec>${asyncapi.inputSpec}</inputSpec>
        <skip>false</skip>
        <addCompileSourceRoot>true</addCompileSourceRoot>
        <addTestCompileSourceRoot>true</addTestCompileSourceRoot>
        <authentication>
            <authentication>
                <key>API_KEY</key>
                <value>XXXXXX</value>
            </authentication>
        </authentication>
    </configuration>
    <executions>
        <execution>
            <id>generate-asyncapi</id>
            <phase>generate-sources</phase>
            <goals>
                <goal>generate</goal>
            </goals>
            <configuration>
                <generatorName>AsyncAPIGenerator</generatorName>
                <configOptions>
                    <role>provider</role>
                    <templates>${zenwave.asyncapiGenerator.templates}</templates>
                    <modelPackage>${asyncApiModelPackage}</modelPackage> <!--  required only for json-schema, otherwise it uses avro package -->
                    <producerApiPackage>${asyncApiProducerApiPackage}</producerApiPackage>
                    <consumerApiPackage>${asyncApiConsumerApiPackage}</consumerApiPackage>
                    <avroCompilerProperties.imports>${asyncapi.avro.imports}</avroCompilerProperties.imports><!-- comma separated list of avro files or folders -->
                </configOptions>
            </configuration>
        </execution>
    </executions>
    <dependencies>
        <dependency>
            <groupId>io.zenwave360.sdk.plugins</groupId>
            <artifactId>asyncapi-generator</artifactId>
            <version>${zenwave.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.avro</groupId>
            <artifactId>avro-compiler</artifactId>
            <version>${avro.version}</version>
            <exclusions>
                <exclusion>
                    <groupId>com.fasterxml.jackson.core</groupId>
                    <artifactId>jackson-core</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>com.fasterxml.jackson.core</groupId>
                    <artifactId>jackson-databind</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>
</plugin>
val asyncapiPrefix = "classpath:io/example/asyncapi/shoppingcart/apis"
val asyncapiInputSpec = "$asyncapiPrefix/asyncapi.yml"
val asyncapiAvroImports = "$asyncapiPrefix/avro/"
val asyncapiGeneratorTemplates = "SpringKafka"
val asyncApiProducerApiPackage = "io.zenwave360.examples.events"
val asyncApiConsumerApiPackage = "io.zenwave360.examples.commands"

plugins {
    java
    id("dev.jbang") version "0.3.0"
}

tasks.register<dev.jbang.gradle.tasks.JBangTask>("generateAsyncApiProvider") {
    group = "asyncapi"
    description = "Generates Producer and Consumer code from AsyncAPI specification"
    script.set("io.zenwave360.sdk:zenwave-sdk-cli:RELEASE")
    jbangArgs.set(listOf(
        "--deps=" +
            "org.slf4j:slf4j-simple:1.7.36," +
            "io.zenwave360.sdk.plugins:asyncapi-generator:RELEASE," +
            "org.apache.avro:avro-compiler:1.11.1"
    ))
    args.set(listOf(
        "-p", "AsyncAPIGenerator",
        "role=provider",
        "templates=$asyncapiGeneratorTemplates",
        "apiFile=$asyncapiInputSpec",
        "targetFolder=${layout.buildDirectory.dir("generated-sources/zenwave").get().asFile.absolutePath}",
        "transactionalOutbox=modulith",
        "modelPackage=$asyncApiModelPackage",
        "producerApiPackage=$asyncApiProducerApiPackage",
        "consumerApiPackage=$asyncApiConsumerApiPackage",
        "avroCompilerProperties.imports=$asyncapiAvroImports"
    ))
}

sourceSets {
    main {
        java {
            srcDir(layout.buildDirectory.dir("generated-sources/zenwave/src/main/java").get().asFile)
        }
    }
    test {
        java {
            srcDir(layout.buildDirectory.dir("generated-sources/zenwave/src/test/java").get().asFile)
        }
    }
}

ZenWaveSDK does not derive Spring Cloud Stream bindings from the AsyncAPI specification. Binding configuration must be provided explicitly in your application.yml.

spring:
  cloud:
      stream:
        bindings:
          shopping-cart-channel-out: # AsyncAPI channel name (kebab-case) with the `-out` suffix (no `-0`)
            destination: shopping-cart
            content-type: application/*+avro
            producer:
              use-native-encoding: true
spring:
  cloud:
    stream:
      kafka.binder.enable-observation: true
      bindings:
        shopping-cart-channel-in-0: # AsyncAPI channel name (kebab-case) with the `-in-0` suffix
          destination: shopping-cart
          group: shopping-cart-group

Note on binding name suffixes: Producer and consumer bindings use different naming conventions. Producers use the binding name exactly as declared (for example, -out), while consumers use the indexed form (-in-0) required by Spring Cloud Stream. The AsyncAPI generator does not append -0 to producer bindings (unlike the legacy SpringCloudStream3Plugin). This behavior is kept for backward compatibility.

ZenWaveSDK also does not generate Spring Kafka configuration from the AsyncAPI file. Topic and consumer settings must be defined manually in application.yml.

app:
  kafka:
    topics:
      shopping-cart-channel: # AsyncAPI channel name (kebab-case)
        topic: shopping-cart
        groupId: shopping-cart-group            # consumers only
        containerFactory: shopping-cart-container-factory # consumers only
OptionDescriptionTypeDefaultValues
apiFileAPI Specification FileURI
apiOverlayFilesOrdered list of API overlay YAML files applied before dereferencing and allOf merge.List[]
roleProject role: provider/clientAsyncapiRoleTypeproviderprovider, client
templatesTemplates to use for code generation.StringSpringCloudStreamSpringCloudStream, SpringKafka, FQ Class Name
modelPackageJava Models package nameString
producerApiPackageJava API package name for outbound (producer) services. It can override apiPackage for producers.String{{apiPackage}}
consumerApiPackageJava API package name for inbound (consumer) services. It can override apiPackage for consumer.String{{apiPackage}}
apiPackageJava API package, if producerApiPackage and consumerApiPackage are not set.String
operationIdsOperation ids to include in code generation. Generates code for ALL if left emptyList[]
excludeOperationIdsOperation ids to exclude in code generation. Skips code generation if is not included or is excluded.List[]
transactionalOutboxTransactional outbox type for message producers.TransactionalOutboxTypenonenone, modulith
jsonschema2pojoJsonSchema2Pojo settings for downstream library (docs)Map
avroCompilerPropertiesAvro Compiler PropertiesAvroCompilerPropertiesSee AvroCompilerProperties
avroCompilerProperties.sourceDirectoryAvro schema file or folder containing avro schemasFile
avroCompilerProperties.importsAvro schema files or folders containing avro schemas. It supports local files/folders, classpath: files/folders or https:// file resources.List
avroCompilerProperties.includesA set of Ant-like inclusion patterns used to select files from the source tree that are to be processed. By default, the pattern **/*.avsc is used to include all avro schema files.List[**/*.avsc]
avroCompilerProperties.excludesA set of Ant-like exclusion patterns used to prevent certain files from being processed. By default, this set is empty such that no files are excluded.List
avroCompilerProperties.customLogicalTypeFactoriesCustom Logical Type FactoriesList
avroCompilerProperties.customConversionsCustom ConversionsList
componentPrefixPrefix used in to reference this component in @Component and application.ymlString
componentSuffixSuffix used in to reference this component in @Component and application.ymlString
generatedAnnotationClassAnnotation class to mark generated code (e.g. org.springframework.aot.generate.Generated). When retained at runtime, this prevents code coverage tools like Jacoco from including generated classes in coverage reports.String
authenticationAuthentication configuration values for fetching remote resources.List[]
targetFolderTarget folder to generate code to. If left empty, it will print to stdout.File
modelNamePrefixSets the prefix for model classes and enumsString
modelNameSuffixSets the suffix for model classes and enumsString
runtimeHeadersPropertyAsyncAPI extension property name for runtime auto-configuration of headers.Stringx-runtime-expression
messageNamesMessage names to include in code generation (combined with operationIds). Generates code for ALL if left emptyList[]
sourceFolderSource folder inside folder to generate code to.Stringsrc/main/java
bindingTypesBinding names to include in code generation. Generates code for ALL bindings if left emptyList
avroFilesList of avro schema files to generate code for. It is alternative to sourceDirectory and imports.List
skipProducerImplementationGenerate only the producer interface and skip the implementation.booleanfalse
exposeMessageWhether to expose underlying spring Message to consumers or not.booleanfalse
useEnterpriseEnvelopeInclude support for enterprise envelop wrapping/unwrapping.booleanfalse
envelopeJavaTypeExtensionNameAsyncAPI Message extension name for the envelop java type for wrapping/unwrapping.Stringx-envelope-java-type
methodAndMessageSeparatorTo avoid method erasure conflicts, when exposeMessage or reactive style this character will be used as separator to append message payload type to method names in consumer interfaces.String$
consumerPrefixConsumer object class name prefixString
consumerSuffixConsumer object class name suffixStringConsumer
consumerServicePrefixBusiness/Service interface prefixStringI
consumerServiceSuffixBusiness/Service interface suffixStringConsumerService
formatterCode formatter implementationFormatterspalantirpalantir, spring, google
skipFormattingSkip java sources output formattingbooleanfalse
haltOnFailFormattingHalt on formatting errorsbooleantrue

ZenWave SDK supports sending messages transactionaly using the Transactional Outbox Pattern.

Transactional Outbox with AsyncAPI and Spring Modulith

See Implementing a Transactional OutBox With AsyncAPI, SpringModulith and ZenWaveSDK for complete details.

Populating Headers at Runtime Automatically

Section titled “Populating Headers at Runtime Automatically”

ZenWave SDK provides x-runtime-expression for automatic header population at runtime. Values for this extension property are:

  • $message.payload#/<json pointer fragment>: follows the same format as AsyncAPI Correlation ID object.
  • $message.payload#{ <SpEL expression> }: will use the SpEL expression to populate the header value.
  • $supplierBeanName: will use a bean named supplierBeanName (you can use any other name) of type java.function.Supplier configured in your Spring context.
    CustomerEventMessage:
      name: CustomerEventMessage
      // [...] other properties omitted for brevity
      headers:
        type: object
        properties:
          kafka_messageKey:
            type: string
            description: This one will be populated automatically at runtime
            x-runtime-expression: $message.payload#/customer/id
          tracingId:
            type: string
            description: This one will be populated automatically at runtime
            x-runtime-expression: $supplierBeanName
          # CloudEvents Attributes:
          # these examples showcase how you can use SpEL expressions to populate runtime headers
          ce-id:
            type: string
            description: Unique identifier for the event
            x-runtime-expression: $message.payload#{#this.id}
          ce-source:
            type: string
            description: URI identifying the context where event happened
            x-runtime-expression: $message.payload#{"CustomersService"}
          ce-specversion:
            type: string
            description: CloudEvents specification version
            x-runtime-expression: $message.payload#{"1.0"}
          ce-type:
            type: string
            description: Event type
            x-runtime-expression: $message.payload#{#this.getClass().getSimpleName()}
          ce-time:
            type: string
            description: Timestamp of when the event happened
            x-runtime-expression: $message.payload#{T(java.time.Instant).now().toString()}

You can also override the runtimeHeadersProperty extension property name (in the rare case you need to):

<configOption>
    <runtimeHeadersProperty>x-custom-runtime-expression</runtimeHeadersProperty>
</configOption>

And provide a bean of type java.function.Supplier in your Spring context:

    @Bean("supplierBeanName")
    public Supplier supplierBeanName() {
        return () -> "some-value";
    }

InMemory Events Producer for Tests (Mocks)

Section titled “InMemory Events Producer for Tests (Mocks)”

ZenWave SDK generates an in-memory producer implementation for testing, allowing you to capture messages sent during a test without a real broker.

// autogenerated in: target/generated-sources/zenwave/src/test/java/.../InMemoryCustomerOrderEventsProducer.java
public class InMemoryCustomerOrderEventsProducer implements ICustomerOrderEventsProducer {

    protected Map<String, List<Message>> capturedMessages = new HashMap<>();
    public Map<String, List<Message>> getCapturedMessages() {
        return capturedMessages;
    }
    // other details omitted for brevity

    /**
     * CustomerOrder Domain Events
     */
    public boolean onCustomerOrderEvent(CustomerOrderEventPayload payload, CustomerOrderEventPayloadHeaders headers) {
        log.debug("Capturing message to topic: {}", onCustomerOrderEventBindingName);
        Message message = MessageBuilder.createMessage(payload, new MessageHeaders(headers));
        return appendCapturedMessage(onCustomerOrderEventBindingName, message);
    }
}
// autogenerated in: target/generated-sources/zenwave/src/test/java/.../ProducerInMemoryContext.java
public class ProducerInMemoryContext {

    public static final ProducerInMemoryContext INSTANCE = new ProducerInMemoryContext();

    private CustomerEventsProducerCaptor customerEventsProducerCaptor = new CustomerEventsProducerCaptor();

    public <T extends ICustomerEventsProducer> T customerEventsProducer() {
        return (T) customerEventsProducerCaptor;
    }
}
jbang zw -p io.zenwave360.sdk.plugins.AsyncAPIGeneratorPlugin --help