DEV Community

Building Reliable Event-Driven Systems: Event Schemas, Versioning, Contract Testing and Events vs Commands (part-2)

Designing Event Schemas That Age Well

Once a team adopts event-driven architecture, the event schema quickly becomes one of the most critical design artifacts in the system. Unlike an internal Java class that is used within a single codebase, an event schema is consumed by multiple independent services. These services may be developed, deployed, and maintained by different teams, often evolving at different speeds.

As a result, every field included in an event effectively becomes part of a contract that consumers may rely on. Changing that contract later is rarely as simple as modifying a Java object. While internal models can evolve freely, event schemas must remain stable over time. Good schemas are designed to evolve gracefully, allowing systems to grow without breaking existing consumers. Poorly designed schemas, on the other hand, tend to accumulate compatibility issues that become increasingly difficult to manage.

At the core of this challenge is a simple but powerful question: Is the event describing a business fact or exposing the producer's implementation?

Events Should Represent Business Concepts

Consider an Order Service that has just confirmed an order. One way to represent this event is by focusing on the business outcome:

{
  "orderId": "ORD-1001",
  "customerId": "CUS-501",
  "status": "CONFIRMED",
  "totalAmount": 249.99
}

Another approach might expose internal structures:

{
  "id": "ORD-1001",
  "customerEntity": { ... },
  "orderAggregate": { ... },
  "hibernateVersion": 12
}

Although both events may contain similar information, only the first represents a stable business contract. The second leaks internal implementation details that consumers should not depend on. Over time, such exposure creates tight coupling and makes evolution difficult.

A useful guideline is: Design events for consumers, not for producers. The producer already understands its internal model. Consumers only need clear, meaningful business information.

Don't Serialize Your Domain Model

A common mistake is publishing JPA entities directly as events. For example:

eventPublisher.publish(orderEntity);

This approach may seem convenient because it avoids creating additional classes. However, it tightly couples consumers to the producer's internal structure. Any change in the domain model can unintentionally break consumers.

Consider an initial model:

public class Order {
    private Customer customer;
}

Later, the model evolves:

public class Order {
    private CustomerAccount customerAccount;
}

From a business perspective, nothing has changed. However, the event payload has changed, potentially breaking consumers. This happens because the event contract was tied directly to the internal model.

A better approach is to define dedicated event models:

public record OrderConfirmedEvent(
    String orderId,
    String customerId,
    BigDecimal totalAmount
) {}

This separation ensures that the event contract remains stable even as the internal model evolves. Both can change independently without affecting each other.

Designing Event Payloads

Every event answers a specific business question. The schema should provide enough information for consumers to understand that answer clearly, without exposing unnecessary implementation details. Striking this balance is one of the most important design decisions in event-driven systems.

Include Business Information

Consider a minimal event:

{
  "orderId": "ORD-1001"
}

While technically valid, it is not very useful. Consumers will likely need additional information, forcing them to make extra API calls:

OrderConfirmed Event
        |
        v
  Inventory Service
        |
        v
  GET /orders/ORD-1001

If multiple consumers follow this pattern, a single event can trigger multiple network requests. This increases system load and introduces unnecessary coupling between services.

Avoid Including Everything

At the other extreme, some events include too much information:

{
  "order": { ... },
  "customer": { ... },
  "payment": { ... },
  "inventory": { ... },
  "shipment": { ... }
}

Large payloads can lead to higher network usage, increased serialization costs, and tighter coupling between services. They also make schema evolution more difficult, as changes in one part of the payload may affect multiple consumers.

Design Around Business Needs

A practical approach is to ask: What information should every consumer reasonably expect to receive? For an OrderConfirmed event, a balanced payload might look like this:

{
  "orderId": "ORD-1001",
  "customerId": "CUS-501",
  "orderDate": "2026-07-01T10:15:30Z",
  "currency": "USD",
  "totalAmount": 249.99
}

This provides essential business context without overwhelming consumers. Those who need additional details can fetch them independently, while others remain unaffected.

