DEV Community

Maksim Matlakhov
Maksim Matlakhov

Posted on • Originally published at blog.vibetdd.dev

Event Handling: Keep It Fast and Simple

When building event-driven systems, one of the biggest mistakes I see is treating all events the same way. A simple username update gets the same heavyweight processing as complex fraud detection logic. This leads to slow systems, unnecessary complexity, and hard-to-debug race conditions.

The solution? Recognize that events fall into two distinct categories and handle them differently. Simple data updates need fast, direct processing. Complex business workflows need careful orchestration. By using the right approach for each type, you can build systems that are both performant and maintainable.

In my previous post, I talked about treating messaging brokers as simple transport layers. Today, let's look at how to handle events the right way.

Three Simple Rules

Here are the basic rules I follow:

Rule 1: Order and Uniqueness

Every event needs an eventId and version. This helps you handle events in the right order and avoid processing the same event twice. Don't trust the messaging broker to do this for you.

Rule 2: Process Fast

Release events quickly. Don't do heavy work like complex calculations or calling external services right away. Store the event locally and do the heavy work later.

Rule 3: Simple vs Complex

Some events are simple (like updating a user's name), others are complex (like fraud detection). Handle them differently.

Two Types of Events

From my experience, events fall into two groups:

Type 1: Simple Updates

Easy data updates with no business rules:

  • Update search indexes
  • Sync read models
  • Clear caches

Type 2: Complex Work

Multistep processes with business rules:

  • Fraud detection
  • Complex workflows
  • External service calls
  • Business rule checks

The difference matters because each type needs different handling.

How Type 1 Processing Works

For simple updates, here's what happens when an event comes in:

  1. Get Event: Consumer receives event from messaging broker
  2. Check Version: Compare event version with stored data version
  3. Skip Old Events: If event.version ≤ stored.version, ignore it
  4. Update Data: Create new versioned data from event
  5. Save Changes: Update database with optimistic locking
  6. Handle Conflicts: Database throws an exception if someone else updated the record first
  7. Retry: Message broker retries the event automatically

This gives you:

  • Fast Processing: Direct database updates
  • Safe Concurrency: Optimistic locking prevents conflicts
  • Version Control: Prevents old events from overwriting new data
  • Auto Recovery: Message broker handles retries

Type 1 Implementation

For simple updates, I use a generic helper that handles versioning and prevents conflicts.

The Generic Helper

Here's the main helper class that handles all simple updates:

// Generic orchestrator that works with any model type
abstract class VTViewOrchestrator<MODEL: Any, DATA: Any>(
    private val modelStorage: VTModelStorageAdapter<MODEL, DATA>,
) {

    // Create new model from event
    fun <EVENT: EventDtoBody> create(
        event: EventV1<EVENT>,
        createModel: (EVENT) -> MODEL
    ) {
        // Don't create if already exists
        if (modelStorage.get(event.modelId) != null) return

        // Create new model with version 0
        modelStorage.create(
            Model(
                id = event.modelId,
                version = 0,
                createdAt = event.createdAt,
                updatedAt = event.createdAt,
                body = createModel(event.body)
            )
        )
    }

    // Update specific field in existing model
    fun <EVENT: EventDtoBody, FIELD: Any> update(
        event: EventV1<EVENT>,
        extractField: (MODEL) -> VersionedField<FIELD>, // Get current field
        updateModel: (MODEL, VersionedField<FIELD>) -> MODEL, // Set new field
        buildFieldData: (EVENT) -> FIELD // Build new data from event
    ) {
        // Get existing model, throws if not found (for cases when an update event comes first)
        val storedModel: Model<MODEL> = modelStorage.getRequired(event.modelId)
        val currentField: VersionedField<FIELD> = extractField(storedModel.body)

        // Skip if this event is older than stored data
        if (currentField.version >= event.version) return

        // Create new field with event data and version
        val newField: VersionedField<FIELD> = VersionedField(
            version = event.version,
            data = buildFieldData(event.body)
        )

        // Update model with new field
        modelStorage.update(
            storedModel.copy(
                updatedAt = event.createdAt,
                body = updateModel(storedModel.body, newField)
            )
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Versioned Fields for Concurrency

The key is using versioned fields so different parts can update independently:

// Each field has its own version
data class UserView(
    val personalData: VersionedField<PersonalData>, // Version for name, email, etc.
    val status: VersionedField<Status<String>>       // Version for status changes
)

// Wrapper that tracks version for any data type
data class VersionedField<T>(
    val version: Long = 0,
    val data: T,          
)
Enter fullscreen mode Exit fullscreen mode

Event Consumer

Here's how you use the helper to handle events:

@EventConsumer
class UserEventsConsumerV1(
    private val orchestrator: UserViewOrchestrator,
) {

    fun onCreated(event: EventV1<UserCreatedV1>) {
        orchestrator.create(event) {
            // Build initial user view from event
            UserView(
                personalData = VersionedField(
                    data = PersonalData(
                        name = event.body.personalData.name,
                    )
                ),
                status = VersionedField(
                    data = Status(
                        name = event.body.status.name,
                        message = event.body.status.message
                    )
                )
            )
        }
    }

    fun onPersonalDataUpdated(event: EventV1<PersonalDataUpdatedV1>) {
        orchestrator.update(
            event = event,
            extractField = { it.personalData },        // Get current personal data field
            updateModel = { model, field ->             // How to update the model
                model.copy(personalData = field) 
            },
            buildFieldData = {                          // Build new data from event
                PersonalData(name = it.current.name) 
            }
        )
    }

    fun onStatusUpdated(event: EventV1<UserStatusUpdatedV1>) {
        orchestrator.update(
            event = event,
            extractField = { it.status },             
            updateModel = { model, field ->            
                model.copy(status = field) 
            },
            buildFieldData = {                         
                Status(name = it.current.name, message = it.current.message) 
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

The Service Setup

The concrete orchestrator is just a Spring component:

@Component
class UserViewOrchestrator(
    storage: UserViewStorage, // Handles database operations
) : VTViewOrchestrator<UserView, UserViewDoc>(storage)
Enter fullscreen mode Exit fullscreen mode

And the mongo document with common version field that prevents concurrent update:

@Document("view-users")
data class UserViewDoc(
    val id: UUID,
    @field:Version // Does the magic
    val version: Long,
    val createdAt: Instant,
    val updatedAt: Instant,
    val data: UserView,
)
Enter fullscreen mode Exit fullscreen mode

Why This Works So Well

The generic helper pattern gives you several benefits:

Same Pattern Everywhere: All services use the same approach for handling events
Reusable Code: One helper works for all your models

Type Safety: Kotlin ensures you can't make mistakes with field types
Easy to Maintain: Fix bugs in one place, all services benefit
Fast Performance: Direct database updates without extra overhead

The versioned field approach solves a common problem: event ordering. Instead of trusting the message broker to deliver events in order (which I don't recommend), each field tracks its own version.

This means a "name updated" event with version 5 won't overwrite data from a "status updated" event with version 7. Each field can be updated independently without conflicts.

At the same time the common version field prevents a model inconsistency when different field updates come simultaneously.

What's Next: Complex Events

Type 1 events handle most of your event processing needs efficiently. But what about complex business logic that needs multiple steps, external service calls, or special error handling?

That's where Type 2 events come in. In my next post, I'll show you how to handle complex business workflows using internal event storage and Spring's event system, while keeping your architecture clean.

The main idea is simple: recognize that not all events need the same processing approach. By classifying events and using the right pattern for each type, you can build systems that are both fast and easy to maintain.

Top comments (0)