Site icon JVM Advent

Introduction to Event Sourcing

Have you ever stumbled upon a concept that completely changed how you think about building software? For me, that moment came when our architect introduced the Event Sourcing pattern a few years ago. Like many junior developers, I started my career working on traditional CRUD applications. They were simple, efficient, and worked well—until we really understood our domain.

As system grew more complex due to complex business flows, we found struggling with basic questions:

The main purpose of my article is to present the foundational concepts with pure Java code examples.

If there’s one thing I’ve learned over the years, it’s that this pattern is highly subjective in its implementation, and there’s no perfect way to do things—any cake recipe is good as long as you and your family enjoy eating it.

PS: I’ll do my best to show you that the following metaphor shouldn’t hold true: “Software ages like milk, not wine“.

To be honest, in the first place, this pattern may require additional related concepts that complement each other very well, especially for complex or large systems. Domain Driven Design (DDD) and Command Query Responsibility Segregation (CQRS) provide significant benefits when used together ❤️ with Event Sourcing (ES), but they are not mandatory ⚠️. Each one has their proven benefits, but the complexity added to the system, pff … can easily become a nightmare, so please be aware and think more carefully before applying any.

“If the only tool you have is a hammer , you tend to see every problem as a nail.” (Abraham Maslow)

DEFINITION

Event sourcing is an architectural pattern where state transitions of a system are stored into an immutable append-only storage as a sequence of immutable events into an immutable append-only storage, instead of just saving the latest state.

These events represent facts that have occurred in the past, and by replaying them in order, you can reconstruct the system’s state at any point in time.

Events timeline

Event Storming

Given the fact that the only single source of truth is in the events stream we need to do a small session of brainstorming but with events, so called Event Storming.

The first step in running an event storming workshop is to invite the right stakeholders and bring them together (I prefer a physical room for high concentration, not a virtual one). It’s a place where technicalities are left at the door—no Java, no Spring Boot, just business. I get it; you might think it’s just another boring meeting, but trust me, it’s the safest playground before writing any piece of code. Try to ask as many questions as possible (even the dumbest ones, because you don’t fully understand the domain yet), you’ll thank yourself later.

The rules of the game are simple, we need coloured sticky notes:

For our remote setup we’ll use one great tool for this called Miro. We’ve been using it since the beginning of our journey, and it has never let us down, so I highly recommend it.

Event Storming terminology

Firstly, each participant must add events to the board in any order. Then, we will remove duplicates and arrange them chronologically on the timeline — unfortunately, I don’t have sufficient space to replicate the timeline in a horizontal format.

Secondly, participants must enhance the events with corresponding commands, actors, policies and views. After all puzzle pieces are in place we must align them accordingly to consistency requirements and aggregates will be evident enough.

In DDD, an aggregate is the core lifecycle pattern treated as a single unit which enforces invariance and transactional boundaries to maintain data consistency within.

