Beyond Pact: Scaling Consumer-Driven Contract Testing in Large Organizations
Scaling contract testing across dozens of microservices. Broker workflows, CI integration, contract versioning, and sustainable testing practices.

Event sourcing stores every state change as an immutable event. Instead of updating a row in place, you append an event to a log. The current state is derived by replaying events. Kafka, with its durable append-only log, is a natural fit for this pattern. But the gap between the concept and a production-ready implementation is wider than most tutorials suggest.

Traditional CRUD systems store current state. Event sourcing stores the history of how you got there. This gives you:
The tradeoffs are real: increased complexity, eventual consistency for read models, and operational overhead. I've worked on systems where event sourcing was the right call (a financial ledger where every state change needed to be auditable) and systems where it was premature complexity (a simple inventory tracker that would have been better served by a CRUD API with an audit log table).
Kafka topics act as the event log. Each aggregate (e.g., an Order, an Account) gets its own partition key to ensure ordered delivery of events for that aggregate.
ProducerRecord<String, OrderEvent> record = new ProducerRecord<>(
"order-events",
order.getId(),
new OrderCreatedEvent(order.getId(), order.getItems(), order.getTotal())
);
producer.send(record).get();log.retention.ms = -1 keeps all events.The quality of your events determines how maintainable the system is long-term. Some principles:
OrderCreated, PaymentProcessed, InventoryReserved. Events represent facts that have already happened.OrderCreated only contains an order ID, every consumer needs to call back to the order service for details. Include the relevant fields.UpdateInventory is a command, not an event. InventoryReserved is an event. The distinction matters because events are facts (immutable, undeniable), while commands are requests (can be rejected).eventVersion field from day one. When you need to add fields or change the schema, bump the version rather than modifying the existing structure.public record OrderCreatedEvent(
String eventId,
String eventVersion,
Instant occurredAt,
String orderId,
String customerId,
List<OrderItem> items,
BigDecimal total,
String currency
) implements OrderEvent {}Events are the source of truth, but you don't query them directly. Projections consume the event stream and build read-optimized views:
@KafkaListener(topics = "order-events", groupId = "order-projection")
public void handle(OrderEvent event) {
switch (event) {
case OrderCreatedEvent e -> orderView.insert(e.orderId(), e.items(), e.total(), "CREATED");
case OrderPaidEvent e -> orderView.updateStatus(e.orderId(), "PAID");
case OrderShippedEvent e -> orderView.updateStatus(e.orderId(), "SHIPPED");
case OrderCancelledEvent e -> orderView.updateStatus(e.orderId(), "CANCELLED");
}
}Projections are disposable: if the read model is corrupted or the schema changes, reset the consumer group offset and replay from the beginning.
Kafka guarantees at-least-once delivery by default, so your projection consumers will process some events more than once. Every projection must handle duplicates gracefully (see Designing for Idempotency for a detailed treatment of deduplication strategies):
INSERT ... ON CONFLICT DO NOTHING or conditional updatesA projection that increments a counter on every event will produce incorrect results when events are replayed or redelivered. A projection that sets the counter to a value derived from the event's content will produce the correct result regardless of how many times the event is processed.
There's always a delay between an event being written and the read model being updated. This is eventual consistency in action. For most use cases, the lag is measured in milliseconds to low seconds. But it creates a UX challenge: a user creates an order, then immediately views their order list, and the new order isn't there yet.
Common mitigations:
As the event count grows, replaying all events to reconstruct state becomes expensive. Snapshots periodically capture the aggregate's current state:
Snapshots are an optimization, not a replacement. The event log remains the source of truth. If a snapshot is corrupted, you can always delete it and rebuild from events. This is also useful during schema migrations: generate new snapshots from the event log after deploying new event-handling code.
Events are immutable, but your schema will change. Options:
In practice, I recommend Avro with a schema registry for any system beyond a prototype. It catches breaking changes before they reach production and makes cross-team event contracts explicit.
Kafka log compaction keeps only the latest value per key, which conflicts with event sourcing (you need the full history). Use retention-based topics, not compacted topics, for event stores.
Kafka supports exactly-once semantics with idempotent producers and transactional writes. For event sourcing, this means:
enable.idempotence=true on the producerread_committed isolation levelWhen something goes wrong, the event log is your best debugging tool. You can reconstruct exactly what happened by replaying events for a specific aggregate:
kafka-console-consumer --bootstrap-server localhost:9092 \
--topic order-events \
--property print.key=true \
--property key.separator="|" \
| grep "ord-789"This gives you a complete timeline of every state change for that order. No guessing, no "I think someone updated this record manually." The event log is an authoritative record.
Build tooling that makes this easy. A simple CLI or admin UI that takes an aggregate ID and shows the full event history (with pretty-printed payloads and timestamps) will save hours of debugging time.
Scaling contract testing across dozens of microservices. Broker workflows, CI integration, contract versioning, and sustainable testing practices.

How Pact contract testing enables consumer-driven contracts for microservices. Real-world examples, failure modes, and CI/CD best practices.

How the SNS + SQS fanout pattern enables scalable event-driven systems on AWS. Real-world use cases, best practices, and implementation guide.
