Skip to content

AxonOps — AI-Native Control Plane for Open Source Data Platforms

Event Collaboration Pattern

Event collaboration (also called choreography) enables services to coordinate through domain events without direct coupling. Services react to events independently, enabling loose coupling and autonomous evolution.


Request/ResponseEvent CollaborationOrderServiceInventoryServicePaymentServiceShippingServiceOrderServiceInventoryServicePaymentServiceShippingServiceKafkacheckInventory()processPayment()createShipment()OrderCreated
AspectRequest/ResponseEvent Collaboration
CouplingTight - caller knows calleesLoose - publisher doesn't know subscribers
AvailabilityDependent on all servicesServices operate independently
ScalabilityLimited by synchronous callsIndependent scaling
EvolutionChanges affect callersAdd subscribers without changes
DebuggingDirect call stackDistributed tracing required

Order ServiceInventory ServicePayment ServiceNotification ServiceAnalytics Serviceorder-eventsinventory-eventspayment-eventsOrderCreatedOrderCancelledreacts to OrderCreatedreacts to OrderCreatedreacts to allInventoryReservedInventoryReleasedupdates statustracks inventoryPaymentCompletedPaymentFailedupdates statustriggers notificationtracks payments
TypeDescriptionExample
Domain eventsBusiness-meaningful occurrencesOrderPlaced, PaymentReceived
Integration eventsCross-service communicationCustomerCreated, PriceChanged
Notification eventsTrigger notificationsOrderShipped, RefundProcessed

public interface DomainEvent {
String getEventId();
String getAggregateId();
Instant getOccurredAt();
}
public record OrderCreated(
String eventId,
String orderId,
String customerId,
List<OrderItem> items,
BigDecimal total,
Instant occurredAt
) implements DomainEvent {
@Override
public String getAggregateId() {
return orderId;
}
}
public record OrderCancelled(
String eventId,
String orderId,
String reason,
String cancelledBy,
Instant occurredAt
) implements DomainEvent {
@Override
public String getAggregateId() {
return orderId;
}
}
@Service
public class OrderService {
private final OrderRepository repository;
private final DomainEventPublisher eventPublisher;
@Transactional
public Order createOrder(CreateOrderRequest request) {
Order order = new Order(request);
order = repository.save(order);
// Publish domain event
eventPublisher.publish(new OrderCreated(
UUID.randomUUID().toString(),
order.getId(),
order.getCustomerId(),
order.getItems(),
order.getTotal(),
Instant.now()
));
return order;
}
}
@Component
public class KafkaDomainEventPublisher implements DomainEventPublisher {
private final KafkaTemplate<String, DomainEvent> kafka;
@Override
public void publish(DomainEvent event) {
String topic = topicFor(event);
String key = event.getAggregateId();
kafka.send(topic, key, event);
}
private String topicFor(DomainEvent event) {
// Route by event type
return switch (event) {
case OrderCreated e -> "order-events";
case OrderCancelled e -> "order-events";
case PaymentCompleted e -> "payment-events";
default -> "domain-events";
};
}
}
// Inventory Service - reacts to orders
@Component
public class OrderEventHandler {
private final InventoryService inventoryService;
@KafkaListener(topics = "order-events", groupId = "inventory-service")
public void onOrderEvent(DomainEvent event) {
switch (event) {
case OrderCreated created ->
inventoryService.reserveItems(created.orderId(), created.items());
case OrderCancelled cancelled ->
inventoryService.releaseReservation(cancelled.orderId());
default ->
log.debug("Ignoring event: {}", event.getClass().getSimpleName());
}
}
}
// Payment Service - reacts to orders
@Component
public class PaymentOrderHandler {
private final PaymentService paymentService;
@KafkaListener(topics = "order-events", groupId = "payment-service")
public void onOrderEvent(DomainEvent event) {
if (event instanceof OrderCreated created) {
paymentService.processPayment(
created.orderId(),
created.customerId(),
created.total()
);
}
}
}
// Analytics Service - observes all events
@Component
public class AnalyticsEventCollector {
private final AnalyticsRepository repository;
@KafkaListener(
topicPattern = ".*-events",
groupId = "analytics-service"
)
public void onAnyEvent(ConsumerRecord<String, DomainEvent> record) {
repository.store(new AnalyticsEvent(
record.topic(),
record.value().getClass().getSimpleName(),
record.value(),
Instant.now()
));
}
}

