Available Hire Me
← All Writing Architecture

Event Storming — From Domain Discovery to Kafka Topic Design

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.

What Event Storming produces

A typical Event Storming session produces:

  • Domain events (orange): things that happened — OrderPlaced, PaymentProcessed, MarketSuspended
  • Commands (blue): user intentions — PlaceOrder, CancelBet, SubmitPayment
  • Aggregates (yellow): the entities commands act on and events emanate from — Order, BettingMarket, Account
  • Bounded contexts: natural groupings of aggregates that change together and have a shared language
  • Policy/reaction (purple): when X happens, do Y — When MarketSuspended, cancel all pending orders

Each of these maps to Kafka topology decisions.

From bounded contexts to topics

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.

Partitioning strategy from aggregates

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).

Schema design from domain events

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.

Consumer group boundaries from policy/reaction events

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.

Handling the reaction chain

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.

Dead letter topics

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.

Topology documentation from Event Storming

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.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 25+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.