In ES, the transactional boundary is redefined within the stream of events – Sara Pellegrini wrote in her gold series about this (https://sara.event-thinking.io/2023/04/kill-aggregate-chapter-1-I-am-here-to-kill-the-aggregate.html).


Our journey begins with a real-life scenario that deviates from the usual examples (e.g., bank accounts or eCommerce shopping carts), drawing inspiration from the Spring demo project, PetClinic.

Let’s begin by defining some fundamental events.

Events

Due to space and time constraints, I will not cover here all use cases but will focus solely on the business flows related to the Pet.

Pet domain

To sum up the board results, the command side is composed by 5 actions, all performed by the same actor:

A single policy exists in our scenario for sending a notification to the new owner to inform about the ownership change.

For the read side, we currently have only 2 views:

The key advantage of this approach is its flexibility—additional views can be constructed even from the existing events, allowing product owners to be highly creative in designing new features.

I’ve tried to simplify as much as I could the story and I hope you got the point.

Earlier this year, I came across the Event Modeling approach proposed by Adam Dymitruk, which feels like a natural evolution of Event Storming. While I haven’t had hands-on experience with this method yet, I’m eager to dive in, explore, and put it into practice.

Event sTORE

What is it ? It’s a specialised database designed to store and retrieve events which are immutable records of changes that have occurred in the system.

Another database ?

Wait, wait, I can try to explain!

Why do we need it? Because we must store the source of truth in a highly available and performant storage with several essential features:

As you dive deeper into the topic you’ll discover additional specific requirements, such as appending multiple events at once, reading only committed events, reading streams from a specific offset, streaming data via subscriptions, building snapshots, and much more.

Currently, Axon Server is the leading choice in the Java ecosystem, while EventStoreDB dominates in the .NET ecosystem.

In 2024 the SQL winner among developers is PostgreSQL, so why not use it for event sourcing as well? You might think a single table partitioned by stream identifier would be sufficient.

Yes, you can do that, but I wouldn’t recommend it. I’ve tried it by myself (sadly, still here 😅), and it works—until you realise you’re essentially re-creating the same features that dedicated event store providers offer by default. Many frameworks support SQL databases for event storage, but that doesn’t mean it’s the right choice in production.


Enough theory, let’s turn on the JVM!

SHOW ME THE CODE

To better understand event sourcing, I decided to build a basic custom infrastructure without using frameworks or libraries. This hands-on approach allowed me to focus on the core concepts.

The foundation of event sourcing lies in events, making their design the natural starting point. I initially implemented a basic interface but found it lacked sufficient compiler support for enforcing validations and raising errors if an event was not properly handled. To address this, I transitioned to a more robust solution using sealed interfaces, introduced in Java 17.

Immutability is a key requirement for events, making records the natural choice.

Applying the theory to our example, each PetEvent implements the sealed interface PetEvent, which enforces a restricted set of allowed events. Adding a new event involves two steps:

  1. Define the event as a record with the necessary business fields (e.g., PetRegistered)
  2. Include it in the sealed interface’s list of permitted implementations.
public sealed interface PetEvent
        permits PetEvent.PetRegistered,
        PetEvent.PetDetailsUpdated,
        PetEvent.PetOwnerTransferred,
        PetEvent.PetDeactivated,
        PetEvent.PetMedicalEntryAdded {

    record PetRegistered(
            UUID id,
            String name,
            Instant birthDate,
            PetType type,
            Owner owner
    ) implements PetEvent {}

    ....
}

Events can be defined as static nested classes within the interface or as separate classes. Given the small number of events in this example, I preferred them in the same interface for simplicity.

Next, we need to establish the API for the event store. Two core functionalities are critical:

Additionally, a subscription mechanism is essential to notify listeners about changes.

public interface EventStore {

    List<Object> readStream(String streamId);
    void appendStream(String streamId, int expectedVersion, Object[] events);

    void subscribe(EventListener listener);
}

The read operation is straightforward, as you only need to provide the stream identifier, and a list of events is returned in the order they were written.

The append operation is a bit more complex because it must handle optimistic locking and the creation of a non-existing stream. The key element here is the provided expectedVersion.

The subscribe operation follows the basic Observer pattern.

public interface EventListener {
    void on(Object event);
}

Let’s explore in more detail how an InMemoryEventStore might be implemented.

First, it must partition all streams by identifiers, maintaining a sequential list of events for each. A Map would be the ideal structure for this. Second, it should manage the list of subscribers. The subscribe and readStream methods are self-explanatory.

public class InMemoryEventStore implements EventStore {

    static final Map<String, LinkedList<EventEnvelope>> STORE = new ConcurrentHashMap<>();
    static final List<EventListener> EVENTS_SUBSCRIBERS = new LinkedList<>();

    public void subscribe(EventListener listener) {
        EVENTS_SUBSCRIBERS.add(listener);
    }

    public List<Object> readStream(String streamId) {
        return STORE.get(streamId)
    }

    ....
        
}

Instead of storing a simple event object, we use an EventEnvelope. But why is this necessary?

It splits the stored data into two parts: metadata and event payload.

This separation makes it easier to track events, adds flexibility for future changes, and improves scalability.

public record EventEnvelope(
        EventMetadata metadata,
        EventPayload data
) {}
public record EventMetadata(
        int version,
        Instant timestamp
) {}
public record EventPayload(
        String name,
        String data
) {}

Roll up your sleeves, it’s time for stream appending:

a) The stream doesn’t exists yet in the store

For safety, we check that the provided expectedVersion aligns with the actual state of an uninitialized stream in storage. I get it—sometimes the term “version” can be misleading since it also refers to the position. In our implementation the version is not zero based, so we start with 1.

Next, we loop through the list of events and map them as described above. The serialize method uses a simple Jackson setup for converting the domain event into JSON format.

if (!STORE.containsKey(streamId)) {
    if (expectedVersion != -1) {
        throw new IllegalStateException("Version mismatch for uninitialized stream");
    }

    int lastVersion = 1;
    var newEvents = new LinkedList<EventEnvelope>();
    
    for (var event : events) {
        newEvents.add(
                new EventEnvelope(
                        new EventMetadata(
                                lastVersion++,
                                Instant.now()),
                        serialize(event)
                )
        );
    }

    STORE.put(streamId, newEvents);
}