// Fine-grained events - more flexibility, more events
OrderLineItemAdded
OrderLineItemRemoved
OrderLineItemQuantityChanged
OrderShippingAddressSet
OrderBillingAddressSet
OrderPaymentMethodSet
// Coarse-grained events - simpler, less flexibility
OrderUpdated // Contains all changes
GranularityProsCons
Fine-grainedPrecise reactions, better auditingMore events to handle, ordering complexity
Coarse-grainedSimpler handling, fewer eventsLess precise reactions, may miss changes
// Notification style - minimal data, requires lookups
public record OrderCreated(
String orderId,
Instant occurredAt
) {}
// Event-carried state transfer - self-contained
public record OrderCreated(
String orderId,
String customerId,
String customerEmail, // Denormalized
String customerName, // Denormalized
List<OrderItem> items,
BigDecimal subtotal,
BigDecimal tax,
BigDecimal total,
Address shippingAddress,
Address billingAddress,
Instant occurredAt
) {}
StyleProsCons
NotificationSmall events, always current dataRequires service calls, coupling
State transferSelf-contained, no lookupsLarger events, potentially stale data

Recommendation: Prefer event-carried state transfer for loose coupling.


@Component
public class IdempotentInventoryHandler {
private final InventoryRepository inventoryRepo;
private final ProcessedEventRepository processedRepo;
@KafkaListener(topics = "order-events")
@Transactional
public void onOrderCreated(OrderCreated event) {
// Check idempotency
if (processedRepo.existsByEventId(event.eventId())) {
return;
}
// Process event
for (OrderItem item : event.items()) {
inventoryRepo.reserve(item.productId(), item.quantity(), event.orderId());
}
// Mark as processed
processedRepo.save(new ProcessedEvent(event.eventId(), Instant.now()));
}
}

Services publish their own events in response:

@Component
public class InventoryOrderHandler {
private final InventoryService inventoryService;
private final DomainEventPublisher eventPublisher;
@KafkaListener(topics = "order-events")
public void onOrderCreated(OrderCreated event) {
try {
inventoryService.reserveItems(event.orderId(), event.items());
// Publish success event
eventPublisher.publish(new InventoryReserved(
UUID.randomUUID().toString(),
event.orderId(),
event.items(),
Instant.now()
));
} catch (InsufficientInventoryException e) {
// Publish failure event
eventPublisher.publish(new InventoryReservationFailed(
UUID.randomUUID().toString(),
event.orderId(),
e.getProductId(),
e.getMessage(),
Instant.now()
));
}
}
}

Each service has its own consumer group:

order-events topic:
├── inventory-service group → reserves inventory
├── payment-service group → processes payment
├── notification-service group → sends confirmation
└── analytics-service group → tracks metrics
@Configuration
public class KafkaConsumerConfig {
@Bean
public ConsumerFactory<String, DomainEvent> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "inventory-service");
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
return new DefaultKafkaConsumerFactory<>(props);
}
}

@Component
public class OrderEventHandler {
private final KafkaTemplate<String, DomainEvent> kafka;
@KafkaListener(topics = "order-events")
public void onOrderEvent(ConsumerRecord<String, DomainEvent> record) {
try {
processEvent(record.value());
} catch (RetryableException e) {
throw e; // Let retry mechanism handle
} catch (Exception e) {
// Send to dead letter topic
kafka.send(
record.topic() + ".dlq",
record.key(),
record.value()
);
log.error("Sent to DLQ: {}", record.value(), e);
}
}
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, DomainEvent>
kafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, DomainEvent> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
// Retry configuration
factory.setCommonErrorHandler(new DefaultErrorHandler(
new DeadLetterPublishingRecoverer(kafkaTemplate),
new FixedBackOff(1000L, 3) // 3 retries, 1 second apart
));
return factory;
}

