Skip to content
On this page

Seamless Events Version Management

One of the biggest pain points in event-driven systems comes when you need to make breaking changes to your event structure. I've seen teams struggle with this countless times: a simple field rename or restructuring forces a complex, coordinated deployment across multiple services, sometimes requiring temporary downtime or complicated migration strategies.

In some systems I've worked with, when you change an event structure, you face a chicken-and-egg problem. Consumers need to understand the new format before producers can start sending it, but producers can't stop sending the old format until all consumers are updated. This creates a deployment nightmare, especially in distributed systems where different teams own different services.

The Pain of Breaking Changes

Consider a scenario: your user registration event initially had a simple name field, but business requirements now demand separate firstName and lastName fields for better personalization. Additionally, for privacy reasons, you need to stop storing the email field (we have a better place for that: Firebase, Auth0 etc).

From my experience, this scenario typically requires:

  1. Coordinated deployment: All consumers must be updated before the producer changes
  2. Complex versioning logic: Producers need conditional logic to send different versions
  3. Temporary compatibility layers: Extra code to handle both old and new formats
  4. Risk of service disruption: Any mistake in the coordination can break the system

Opening the Door to Seamless Evolution

My framework takes a different approach - it keeps the door open for evolution from day one. Instead of treating event versions as mutually exclusive, the system assumes that a single model event can be mapped and published to multiple DTO event versions simultaneously.

Here's the key insight: each event version has the same core metadata (eventId, modelId, version, timestamp) but different topic names that include the DTO version (.v1, .v2, etc.). The framework automatically detects all available mappers and publishes to every configured version.

Step-by-Step Migration Process

Let's walk through our example transformation using the code provided:

1. Create the New Event Structure

First, we define the new data structure with separated name fields:

kotlin
data class PersonalDataV2(
    val firstName: String,
    val lastName: String,
)

data class UserCreatedV2(
    val personalData: PersonalDataV2,
    val status: StatusV1
) : EventDtoBody
1
2
3
4
5
6
7
8
9

2. Define the New Topic Version

Create a new topic enum that follows the same pattern but with version 2:

kotlin
enum class UsersEventTopicV2(
    override val eventClass: KClass<out EventDtoBody>,
    override val action: EventAction,
    override val model: String = "user",
    override val version: Int = 2, // Increment the version
) : EventTopic {
    CREATED(UserCreatedV2::class, EventAction.created()),
}
1
2
3
4
5
6
7
8

This generates the topic user.model.created.v2 alongside the existing user.model.created.v1.

3. Implement the New Mapper

The framework automatically discovers mappers through Spring's component scanning:

kotlin
@Component
class UserCreatedMapperV2 : EventMapper<UserCreated, UserCreatedV2>(
    modelClass = UserCreated::class,
    topic = UsersEventTopicV2.CREATED
) {

    override fun UserCreated.mapToDto(): UserCreatedV2 {
        val nameParts: List<String> = personalData.name.split(" ")
        return UserCreatedV2(
            personalData = PersonalDataV2(
                firstName = nameParts.firstOrNull().orEmpty(),
                lastName = nameParts.lastOrNull().orEmpty(),
            ),
            status = StatusV1(
                name = status.name.name,
                message = status.message
            )
        )
    }

    override fun UserCreatedV2.mapToModel() = UserCreated(
        personalData = PersonalData(
            name = "${personalData.firstName} ${personalData.lastName}",
            email = "",
        ),
        status = Status(
            name = UserStatus.valueOf(status.name),
            message = status.message
        )
    )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

Notice how the mapping handles the transformation:

  • Splits the existing name field into firstName and lastName
  • Removes the email field from the V2 event
  • Provides reverse mapping for transforming DTO to the event model

4. Update Consumers at Your Own Pace

On the consumer side, teams can migrate independently:

kotlin
@EventConsumer
class UserEventsConsumerV2 {

    fun onCreated(event: EventV1<UserCreatedV2>) {
       // Handle me
    }
}
1
2
3
4
5
6
7

The consumer simply needs to:

  • Create a new consumer class
  • Subscribe to the V2 topic
  • Handle the new event structure

5. The Magic Happens Automatically

Once you deploy the producer service with both mappers, the system automatically:

  • Detects both V1 and V2 mappers for the UserCreated model
  • Maps each event to both DTO versions
  • Stores both versions in the event store
  • Publishes to both topics (user.model.created.v1 and user.model.created.v2)

Event Storage: Both Versions Coexist

Here's how the events look in MongoDB - notice they share the same metadata but have different topic names and body structures:

json
// V1 Event
{
  "_id": "1cc64bb3-17f0-3619-a68d-a2564bf1644d",
  "topic": "user.model.created.v1",
  "event": {
    "body": {
      "personalData": {
        "name": "John Smith",
        "email": "example@vibetdd.dev"
      },
      "status": {
        "name": "ACTIVE"
      }
    },
    "metadata": {
      "eventId": "befadebf-66ff-3c50-a068-8468856189d8",
      "modelId": "fc619c9e-23aa-3210-bad2-d69bc2e35e13",
      "version": 1,
      "createdAt": "2025-09-25T15:13:15.772+0000"
    }
  },
  "notification": {
    "status": "SENT",
    "processedBrokers": ["kafka"]
  }
}

// V2 Event  
{
  "_id": "ea196b5c-0da0-30fd-9c11-92d9f3dc5b37",
  "topic": "user.model.created.v2", 
  "event": {
    "body": {
      "personalData": {
        "firstName": "John",
        "lastName": "Smith"
      },
      "status": {
        "name": "ACTIVE"
      }
    },
    "metadata": {
      "eventId": "befadebf-66ff-3c50-a068-8468856189d8",
      "modelId": "fc619c9e-23aa-3210-bad2-d69bc2e35e13", 
      "version": 1,
      "createdAt": "2025-09-25T15:13:15.772+0000"
    }
  },
  "notification": {
    "status": "SENT",
    "processedBrokers": ["kafka"]
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

Cleanup: Removing the Old Version

Once you've confirmed all consumers have migrated to V2, cleanup is straightforward:

  1. Remove the UserCreatedV1 class
  2. Remove the UserCreatedMapperV1
  3. Remove the UsersEventTopicV1.CREATED enum entry
  4. Remove the PersonalDataV1 class if no longer used

The system will automatically detect that only the V2 mapper exists and publish only the V2 events going forward.

Benefits of This Approach

Zero-Downtime Migration: Consumers and producers can be updated independently without any service interruption.

Gradual Rollout: You can migrate consumers one by one, testing each step thoroughly before proceeding.

Rollback Safety: If issues arise, you can quickly revert consumers to the V1 topic while investigating.

Audit Trail: Both event versions are preserved in storage, providing a complete history of what was sent to different consumers.

No Coordination Required: Teams can work independently without complex deployment choreography.

The Framework's Philosophy

This design reflects a core philosophy: embrace change rather than fight it. Instead of treating event evolution as a problem to be solved with complex tooling, the framework makes evolution a first-class citizen. By assuming events will evolve and preparing for it from the start, we eliminate the pain that traditionally comes with event schema changes.

The automatic discovery and mapping approach also makes the system AI-friendly - when you need to create a new event version, you simply follow the established patterns, and the framework handles the rest. No manual registration, no complex configuration files, just clean, predictable code that works.

Built by software engineer for engineers )))