b) The stream exists already in the store

Again, for safety, we ensure the stream not only exists but also contains at least one event. Then, the powerful but simple enough optimistic mechanism is used. We verify that the last event version from stream is equal to the one we read from stream when we begin the workflow.

Finally, we append the new events, properly packaged, to the stream.

else {
    var currentEvents = STORE.get(streamId);

    if (currentEvents == null) {
        throw new IllegalStateException("Stream not initialized");
    }

    var lastVersion = currentEvents.getLast().metadata().version();
    if (lastVersion != expectedVersion) {
        throw new IllegalStateException("Optimistic locking failure");
    }

    int newVersion = lastVersion + 1;
    for (var eventPayload : eventPayloads) {
        currentEvents.addLast(new EventEnvelope(new EventMetadata(newVersion++, Instant.now()), eventPayload));
    }
}

In the end, we’ll notify the subscribers. Note that this operation should be performed asynchronously to ensure good performance.

for (var event : events) {
    for (var eventSubscriber : EVENTS_SUBSCRIBERS) {
        eventSubscriber.on(event);
    }
}

Let’s move on to the next chapter: event handling and command handling. Since I’ve chosen to combine DDD concepts, we’ll handle it within the Aggregate.

public interface Aggregrate<ID> {

    ID id();
    int version();

    Object[] uncommitedEvents();

}

Good enough, but the ID and version fields make it look like a classic entity. That’s true, and this is one of the limitations of this design. If you fail while identifying the aggregates you’ll have hard times refactoring (been there, done that). Another issue is that now that the stream is coupled with the aggregate, so the unit of work becomes limited, and cross-aggregate updates are possible only through eventual consistency. The alternative is the Sara Pellegrini approach, which I really must try!

But we have one more important piece: uncomittedEvents. This is the core of the entire event-sourced aggregate lifecycle. The workflow is managed through these events, which are basically stored in a Queue, not in a random collection.

The interface, though useful, is not sufficient on its own. That’s why we need another layer of abstraction, called AbstractAggregate, which implements the basic features:

  • Apply an event – only updates the aggregate state.
  • Enqueue an event – pushes the event into queue and applies it.
  • Fetch uncommitted events – pops all events from the queue for storing.
  • Replay events – rebuilds the state of the aggregate from the stream.
public abstract class AbstractAggregate<I, E> implements Aggregrate<I> {

  protected I id;
  protected int version;

  private final Queue<E> uncommittedEvents = new LinkedList<>();

  @Override
  public I id() {
      return id;
  }

  @Override
  public int version() {
      return version;
  }

  @Override
  public Object[] uncommitedEvents() {
      Object[] events = uncommittedEvents.toArray();
      uncommittedEvents.clear();
      return events;
  }

  public void replay(List<E> events) {
      events.forEach(e -> {
          apply(e);
          version++;
      });
  }

  protected abstract void apply(E event);

  protected void enqueue(E event) {
      uncommittedEvents.add(event);
      apply(event);
  }

}

One tricky part is the versioning of the aggregate. While applying an event, the version doesn’t increment because it is used at the end of the transaction for optimistic locking in the event store in order to check that the read version hasn’t changed in the meantime. However, during replaying, the version must increment because those events are being validated.


Puzzle pieces are in place, let’s build our Pet aggregate.

The structure is pretty straightforward, I won’t spend time explaining it. Just one note, soft delete was preferred through active flags. The argument is that maybe the Pet is coming back to our clinic – I advise you to check the Udi Dahan article about deletion

public class Pet extends AbstractAggregate<UUID, PetEvent> {

    private String name;
    private Instant birthDate;
    private PetType type;
    private Owner owner;
    private boolean active;
    private final List<MedicalEntry> medicalEntries = new LinkedList<>();

    ...
}

The first question is how a new Pet is created (Java way) ? Constructor. But a constructor with the first RegisterPet command as an argument.

public Pet(RegisterPet command) {
    enqueue(
       new PetRegistered(
         UUID.randomUUID(), 
         command.name(), 
         command.birthDate(), 
         command.type(), 
         command.owner()
       )
   );
}

Simple, right? Let’s move forward. How is the event handled ? Pattern matching for switch.

After the event is enqueued to store by being pushed into uncommitted events, the apply method is triggered and the aggregate’s state is updated.

