In a system whose services work together through events, the events are the public side of a service. An event contract writes down what a service publishes, what its events are called and what data they carry, before the first producer or consumer is built. This page shows a contract in AsyncAPI using a bike sharing example and explains what belongs in an event and what does not.
English version. The German README.de.md is the source of truth and is the text
embedded by the archi-lab.io infopage.
In a system whose services work together through events, the events are a service’s public interface. What a service publishes, what its events are called and which data they carry is what every consumer relies on. An event contract writes that down before the first producer or consumer is built. AsyncAPI is the format for it: AsyncAPI is to events what OpenAPI is to REST endpoints.
This module shows a contract on a small example and explains what belongs in an event and what does not.
Customers take a bike from a station and return it to any station. Two services are involved:
contracts/rental-service.asyncapi.yaml.contracts/billing-service.asyncapi.yaml.Each service has its own contract, describing the service from its own point of view: what it sends and what it receives.
A contract in AsyncAPI 3 has four parts.
info and servers: which service this is, in which version, and which broker carries its
events. Here it is a locally started Kafka.channels: where events flow. With Kafka, a channel’s address is the topic name. The rental
service writes all events of a rental to a topic rentals.operations: what the service does with a channel, send or receive. The rental service has two
send operations, the billing service one receive operation on the same channel.components: the messages themselves, with their headers, payload and Kafka key, and the schemas
they are made of.The message is the core. This is the payload of RentalEnded:
RentalEndedPayload:
type: object
required:
- rentalId
- bikeId
- customerId
- endStationId
- startedAt
- endedAt
properties:
rentalId:
type: string
format: uuid
bikeId:
type: string
customerId:
type: string
format: uuid
endStationId:
type: string
startedAt:
type: string
format: date-time
endedAt:
type: string
format: date-time
Whether a contract is any good is decided less by its syntax than by three questions.
Does the event report a fact, or is it a call in disguise? An event describes something that has
already happened, so it is named in the past tense: RentalEnded, not EndRental. An obvious mistake
would be an event ChargeCustomer that the rental service sends to billing. That is a command wearing
the shape of an event: the rental service would have to know that billing exists and what it should
do, and the two services would be as tightly coupled as with a synchronous call. With RentalEnded
the rental service only reports what happened. What follows from it, each consumer decides for
itself.
Does it carry the data the consumers need, and no more? Billing needs to know who rode and for how
long, so RentalEnded carries the customerId and both timestamps. The price is deliberately not in
it: billing owns the tariff, and when it changes, the rental service should not notice. The
customer’s name and address are missing too. Every field in a payload is a promise the producer
cannot take back without asking its consumers.
Is a consumer missing? A contract is checked against everyone who needs the event. If maintenance
were added later, counting how often a bike has been ridden, it could consume RentalEnded without the
rental service changing anything. That is the real gain of events: new consumers cost the producer
nothing, as long as the event already carries what they need.
The billing service does not define RentalEnded again. Its contract references the message in the
rental service’s contract:
messages:
RentalEnded:
$ref: './rental-service.asyncapi.yaml#/components/messages/RentalEnded'
So every event has exactly one definition, and it lives with the service that owns the event. Put the contracts of all services side by side, and they become the system’s event catalogue.
A contract is only a foundation once it is formally valid. Two ways:
On the command line, with Node.js:
npx @asyncapi/cli validate contracts/rental-service.asyncapi.yaml
The contracts in this module have been checked with the AsyncAPI CLI and are valid.
Both events of a rental go to the same topic rentals, with the rentalId as the Kafka key. Kafka
guarantees order only within a partition, and all events with the same key land in the same
partition. So every consumer sees RentalStarted before RentalEnded of the same rental. With one
topic per event type that guarantee would be lost: a consumer could see a rental end before it knows
the rental began.
The price: a consumer that only needs RentalEnded reads RentalStarted too and ignores it. That is
usually cheap. Rule of thumb: what has to arrive in order for the same thing belongs in one topic,
keyed by that thing’s id.
Business data goes in the payload, technical data in the headers. Which headers a message carries is
fixed here by the Kafka helper library of the Microservice Dungeon, and the contract describes what it
actually sends: among others an event_id, unique per event, and event_type and
event_type_version, from which a consumer recognises which message it has. Kafka can deliver an event
more than once; the event_id lets a consumer recognise that it has already processed it.
When something happened in the domain belongs in the payload (startedAt, endedAt). The header
created_at only says when the event was written. The module service-development-3-with-infrastructure
shows how the library uses the headers.