// Version 1
public record OrderCreatedV1(
String orderId,
String customerId,
BigDecimal total
) {}
// Version 2 - added fields
public record OrderCreatedV2(
String orderId,
String customerId,
BigDecimal total,
String currency, // New field
List<OrderItem> items // New field
) {}
@Component
public class VersionAwareOrderHandler {
@KafkaListener(topics = "order-events")
public void onOrderEvent(ConsumerRecord<String, JsonNode> record) {
JsonNode event = record.value();
int version = event.path("version").asInt(1);
switch (version) {
case 1 -> handleV1(parseV1(event));
case 2 -> handleV2(parseV2(event));
default -> log.warn("Unknown version: {}", version);
}
}
private void handleV1(OrderCreatedV1 event) {
// Handle with defaults for missing fields
handleOrder(event.orderId(), event.customerId(),
event.total(), "USD", List.of());
}
private void handleV2(OrderCreatedV2 event) {
handleOrder(event.orderId(), event.customerId(),
event.total(), event.currency(), event.items());
}
}

public record DomainEventHeaders {
String eventId;
String correlationId;
String causationId;
String source;
Instant timestamp;
}
@Component
public class CorrelatedEventPublisher {
public void publish(DomainEvent event, String correlationId, String causationId) {
ProducerRecord<String, DomainEvent> record = new ProducerRecord<>(
topicFor(event),
event.getAggregateId(),
event
);
record.headers()
.add("correlation-id", correlationId.getBytes())
.add("causation-id", causationId.getBytes())
.add("source", "order-service".getBytes());
kafka.send(record);
}
}
@Component
public class CorrelatedEventHandler {
private final DomainEventPublisher publisher;
@KafkaListener(topics = "order-events")
public void onOrderCreated(ConsumerRecord<String, OrderCreated> record) {
String correlationId = header(record, "correlation-id");
String causationId = record.value().eventId(); // This event caused next
// Process and publish reaction with correlation
InventoryReserved reaction = processAndReact(record.value());
publisher.publish(reaction, correlationId, causationId);
}
}
@Component
public class TracedEventHandler {
private final Tracer tracer;
@KafkaListener(topics = "order-events")
public void onOrderEvent(ConsumerRecord<String, DomainEvent> record) {
// Extract trace context from headers
SpanContext parentContext = tracer.extract(
Format.Builtin.TEXT_MAP,
new KafkaHeadersExtractor(record.headers())
);
// Create child span
Span span = tracer.buildSpan("process-order-event")
.asChildOf(parentContext)
.withTag("event.type", record.value().getClass().getSimpleName())
.withTag("event.id", record.value().getEventId())
.start();
try (Scope scope = tracer.scopeManager().activate(span)) {
processEvent(record.value());
} finally {
span.finish();
}
}
}

Avoid These Mistakes

Event chains as workflow : Long event chains are hard to understand and debug. Use saga orchestration for complex workflows.

Request/response over events : Don't use events for synchronous request/response. Use direct calls or dedicated request/reply topics.

Missing events : Consumers shouldn't assume all events arrive. Design for missing or out-of-order events.

Tight event coupling : Don't design events around consumer needs. Events should represent domain facts.

Ignoring idempotency : Events may be delivered multiple times. All handlers must be idempotent.


PracticeDescription
Past tense namingEvents describe what happened: OrderCreated, not CreateOrder
Self-containedInclude all data consumers need
VersionedInclude version for schema evolution
ImmutableEvents represent facts, never change them
PracticeDescription
Independent groupsEach service has its own consumer group
Idempotent handlingHandle duplicates gracefully
Selective consumptionOnly process relevant event types
Error isolationOne consumer failure shouldn't affect others
PracticeDescription
Correlation IDsTrack event chains across services
Dead letter topicsCapture failed events for analysis
MonitoringTrack consumer lag and processing times
Replay capabilityDesign for event replay scenarios