For the past few months I’ve been moving a set of AWS Lambdas into a single Java service running on ECS. Each Lambda does a small job: listen to an event, call one or two internal APIs, publish a status. Small jobs, but every port started the same way: write a POJO for the incoming event, write DTOs for each API we call, write the HTTP call, write a mapper, and hope it all matches what really goes over the wire.
It usually didn’t, at least not for long. Payloads grew a field, an API renamed a parameter, and our hand-written copies quietly drifted. So we flipped the order: write the contract first and let the code come from it. This post is about how that works in practice.
One folder of truth: docs/
Every contract the service depends on lives in one flat folder:
docs/
├── asyncapi/ # what we receive
│ ├── petstore-events.yaml
│ ├── payment-events.yaml
│ └── ...
└── openapi/ # what we call
├── petstore-inventory-openapi-spec.yaml
├── petstore-notifications-openapi-spec.yaml
├── idempotency-openapi-spec.yaml
└── ...
The rule is simple: AsyncAPI for events coming in, OpenAPI for APIs going out. File names follow *-events.yaml and *-openapi-spec.yaml, so you can tell from the name what a file is and which side of the service it sits on.
An AsyncAPI file describes one consumer module: one channel per SQS queue, and a receive operation for each.
asyncapi: 3.0.0
info:
title: Petstore Events
version: 1.0.0
channels:
order.placed.v1:
address: order.placed.v1
messages:
OrderPlaced:
$ref: "#/components/messages/OrderPlaced"
operations:
onOrderPlaced:
action: receive
channel:
$ref: "#/channels/order.placed.v1"
A nice side effect: the spec is now the documentation. When someone asks “what does the order-placed event look like?”, the answer is a link, not a Slack thread.
Wiring it up in platform.yaml
The contracts are only half the story. Each module has a platform.yaml that tells our in-house platform plugin (a Maven plugin wrapping openapi-generator) which contracts to read and what to generate from them.
platformVersion: "2"
service:
name: petstore-orders
registries:
events: { type: local, url: ../../docs, settings: { layout: flat } }
docs: { type: local, url: ../../docs, settings: { layout: flat } }
namespace:
basePackage: com.example.petstore.generated
event:
artifact:
registry: events
artifactId: petstore-events
version: 1.0.0
generation:
generatorCommand: openapi-generator
modelsProfile: java-event-models
consumers:
- name: order-placed
channel: order.placed.v1
profile: java-sqs-consumer
validation:
enabled: true
beanValidation: false
client:
- name: notifications
profile: java-client
artifact:
registry: docs
artifactId: petstore-notifications-openapi-spec
version: 1.0.0
Three blocks do all the work:
registriespoint atdocs/.type: localwithlayout: flatmeans “read the file straight from the repo”, so there’s no artifact server to publish to first.event.generation.consumersmaps each AsyncAPI channel to an SQS consumer.clientturns an OpenAPI spec into a typed client undergenerated.client.<name>.
What the generator gives you
Run mvn generate-sources and you get two things.
For each event: typed models plus a delegate interface. The generated SQS consumer handles polling, deserialisation and validation, and our code only implements one method:
public class OrderPlacedDelegate implements OrderPlacedEventQueueConsumerDelegate {
@Override
public void processEvent(final SqsMessageMetadata headers, final OrderPlacedEvent event) {
String orderId = event.getOrderId();
Long petId = event.getPetId();
// ... business logic only
}
}
For each API: api, model and ApiClient packages. Our “client” class is now a thin wrapper that only holds the parts the spec can’t express, such as which status codes count as success (here, 202 Accepted):
NotificationRequest body = new NotificationRequest()
.recipient(email)
.channel(NotificationRequest.ChannelEnum.EMAIL)
.message("Your order " + orderId + " is placed");
notificationsApi.sendNotificationWithHttpInfo(body).block();
No hand-written DTO, no URL string building, no ObjectMapper boilerplate. If the spec changes, the code stops compiling, and that’s exactly what we want.
Sharing clients across consumers
Some APIs are used by almost every consumer: inventory, the idempotency service, the user directory. Generating them in every module would mean duplicate classes everywhere, so they live in one shared library/clients module with its own platform.yaml:
namespace:
basePackage: com.example.petstore.clients.generated
client:
- name: inventory
profile: java-client
artifact: { registry: docs, artifactId: petstore-inventory-openapi-spec, version: 1.0.0 }
- name: idempotency
profile: java-client
artifact: { registry: docs, artifactId: idempotency-openapi-spec, version: 1.0.0 }
- name: users
profile: java-client
artifact: { registry: docs, artifactId: petstore-users-openapi-spec, version: 1.0.0 }
additionalProperties:
openApiNullable: "true"
additionalModelTypeAnnotations: "@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)"
Consumers depend on the library and get the same typed client.
🔥 Generated models are strict by default. If the upstream team adds a field before you update the spec, deserialisation fails.
@JsonIgnoreProperties(ignoreUnknown = true)throughadditionalModelTypeAnnotationskeeps the client lenient, andopenApiNullablelets you tell “field absent” from “field set to null”.
Gotchas we hit
Contract-first isn’t free. A few things that cost us time:
- Nested
$refs in AsyncAPI. Our event-model profile didn’t resolve schema$refs, so nested shapes had to be inlined per consumer. That’s a bit repetitive, but it’s still one YAML file instead of a dozen Java classes. requiredis a promise you might not keep. Turning on bean validation made the generated consumer reject events the old Lambda happily accepted, because real producers didn’t always send every field the spec calledrequired. During a migration, parity matters more than strictness, so we setbeanValidation: falseand validate the fields we really need in code.- Generator upgrades are real upgrades. One platform bump made every generated package name lowercase and added a new prefix to all generated property keys, so our env vars and infrastructure had to change in the same release. A later bump generated code that imported a class from a newer runtime, while our shared BOM still pinned the old one. The fix was a single version line, but it took a compile error to find it.
- A hand-written spec is still hand-written. For services that didn’t publish their own OpenAPI, we wrote the spec from how the Lambda called them. Generated code matching your spec doesn’t prove the spec matches the real API, so check paths, status codes and field names against the service.
Wrapping up
Moving to contracts changed where the work happens. We spend our time on the YAML: arguing about field names, nullability and status codes, which is where those arguments belong. The Java that’s left is the business logic: no event POJOs, no DTOs, no HTTP plumbing. Every consumer module looks the same, so the next migration is mostly “write the AsyncAPI, list it in platform.yaml, implement the delegate”.
Next up: opening the generator to see where processEvent actually comes from, with a playground you can type into.
Reference: OpenAPI Specification, AsyncAPI 3.0, OpenAPI Generator
The Contract First series
- Part 1. Contract First: Letting the Spec Write the Code (you are here)
- Part 2. Where Does processEvent Come From?
- Part 3. Dual-running Lambda and ECS with an Idempotency Service (coming soon…)