Event Metadata Matters

While developers often focus on the payload, metadata plays an equally important role in production systems. Metadata provides critical context that helps systems understand how to process and trace events. It enables systems to determine when an event occurred, where it originated, how it should be tracked, and whether it has already been processed. Without this information, operating and debugging event-driven systems becomes significantly more challenging.

Business Data vs Technical Metadata

Business data should reside in the payload, while technical details belong in metadata. For example:

{
  "eventId": "8c1e6d12",
  "eventType": "OrderConfirmed",
  "eventVersion": "1.0",
  "occurredAt": "2026-07-01T10:15:30Z",
  "correlationId": "REQ-98451",
  "payload": {
    "orderId": "ORD-1001",
    "customerId": "CUS-501",
    "totalAmount": 249.99
  }
}

This separation improves clarity and makes it easier to evolve both business data and technical metadata independently.

Event Identifier

Every event should include a unique identifier: eventId. This identifier is essential for de-duplication, ensuring idempotent processing, enabling tracing, and supporting auditing. It also simplifies handling scenarios where events may be delivered multiple times.

Correlation Identifier

In distributed systems, workflows often span multiple services:

Create Order
    |
Reserve Inventory
    |
Process Payment
    |
Create Shipment

Each step may produce additional events. A correlation identifier links these events together:

Correlation ID: REQ-98451

This makes it much easier to trace workflows and debug issues in production environments.

Event Timestamp

Events should record when the business action occurred, not when the event was received. These timestamps can differ due to network delays, retries, or temporary failures. Keeping business time separate from delivery time ensures accurate interpretation of events.

Naming Events Consistently

Naming may seem like a minor detail, but it becomes increasingly important as systems grow. Large organizations may produce hundreds of event types, and consistency helps maintain clarity and usability across teams.

Prefer Past-Tense Business Events

Effective event names describe completed business actions:

  • CustomerRegistered
  • OrderConfirmed
  • InventoryReserved
  • PaymentCompleted
  • ShipmentCreated

These names clearly communicate what has happened, making them easy for consumers to understand.

Avoid CRUD-Oriented Events

Generic names such as:

  • OrderUpdated
  • CustomerModified
  • ProductChanged

lack clarity. They do not explain what changed or why, forcing consumers to inspect the payload for meaning. Event names should convey intent directly.

Keep Names Business-Oriented

Avoid technical or implementation-focused names:

  • DatabaseUpdated
  • RowInserted
  • EntitySaved
  • JpaEntityUpdated

These describe internal processes rather than business outcomes. Consumers care about what happened in the business domain, not how it was implemented.

Common Schema Design Mistakes

Despite differences in technology, many event-driven systems encounter similar design issues. Recognizing these common mistakes can help teams avoid long-term problems.

Publishing Internal Objects

Internal models change frequently, while public contracts should remain stable. Mixing the two leads to fragile systems. Keeping them separate ensures better maintainability.

Making Events Too Generic

An event like OrderUpdated can represent many different actions, making it difficult for consumers to interpret. More specific events provide clearer intent:

  • OrderConfirmed
  • OrderCancelled
  • OrderRefunded

This clarity simplifies consumer logic and improves overall system understanding.

Missing Metadata

Without essential metadata such as event identifiers, timestamps, and correlation IDs, troubleshooting becomes significantly harder. Operational concerns should be considered from the beginning of event design.

Designing for Today's Consumers

Many producers design events based only on current consumers, overlooking future needs. A better approach is: Design events as though the next consumer has not been written yet. This mindset encourages more flexible and future-proof designs.

Practical Rule of Thumb

Well-designed schemas remain clear and understandable long after they are introduced. They focus on business facts rather than implementation details, separate metadata from payload data, and provide sufficient context without unnecessary complexity.

In the next part, we will explore schema evolution, event versioning, backward compatibility, and strategies that allow producers and consumers to evolve independently without disrupting production systems.

Comments

No comments yet. Start the discussion.