@Override
protected void apply(PetEvent event) {
    switch (event) {
        case PetRegistered registered -> {
            this.id = registered.id();
            this.name = registered.name();
            this.birthDate = registered.birthDate();
            this.owner = registered.owner();
            this.type = registered.type();
            this.active = true;
        }
        ...
    }
}

The story continues the same for each command of an event, but in a more complex way. We can handle conditions in the command handling methods. Keep in mind that conditions shouldn’t be used in the event handling because that’s out of their scope.

For example, we can’t update pet details if it’s inactive.

public void handle(UpdatePetDetails command) {
    if (!active) {
        throw new IllegalStateException("Pet is not active");
    }

    enqueue(new PetDetailsUpdated(this.id, command.name(), command.birthDate(), command.type()));
}

Nice, we successfully created a Pet, processed a command and applied an event. Putting all together, you should see the following in the application service:

Pet dog = new Pet(
        new PetCommand.RegisterPet(
                "My dog", 
                LocalDateTime.of(2020, 1, 1, 0, 0)
                        .toInstant(ZoneOffset.UTC), 
                PetType.DOG, 
                new Owner("Alex", "alex@gmail.com", "Street 1")
        )
);

eventStore.appendStream(
        dog.id().toString(), 
        -1, 
        dog.uncommitedEvents()
);

Nothing fancy, just a Pet stored. Let’s now update its details. We load the aggregate from events, emit a new command and store the new version.

Pet storedPet = new Pet(
        eventStore.readStream(dog.id().toString())
);

storedPet.handle(
        new PetCommand.UpdatePetDetails(
                "My dog is a cat", 
                LocalDateTime.of(2020, 1, 1, 0, 0)
                        .toInstant(ZoneOffset.UTC), 
                PetType.CAT
        )
);
eventStore.appendStream(
        dog.id().toString(), 
        storedPet.version(), 
        storedPet.uncommitedEvents()
);

Expected, right?

Until now we’ve discussed the C (command) from the CQRS. Let’s move forward to the Q (query) side.

In my code example I’ve treated the query synchronously through event listeners but the desire is to be asynchronous. Policies and views (projections) are the most suited for this part.

You remember the notification policy ? We will firstly store the notifications then a worker will push them into the remote service. The policy only reacts to required events, builds the message and stores it for delivery.

public class NotificationPolicy implements EventListener {

 private final Repository<Notification, UUID> repository;

 public NotificationPolicy(
          Repository<Notification, UUID> repository
 ) {
     this.repository = repository;
 }

  @Override
  public void on(Object event) {
    switch (event) {
      case PetEvent.PetOwnerTransferred petOwnerTransferred -> {
           repository.save(
                 new Notification(
                     petOwnerTransferred.newOwner().email(), 
                     "Your new pet", 
                     "Congratulations for owning!"
                 )
            );
      }
      default -> 
         System.out.println(
          "Event " + event.getClass().getName() + " not handled"
         );
    }
  }
}

We needed 2 views: Pets and Sick pets, right ? In a classic SQL (or ORM) environment you would expect to have different queries applied to the same tables in order to produce the expected result set. Then, in production you will face some performance bottlenecks and performance strategies are going to appear (e.g. indexes, partitioning, sharding). But why not precompute the data in the desired format ?

The projection classes act as event listeners for each desired view. They use a specialized storage solution (SQL/NoSQL – doesn’t matter for us) to save the data according to the business requirements. Fields are added and managed only if they are needed, not because they exist.

The all pets projection is complex because it must keep up to date with all the pet data.

public class PetsProjection implements EventListener {

    private final Repository<PetDetails, UUID> repository;

    public PetsProjection(Repository<PetDetails, UUID> repository) {
        this.repository = repository;
    }

    @Override
    public void on(Object event) {
        switch (event) {
            case PetEvent.PetRegistered registered 
-> addPet(registered);
            case PetEvent.PetDetailsUpdated detailsUpdated 
-> updatePetDetails(detailsUpdated);
            case PetEvent.PetMedicalEntryAdded medicalEntryAdded -> addMedicalEntry(medicalEntryAdded);
            case PetEvent.PetOwnerTransferred ownerTransferred 
-> changeOwner(ownerTransferred);
            case PetEvent.PetDeactivated petDeactivated 
-> deactivatePet(petDeactivated);
            default -> System.out.println("Event " + event.getClass().getName() + " not handled");
        }
    }

