Post

Defining Events

Defining Domain Events in the Eventstore

Defining Events

This guide covers how to define domain events in your application to use with the Sliceworkz EventStore, As you’ll want strongly-typed Events accessible from your event streams.

Defining Domain Events

Domain events in the EventStore library are implemented using Java’s sealed interfaces combined with record implementations. This approach provides type safety, immutability, and a closed set of possible event types.

Basic Structure

1
2
3
4
5
sealed interface CustomerEvent {
    record CustomerRegistered(String name) implements CustomerEvent {}
    record CustomerNameChanged(String name) implements CustomerEvent {}
    record CustomerChurned() implements CustomerEvent {}
}

These records should only contain the application-level information about your event. Metadata like a unique ID, timestamp, Tags etc… are added as metadata at the time the event is appended to the event log.

Customizing to your application needs

You can easily extend this basic pattern as per your own requirements, with:

  • record structures that are shared over events for data that (eg: an Address record that is both refered to from a CustomerRegistered and CustomerMoved event)
  • factory methods to aid in the construction
  • builder pattern for more complex events
  • interface methods for elements that should be present on all events (eg: customerId(), which would require you to defined a customerId on each and every CustomerEvent implementing record

Why This Pattern?

Sealed Interfaces provide a closed set of domain events. The compiler knows all possible implementations, enabling:

  • Exhaustive pattern matching in switch expressions
  • Prevention of unauthorized event type extensions
  • Clear domain boundaries

Records ensure immutability and provide:

  • Automatic implementation of constructors, getters, equals(), hashCode(), and toString()
  • Concise syntax reducing boilerplate
  • Guaranteed immutability (all fields are final)
  • Value-based semantics appropriate for events

Benefits:

  • Type Safety: The compiler enforces that only defined event types can be used
  • Immutability: Events cannot be modified after creation, preserving historical integrity
  • Expressiveness: Event hierarchies clearly communicate domain concepts
  • Pattern Matching: Switch expressions on sealed types must handle all cases or fail at compile-time

Example with Multiple Event Types

1
2
3
4
5
6
sealed interface OrderEvent {
    record OrderPlaced(String orderId, String customerId) implements OrderEvent {}
    record OrderLineAdded(String productId, int quantity) implements OrderEvent {}
    record OrderShipped(String trackingNumber) implements OrderEvent {}
    record OrderCancelled(String reason) implements OrderEvent {}
}

Pattern Matching

Java pattern matching makes event handling code very straightforward:

1
2
3
4
5
6
7
public void when(CustomerEvent event) {
    switch(event) {
        case CustomerRegistered r -> this.name = r.name();
        case CustomerNameChanged n -> this.name = n.name();
        case CustomerChurned c -> this.active = false;
    }
}

Erasable Data in the event payload

In principle, Event Sourcing states that events are immutable. In some situations, however, this conflicts with with functional or non-functional (regulatory, …) requirements where data needs to be deletable from the system. The European GDRP (General Data Protection Regulation), for example requires the “right to be forgotten”, leading to a functional requirement that any personal data must be deleted from the system.

Therefore, the Eventstore separates event data internally in an immutable and erasable (forgettable) part. Those are combined for everyday usage, but when the erasable part is deleted, the event lives on with only the immutable data. It will be clear that your application logic will need to be able to process events that have been stripped off of this erasable data.

The EventStore library provides annotations to mark personal data fields that must be erasable for GDPR compliance (right to be forgotten). These annotations enable selective data removal while preserving event structure and non-personal metadata.

The @Erasable Annotation

The @Erasable annotation marks fields containing personal data that can be completely erased without losing the event’s semantic meaning.

1
2
3
4
5
6
7
8
9
10
11
public record CustomerRegistered(
    String customerId,           // Not erasable - needed for correlation

    @Erasable
    String name,                 // Personal data - can be erased

    @Erasable
    String email,                // Personal data - can be erased

    LocalDateTime registeredAt   // Temporal metadata - not erasable
) implements CustomerEvent {}

After erasure:

1
// CustomerRegistered(customerId="123", name=null, email=null, registeredAt=2024-01-15T10:30:00)

The @PartlyErasable Annotation

The @PartlyErasable annotation marks fields containing nested objects that have some (but not all) erasable fields. This signals that erasure logic must recurse into the nested structure.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public record Address(
    @Erasable
    String street,          // Personal data

    @Erasable
    String houseNumber,     // Personal data

    String zipCode,         // Not personal - aggregate data
    String country          // Geographic metadata
) {}

public record CustomerRegistered(
    String customerId,

    @Erasable
    String name,

    @PartlyErasable         // Contains some erasable fields
    Address address
) implements CustomerEvent {}

After erasure:

1
2
3
4
5
// CustomerRegistered(
//   customerId="123",
//   name=null,
//   address=Address(street=null, houseNumber=null, zipCode="12345", country="USA")
// )

When to Use Each Annotation

Use @Erasable when:

  • The entire field value is personal data
  • The field can be set to null or a sentinel value without breaking semantics
  • Examples: name, email, phone number, social security number

Use @PartlyErasable when:

  • The field contains a complex object (record, class, or collection)
  • Only some nested fields within the object are personal data
  • The structure must be preserved with selective field erasure
  • Examples: address objects, contact information blocks, nested value objects

Multi-Level Nesting

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public record ContactInfo(
    @Erasable String email,
    @Erasable String phone
) {}

public record Person(
    @Erasable String name,
    @PartlyErasable ContactInfo contactInfo
) {}

public record CustomerRegistered(
    String customerId,
    @PartlyErasable Person person
) implements CustomerEvent {}

Customizing to your application needs

It could be a good idea to create your own application-level annotation that implies @Erasable but adds some documentation fields:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
@Erasable
public @interface GdprErasable {
    String purpose() default "";
    Category category() default Category.PERSONAL;
    
    enum Category {
        PERSONAL,      // Name, address, etc.
        CONTACT,       // Email, phone
        FINANCIAL,     // Payment info
        HEALTH,        // Medical data
        BIOMETRIC      // Fingerprints, facial recognition
    }

}

You can then use this annotation instead of @Erasable, adding the metadata about each field:

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
public sealed interface CustomerEvent {
	
public record CustomerRegistered (  

	String id,
	
	@GdprErasable(
		category = Category.CONTACT, 
		purpose = "required for personal communication")
	String name,
	
	@GdprErasable(
		category = Category.CONTACT, 
		purpose = "required for sending transactional e-mails")
	String email,
	
	@PartlyErasable
	Address address
	
	) implements CustomerEvent {
	
}

public record Address ( 
	
	@GdprErasable(
		category = Category.PERSONAL, 
		purpose="sending snail mail")
	String street,
	
	@GdprErasable(
		category = Category.PERSONAL, 
		purpose="sending snail mail")
	String number,
	
	String zip ) {
	
}
	
}

Generating your obligatory GDPR data register that lists all personal data held, along with the rationale for keeping it, now becomes just a matter of Java reflection on your domain events!

IMPORTANT: ErasableData can be used to “forget” data in your EventStore, but be aware that PII data will probably still exist in your projections and readmodels. Make sure to register a domain event that expresses that the right to be forgotten has been used, and remove that data from your readmodels in the projection logic.

Versioning Events

As systems evolve, event structures need to change. The EventStore library supports two primary approaches to event versioning while maintaining event immutability.

Approach 1: Versioned Event Names

Create new event types with explicit version suffixes:

1
2
3
4
5
6
7
8
9
sealed interface CustomerEvent {
    // Original version
    record CustomerRegistered(String name) implements CustomerEvent {}

    // New version with additional fields
    record CustomerRegisteredV2(Name name, Email email) implements CustomerEvent {}

    record CustomerRenamed(Name name) implements CustomerEvent {}
}

Advantages:

  • Simple and explicit
  • Both versions can coexist in the codebase
  • Clear distinction between old and new structures

Disadvantages:

  • Compiler won’t stop you from appending new instances of an older event type
  • Application code must handle multiple event types for the same business fact
  • Queries must explicitly include all versions of an event

Approach 2: Upcasting

In this approach, you separate between (current) domain events and historical domain events. The latter still exist in the codebase, but cannot be appended anymore. They only exists In addition, each historical event needs to have an Upcaster that transforms it to a current event type.

Since this is all checked at compile-time, this approach is the recommended one.

Define legacy events separately and transform them transparently when reading from the store:

The current ones look just as you would expect them to be, but the naming can give away that things have looked differently in the past:

1
2
3
4
5
// Current event definitions
sealed interface CustomerEvent {
    record CustomerRegisteredV2(Name name, Email email) implements CustomerEvent {}
    record CustomerRenamed(Name name) implements CustomerEvent {}
}

Historical ones are defined in a parallel sealed interface, annotated as a LegacyEvent with the reference to an Upcaster:

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
// Historical events (for deserialization only)
sealed interface CustomerHistoricalEvent {
    @LegacyEvent(upcast = CustomerRegisteredUpcaster.class)
    record CustomerRegistered(String name) implements CustomerHistoricalEvent {}

    @LegacyEvent(upcast = CustomerNameChangedUpcaster.class)
    record CustomerNameChanged(String name) implements CustomerHistoricalEvent {}
}

// Upcaster implementation
public class CustomerRegisteredUpcaster
    implements Upcast<CustomerHistoricalEvent.CustomerRegistered,
                      CustomerEvent.CustomerRegisteredV2> {

    @Override
    public CustomerEvent.CustomerRegisteredV2 upcast(
            CustomerHistoricalEvent.CustomerRegistered legacy) {
        return new CustomerEvent.CustomerRegisteredV2(
            new Name(legacy.name()),
            Email.unknown()  // Default for new required field
        );
    }

    @Override
    public Class<CustomerEvent.CustomerRegisteredV2> targetType() {
        return CustomerEvent.CustomerRegisteredV2.class;
    }
}

Usage:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Include historical events when creating the stream
EventStream<CustomerEvent> stream = eventstore.getEventStream(
    streamId,
    CustomerEvent.class,              // Current events
    CustomerHistoricalEvent.class     // Historical events
);

// Queries automatically upcast legacy events
stream.query(EventQuery.matchAll())
    .forEach(event -> {
        // All events are of type CustomerEvent
        CustomerEvent current = event.data();
    });

Advantages:

  • Clean separation between current and historical schemas
  • Application code only works with current event definitions (compile-time checked)
  • Transparent transformation when reading from the store (no additional cognitive load upon application developers)
  • Queries can filter on upcasted target types (all historical types are included for free and returned as their corresponding current type)

Disadvantages:

  • Upcaster implementations needed for each legacy event type
  • Slight runtime overhead during event deserialization

Customizing to your application needs

Once you fully understand how event versioning and the Eventstore library works, you could go for more advanced tactics, e.g.:

  • When you update the event type column in the eventstore database, and adapt the Java type name directly after, you could rename CustomerRegisteredV2 back to CustomerRegistered, as long as you first rename the original CustomerRegistered to e.g. HistoricalCustomerRegisteredV1. This way, all application code keeps the clean current naming, as none of your code will depend on the historical events anyway. These types are only there to support querying and upcasting old event types from your eventstream.

  • If you want (although potentially more controversial) you could even update the event data in the eventstore database and replace it with the upcasted version. This way, it is as if your old event type never existed. No more overhead as none of your events will need upcasting, but it violates the idea of immutability and prohibits an older version of the software to read the eventstream up until the point it created it at the time.

This post is licensed under CC BY 4.0 by the author.