In Part 1 we let the spec write the code. Every consumer module ends with the same small job: implement one interface.
public class OrderPlacedDelegate implements OrderPlacedEventQueueConsumerDelegate {
@Override
public void processEvent(SqsMessageMetadata headers, OrderPlacedEvent event) {
// business logic only
}
}
I wrote dozens of these before I asked the obvious question: where does this interface actually come from? Nobody on the team wrote OrderPlacedEventQueueConsumerDelegate, yet it’s there after every build. So I opened the generator. All examples use the petstore contract that ships with the platform’s own examples. This post is what I found, plus a small playground at the end so you can watch it happen.
The platform behind platform.yaml
platform.yaml is read by a Maven plugin that runs during mvn generate-sources. The plugin is thin. It calls a CLI, and the CLI does the real work through two profiles:
petstore-events.yaml (AsyncAPI) ─┐
platform.yaml ───────────────────┤
▼
Maven plugin → platform CLI
│ │
① modelsProfile: java-event-models ② profile: java-sqs-consumer
│ │ (one per consumer binding)
AsyncAPI → OpenAPI shell mustache templates
→ openapi-generator (models) → delegate, consumer, Spring config
│ │
OrderPlacedEvent OrderPlacedEventQueueConsumerDelegate
(+ one class per nested object) OrderPlacedEventQueueConsumer
...ConsumerConfig, ...EnvironmentConfig
- ①
java-event-modelsmakes the payload classes. The interesting trick: openapi-generator doesn’t speak AsyncAPI, so the CLI copiescomponents.schemasinto a temporary OpenAPI document and lets openapi-generator build plain Java models from that. - ②
java-sqs-consumermakes everything around the payload: the delegate interface, the SQS consumer that calls it, and the Spring wiring. These come from mustache templates bundled inside the CLI jar.
Step by step
Take one consumer binding from platform.yaml:
event:
generation:
consumers:
- name: order-placed
channel: order.placed.v1
profile: java-sqs-consumer
1. Follow the references. The generator looks up the channel in the AsyncAPI file and walks down to a schema:
channels.order.placed.v1
→ messages.OrderPlaced ($ref: #/components/messages/OrderPlaced)
→ payload ($ref: #/components/schemas/OrderPlacedEvent)
→ schema OrderPlacedEvent ← its title (or its name) becomes the payload class
2. Build the models. Each properties entry becomes a field. orderId, petId, quantity and placedAt become String, Long, Integer and OffsetDateTime, following their type and format. A nested type: object doesn’t stay a Map: it becomes its own class, named by its path. A buyer object inside OrderPlacedEvent would become OrderPlacedEventBuyer, and an address inside that OrderPlacedEventBuyerAddress. Array items get an Inner suffix: items becomes List<OrderPlacedEventItemsInner>.
🔥 Those long names are why our consumers have imports like
OrderPlacedEventBuyerAddress. They’re not anyone’s naming choice, just the path from the root of the payload.
3. Fill the template. The delegate is a mustache template with a handful of placeholders:
public interface {{delegateClass}} {
void processEvent(SqsMessageMetadata headers, {{payloadClass}} event);
...
}
{{delegateClass}} is the binding name in PascalCase plus a fixed suffix (order-placed → OrderPlaced + EventQueueConsumerDelegate). Note it’s the binding name from platform.yaml, not the channel, so the .v1 never leaks into a class name. {{payloadClass}} is the schema from step 1. The package is the base package plus the binding name in lowercase without dashes, so ...event.consumers.orderplaced.
4. Wire it up. The generated OrderPlacedEventQueueConsumer reads the SQS message, deserialises the JSON into OrderPlacedEvent, runs validation and calls delegate.processEvent(headers, event). The generated Spring config finds your implementation and hands it to the consumer. Your class is the only hand-written part.
Try it yourself
Below is a toy version of steps 1 to 3, running in your browser. The top box is the consumer binding from platform.yaml; the bottom one is the platform’s example petstore-events.yaml. Edit either one and the Java underneath updates as you type.
A few things to try:
- Add a nested object under
OrderPlacedEvent.propertiesand watch a newOrderPlacedEventBuyerclass appear:buyer: type: object properties: name: type: string - Rename the binding to
name: pet-orderand see the delegate becomePetOrderEventQueueConsumerDelegatein packagepetorder. - Add a second binding
- name: pet-adoptedwithchannel: pet.adopted.v1to generate the other event too.
🔥 The playground never sends your YAML anywhere. It also refuses anything that isn’t a plain identifier: a field called
<script>or a title full of Java code is rejected, not “cleaned”. A real generator should do the same, because whatever goes into a name ends up in compiled code.
What the toy leaves out
The real generator does a lot more than this page:
- The consumer itself. JSON parsing, validation, dead-letter handling and the retry rules all live in the generated
...EventQueueConsumer. - Spring configuration, including queue URLs and thread counts under one property prefix per binding.
- Richer types. Enums,
requiredfields with bean validation, and moreformatmappings.
Wrapping up
The “magic” interface turned out to be three ordinary steps: follow $refs to a schema, turn the schema into classes named by their path, and fill a template. Knowing that makes the generated code much less mysterious. Long class names, lowercase packages and the processEvent signature all fall out of the YAML you wrote.
Next up: running the old Lambda and the new service side by side on the same events, without doing the work twice.
Reference: AsyncAPI 3.0, OpenAPI Generator, Mustache
The Contract First series
- Part 1. Contract First: Letting the Spec Write the Code
- Part 2. Where Does processEvent Come From? (you are here)
- Part 3. Dual-running Lambda and ECS with an Idempotency Service (coming soon…)