    private void addPet(PetEvent.PetRegistered registered) {
        repository.save(
                new PetDetails(
                        registered.id(),
                        registered.name(),
                        registered.birthDate(),
                        registered.type(),
                        registered.owner(),
                        true,
                        new ArrayList<>()
                )
        );
    }

    private void updatePetDetails(PetEvent.PetDetailsUpdated detailsUpdated) {
        var petDetails = repository.findById(detailsUpdated.petId());

        petDetails.ifPresent(pet -> {
            pet.updateDetails(detailsUpdated.name(), detailsUpdated.birthDate(), detailsUpdated.type());
            repository.save(pet);
        });
    }

   .....
}

The sick pets projection is simpler because it’s interested only if a Pet has a new medical record or if it’s removed. The remaining events are not handled because they are useless for the clinic’s graphs and statistics.

public class MostSickPetsProjection implements EventListener {

    private final Repository<SickPetDetails, UUID> repository;

    public MostSickPetsProjection(Repository<SickPetDetails, UUID> repository) {
        this.repository = repository;
    }

    @Override
    public void on(Object event) {
        switch (event) {
            case PetEvent.PetMedicalEntryAdded medicalEntryAdded -> addMedicalEntry(medicalEntryAdded);
            case PetEvent.PetDeactivated petDeactivated 
-> removePet(petDeactivated);
            default -> System.out.println("Event " + event.getClass().getName() + " not handled");
        }
    }

    private void addMedicalEntry(
         PetEvent.PetMedicalEntryAdded medicalEntryAdded) 
     {
        var petDetails = repository.findById(medicalEntryAdded.petId());

        petDetails.ifPresentOrElse(pet -> {
            pet.addMedicalEntry(new MedicalEntry(medicalEntryAdded.entryDescription(), medicalEntryAdded.entryDate()));
            repository.save(pet);
        }, () -> {
            repository.save(new SickPetDetails(medicalEntryAdded.petId(), new MedicalEntry(medicalEntryAdded.entryDescription(), medicalEntryAdded.entryDate())));
        });

    }
    
    private void removePet(PetEvent.PetDeactivated petDeactivated) {
        repository.delete(petDeactivated.petId());
    }
}

We see how to build a view, but how to effectively query?

If we need to filter or paginate the results, it would be recommended to create query classes (or records) and pass them directly to the corresponding repository.

public record GetPetsQuery(
        int offset,
        int size,
        Optional<String> name
) {}

public class PetsRepository extends Repository<PetDetails, UUID> {

    public List<PetDetails> findPets(GetPetsQuery query) {
        var pets = findAll();

        if (query.name().isPresent()) {
            pets = pets.stream()
                 .filter(pet -> pet.name().toLowerCase()
                    .contains(query.name().get().toLowerCase()))
                 .toList();
        }

        return pets.subList(
            query.offset(), 
            Math.min(query.size(), pets.size())
        );
    }  

}

The second approach is to directly call the repository if no parameters needs to be provided

public class SickPetsRepository extends Repository<SickPetDetails, UUID> {

    public List<SickPetDetails> findSickPetsOrderDesc() {
        return findAll().stream()
                .sorted(
                     Comparator.comparing(p ->   
                         p.medicalEntries().size()))
                .toList();
    }
}

CONCLUSION

Event Sourcing is more than a trendy pattern—it’s a time-tested approach that has proven invaluable for many businesses. It preserves the entire history of changes, provides built-in audit capabilities, and allows for easy time-based reversion when needed.

Events first 📖, view later 📊.

If you’re just starting out, focus on building a simple proof of concept (PoC) before exploring more advanced frameworks. Study how they work, but always critically assess their approach to ensure it aligns with your needs.

Most importantly, keep your business logic decoupled from the underlying technology. Industries like banking, e-commerce, and e-government have maintained stable core systems for decades, with only incremental feature additions. Unlike frameworks, which evolve rapidly, the core business remains relatively unchanged.

Treat your domain as the most valuable part 👑 — it’s the heart ❤️ of your business.


If you’ve read this far, thank you so much for your time! 🙏

I’ve done my best to simplify these concepts, though event sourcing is such an immersive and vast topic that one article can hardly do it justice. Hopefully, we’ll cross paths again next year to explore even more! 🚀

In the end, I definitely need to share some links to excellent resources and the top experts I follow to learn more about this concept:

And of course, the article’s GitHub repository.

Author: Alexandru Adam

Experienced Java developer with expertise in Spring and Kubernetes, driven by a passion for clean architecture and event-driven systems, including event sourcing.

Exit mobile version