How Event Storming workshop output maps to Kafka topic design — topic naming, partitioning strategy, schema decisions, and consumer group boundaries in Java.
Event Storming is a collaborative modelling technique that produces a shared understanding of a domain expressed as domain events. What it doesn’t tell you is how to turn that output into a Kafka topic structure. That translation — from sticky notes to partitioned, replicated event streams — involves a set of decisions that significantly affect both correctness and performance.
This post covers the full path from Event Storming output to a working Kafka topic design in Java.
A typical Event Storming session produces:
OrderPlaced, PaymentProcessed, MarketSuspendedPlaceOrder, CancelBet, SubmitPaymentOrder, BettingMarket, AccountWhen MarketSuspended, cancel all pending ordersEach of these maps to Kafka topology decisions.
The key mapping: one Kafka topic per event type within a bounded context, not one topic per bounded context.
A common mistake is creating one broad topic per bounded context (market-events, order-events) and mixing all event types inside it. Consumers then deserialise every message to filter the type they care about — wasteful and brittle. Each event type should be its own topic:
betfair.markets.v1.market-opened
betfair.markets.v1.market-suspended
betfair.markets.v1.market-settled
betfair.orders.v1.order-placed
betfair.orders.v1.order-matched
betfair.orders.v1.order-cancelled
betfair.accounts.v1.balance-updated
Topic naming convention: {org}.{bounded-context}.{version}.{event-type-kebab}
The v1 segment is critical. When your schema changes incompatibly, you create v2 topics and migrate consumers — you never silently break existing consumers by changing the schema in-place.
The Event Storming aggregate maps directly to the Kafka partition key. Events for the same aggregate must land on the same partition to guarantee ordering.
// Order aggregate → partition by orderId
producer.send(new ProducerRecord<>(
"betfair.orders.v1.order-placed",
order.id(), // key = partition key
orderPlacedEvent // value
));
// Market aggregate → partition by marketId
producer.send(new ProducerRecord<>(
"betfair.markets.v1.market-suspended",
market.id(), // key = marketId
marketSuspendedEvent
));
This ensures that OrderPlaced, OrderMatched, and OrderCancelled events for the same order always arrive at the same consumer in the same order they were produced.
Partition count: For a market data service handling thousands of markets, set partition count to something that allows parallelism. A rule of thumb: partition_count = target_throughput / throughput_per_consumer_thread. Start with 12–24 partitions for active domains and scale up (you can only increase, never decrease).
Each domain event identified in the Event Storming session becomes an Avro (or JSON Schema) schema. Define it close to the bounded context that owns it:
// Using Avro schema with Confluent Schema Registry
@Configuration
public class KafkaConfig {
@Bean
public ProducerFactory<String, SpecificRecord> producerFactory(
@Value("${kafka.bootstrap-servers}") String bootstrapServers,
@Value("${schema.registry.url}") String schemaRegistryUrl) {
return new DefaultKafkaProducerFactory<>(Map.of(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers,
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class,
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, KafkaAvroSerializer.class,
"schema.registry.url", schemaRegistryUrl,
ProducerConfig.ACKS_CONFIG, "all",
ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true
));
}
}
For the OrderPlaced event:
{
"type": "record",
"name": "OrderPlaced",
"namespace": "uk.co.trinitylogic.orders.v1",
"fields": [
{ "name": "orderId", "type": "string" },
{ "name": "marketId", "type": "string" },
{ "name": "selectionId","type": "long" },
{ "name": "side", "type": { "type": "enum", "name": "Side", "symbols": ["BACK", "LAY"] } },
{ "name": "price", "type": "double" },
{ "name": "stake", "type": "double" },
{ "name": "placedAt", "type": { "type": "long", "logicalType": "timestamp-millis" } },
{ "name": "correlationId", "type": ["null", "string"], "default": null }
]
}
The correlationId field — always include it. It’s how you trace a command through multiple events and services without coupling them at the schema level.
The purple “policy” sticky notes from Event Storming — When X, do Y — define where consumer groups should sit. Each policy is a consumer group with a single responsibility:
// Policy: "When MarketSuspended, cancel all pending orders"
@Component
public class MarketSuspensionPolicy {
private final OrderCancellationService cancellationService;
@KafkaListener(
topics = "betfair.markets.v1.market-suspended",
groupId = "order-cancellation-policy",
containerFactory = "kafkaListenerContainerFactory"
)
public void onMarketSuspended(MarketSuspended event) {
log.info("Market {} suspended — cancelling pending orders", event.getMarketId());
cancellationService.cancelPendingOrdersForMarket(event.getMarketId());
}
}
// Policy: "When MarketSuspended, update risk exposure"
@Component
public class RiskExposurePolicy {
private final RiskCalculationService riskService;
@KafkaListener(
topics = "betfair.markets.v1.market-suspended",
groupId = "risk-exposure-policy",
containerFactory = "kafkaListenerContainerFactory"
)
public void onMarketSuspended(MarketSuspended event) {
riskService.recalculateExposure(event.getMarketId());
}
}
Two consumer groups, same topic. Both receive every MarketSuspended event independently. They process at their own pace and neither blocks the other.
One consumer group per policy — never combine unrelated policies into one consumer. The Event Storming output makes the boundaries obvious: if two policies react to different events or serve different bounded contexts, they’re separate groups.
Event Storming often reveals chains: When A, do B, which produces C, which triggers D. This maps to a pipeline of consumers:
// Step 1: MarketSuspended → cancel orders → produce OrderCancelled
@KafkaListener(topics = "betfair.markets.v1.market-suspended", groupId = "order-cancellation")
public void handle(MarketSuspended event) {
var cancelled = orderService.cancelAll(event.getMarketId());
cancelled.forEach(order -> kafkaTemplate.send(
"betfair.orders.v1.order-cancelled",
order.id(),
buildOrderCancelledEvent(order, event.getMarketId())
));
}
// Step 2: OrderCancelled → update P&L
@KafkaListener(topics = "betfair.orders.v1.order-cancelled", groupId = "pnl-updater")
public void handle(OrderCancelled event) {
pnlService.reverseOrder(event.getOrderId());
}
Each step in the chain is a separate consumer group. The events flow through Kafka rather than through direct service calls — any step can be independently scaled, replayed, or replaced.
For every consumer group that processes commands or triggers side effects, add a dead letter topic:
@Bean
public ConcurrentKafkaListenerContainerFactory<String, SpecificRecord> kafkaListenerContainerFactory(
ConsumerFactory<String, SpecificRecord> consumerFactory,
KafkaTemplate<String, SpecificRecord> kafkaTemplate) {
var factory = new ConcurrentKafkaListenerContainerFactory<String, SpecificRecord>();
factory.setConsumerFactory(consumerFactory);
var recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate,
(record, ex) -> new TopicPartition(record.topic() + ".dlq", record.partition()));
factory.setCommonErrorHandler(
new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 3))
);
return factory;
}
The DLQ topic name convention: {original-topic}.dlq. After 3 retries with 1-second backoff, the message lands in the DLQ for inspection and manual replay.
One underused output of Event Storming is a living topology diagram. After mapping aggregates to partition keys and policies to consumer groups, generate a Kafka Streams topology view:
@Component
public class TopologyExporter {
@EventListener(ApplicationReadyEvent.class)
public void exportTopology() {
var topology = buildTopology();
log.info("Kafka topology:\n{}", topology.describe());
}
}
Keep the Event Storming diagram and the actual topic/consumer group list in sync. When a new event is added to the sticky-note model, it should immediately have a corresponding topic in the schema registry. The two artefacts should never drift — if they do, the Event Storming session wasn’t the source of truth, and the next session will start from stale assumptions.
If you’re translating Event Storming output into a Kafka-based event-driven architecture in Java, discuss your project.