Demistifying Event Sourcing (1 of 5)
A part-by-part deep dive into event sourcing through five myth-busting articles.
Event sourcing carries a reputation for being complex, exotic, and risky. Much of that reputation comes from myths, misunderstandings that make developers hesitant to adopt a pattern that accountants, banks, and ledger-based systems have relied on for centuries. I’ve heard the same objection many times when assessing event sourcing in a design discussion: “It’s too much. You end up dealing with events everywhere.” It’s a reasonable concern, but in my experience it points to an architecture problem, not an event sourcing problem.
This is the first in a series of articles where I’ll work through the most common event sourcing myths, using a real eLearning platform as the example: a course review system where students can rate and comment on courses. Let’s start with the big one.
Myth 1: “Implementing event sourcing is hard”
The Myth: “When working with event sourcing I’m dealing with events in every area of my code, making it harder to maintain and reason about.”
The Reality: Event sourcing challenges are real, but they can be fully contained within the repository layer. Your domain model doesn’t need to know anything about it.
The key insight is this: event sourcing is a persistence strategy, not a domain modelling strategy. If you treat it as such, all of its complexity lives in one place, the repository adapter, and nowhere else.
The domain model: completely unaware
The ReviewEntry aggregate handles business logic the same way it would in any well-structured DDD application. It records what happened — nothing more:
ReviewEntry aggregate - update review
void updateReview(Review review) {
if(!Objects.equals(this.review, review)) {
this.review = review;
this.record(new ReviewUpdatedEvent(review, reviewEntryId,
Instant.now(), courseId));
}
}
No event store. No serialisation. No stream versioning. The aggregate just calls record(), a method defined higher up in the entity hierarchy to track the changes on the aggregate, and carries on. The command handler is equally clean:
Command Handler
@Override
public void handle(UpdateReviewCommand command) {
ReviewEntry reviewEntry = reviewEntryRepository
.retrieveReviewEntryFor(command.getAuthor(), command.getCourseId())
.orElseThrow(() -> new ReviewEntryNotFoundForCourseAndUserException(...));
reviewEntry.updateReview(command.getReview());
reviewEntryRepository.save(reviewEntry);
}
This is just a standard command handler pattern. No event sourcing footprint here at all.
The repository: where all the complexity lives
The ReviewEntryRepository is defined in the domain as a plain interface, a port that knows nothing about how persistence works:
Domain Port
interface ReviewEntryRepository {
void save(ReviewEntry reviewEntry);
Optional<ReviewEntry> retrieveReviewEntryFor(EntryAuthor author, CourseId courseId);
}
The event sourcing implementation lives in its own dedicated module: rating-system-repository-event-sourced. It implements both the repository port and an EventSourced<ReviewEntry> interface that forces a single contract:
EventSourced Interface
public interface EventSourced<T extends Entity> {
Optional<T> hydrateFrom(List<Event<DomainEventPayload>> events);
}
Saving is trivially simple, just append whatever events the aggregate recorded:
Save: append events to the store
@Override
public void save(ReviewEntry reviewEntry) {
eventStore.appendEvents(reviewEntry.getDomainEvents());
}
Loading is where the real work happens. The aggregate state is rebuilt by replaying its event history, a left fold over the event stream:
Hydrate: replay events to rebuild state
@Override
public Optional<ReviewEntry> hydrateFrom(
List<Event<DomainEventPayload>> events) {
if (Objects.isNull(events) || events.isEmpty()) {
return Optional.empty();
}
return Optional.of(events.stream()
.reduce(new ReviewEntry((long) events.size()),
(reviewEntry, event) -> {
if (event.getPayload() instanceof ReviewEntryCreatedEvent e) {
reviewEntry.setCourseId(new CourseId(e.getCourseId()));
reviewEntry.setTitle(new Title(e.getTitle()));
reviewEntry.setReview(e.getReview());
// ... set remaining fields
} else if (event.getPayload() instanceof ReviewUpdatedEvent e) {
reviewEntry.setReview(new Review(
e.getReview().stars(), e.getReview().comment()
));
reviewEntry.setEntryUpdateTime(e.getUpdatedAt());
}
return reviewEntry;
},
(a, b) -> b));
}
How the pieces fit together
The flow from command to persisted event is straightforward:
The supporting infrastructure (the EventStore interface and its Postgres JDBC implementation) lives in toolkit-core and toolkit-event-store-jdbc-postgres, completely separate from the rating system’s domain logic. The rating system modules are structured to reinforce these boundaries:
rating-system-domain: Aggreates, value objects, domain events, repository port.rating-system-application: Commands and handlers, orchestrates use case execution.rating-system-repository-event-sourced: The only module that nows about event sourcing.rating-system-rest: HTTP inbound adapter exposing the rating endpoints.
The takeaway
Event sourcing does add complexity, I’m not going to pretend otherwise. But that complexity has a natural home: the repository adapter. When you architect the boundary correctly, the rest of your application is completely insulated. Your domain model, command handlers, and REST layer don’t know or care how persistence works.
That separation is what makes event sourcing liveable at scale. The myth isn’t that it’s complex, it’s that the complexity is uncontrollable. It isn’t.
The full implementation is available on GitHub, it’s worth cloning if you want to see how everything wire together in practice.
Coming up in this series:
- Myth 1: Implementing event sourcing is hard —> you are here.
- Myth 2: Event sourcing and event driven architectures are the same thing
- Myth 3: Storing every change forever will kill performance
- Myth 4: If I change my data Structure, old events are broken forever
- Myth 5: It is difficult to query the state when using event sourcing
Next up: Myth 2 will tackle one of the most common conceptual confusions in distributed systems, the difference between event sourcing and event-driven architecture. They often appear together, but they solve completely different problems. See you there!