Simplifying an event sourced application
Bertil Muth
Posted on April 27, 2018
Every time you make a change to the application state, you record the change as an event.
You can replay the events since the beginning of the recording, up to a certain time. Then you've recreated the state of the application at that time.
That's what Event Sourcing is about. It's like you can time travel to the past. I find it fascinating.
Event sourcing provides an audit trail when you need to meet regulatory requirements. It can help with debugging. And you can even explore alternate realities: what would have happened if...
I recently saw a great talk by Jakub Pilimon and Kenny Bastani about event sourcing.
The talk is a 1 hour session of life coding. The two speakers start with a simple application that is not event sourced. Then they refactor it to use events.
They end up wiring the application up with Apache Kafka. I will skip that part in this article and focus on the conceptual part of event sourcing instead.
A recap of the talk
As a user of a Credit Card management application, you can:
- Assign a limit to the credit card
- Withdraw money
- Repay money
For each of these commands, there is a method in the CreditCard
class.
Here's the original code of the assignLimit
method:
public void assignLimit(BigDecimal amount) {
if(limitAlreadyAssigned()) {
throw new IllegalStateException();
}
this.initialLimit = amount;
}
Here's the withdraw
method:
public void withdraw(BigDecimal amount) {
if(notEnoughMoneyToWithdraw(amount)) {
throw new IllegalStateException();
}
if(tooManyWithdrawalsInCycle()) {
throw new IllegalStateException();
}
this.usedLimit = usedLimit.add(amount);
withdrawals++;
}
The repay
method is similiar.
Remember that for event sourcing, you need to record an event
any time the application changes its state?
So the speakers extract each state change to its own method in the CreditCard class.
Here's the refactored withdraw
method:
public void withdraw(BigDecimal amount) {
if(notEnoughMoneyToWithdraw(amount)) {
throw new IllegalStateException();
}
if(tooManyWithdrawalsInCycle()) {
throw new IllegalStateException();
}
cardWithdrawn(new CardWithdrawn(uuid, amount, Instant.now()));
}
private CreditCard cardWithdrawn(CardWithdrawn event) {
this.usedLimit = usedLimit.add(event.getAmount());
withdrawals++;
pendingEvents.add(event);
return this;
}
An instance of CardWithdrawn
represents the event that a user has successfully withdrawn money. After the state has changed, the event is added to the list of pending events.
You call the save
method of the CreditCardRepository class to flush the pending events to the event stream. Event listeners may handle the events then.
Apart from the payload, each event has its own unique identifier and timestamp. So you can sequence and replay the events later.
To replay the events for a specific credit card, the repository calls the recreateFrom
method of the CreditCard class, passing in the id of the card and the events stored for it:
public static CreditCard recreateFrom(UUID uuid, List<DomainEvent> events) {
return ofAll(events).foldLeft(new CreditCard(uuid), CreditCard::handle);
}
private CreditCard handle(DomainEvent event) {
return Match(event).of(
Case($(Predicates.instanceOf(LimitAssigned.class)), this::limitAssigned),
Case($(Predicates.instanceOf(CardWithdrawn.class)), this::cardWithdrawn),
Case($(Predicates.instanceOf(CardRepaid.class)), this::cardRepaid),
Case($(Predicates.instanceOf(CycleClosed.class)), this::cycleWasClosed)
);
}
This code uses the vavr.io library to call the handle
method for each event. The handle
method dispatches the event object to the appropriate method.
For example: for each LimitAssigned
event, the handle
method calls the limitAssigned
method with the event as parameter.
Simplifying the application
I used the requirements as code library for simplifying the code. First, I put all of the event classes and the handling methods in a model. Like this:
this.eventHandlingModel =
Model.builder()
.on(LimitAssigned.class).system(this::limitAssigned)
.on(CardWithdrawn.class).system(this::cardWithdrawn)
.on(CardRepaid.class).system(this::cardRepaid)
.on(CycleClosed.class).system(this::cycleWasClosed)
.build();
I had to change the return type of the handling methods (e.g. limitAssigned
) to void
. Apart from that, the conversion from vavr.io was straight forward.
Then, I created a runner and started it for the model:
this.modelRunner = new ModelRunner();
modelRunner.run(eventHandlingModel);
After that, I changed the recreateFrom
and handle
methods to this:
public static CreditCard recreateFrom(UUID uuid, List<DomainEvent> events) {
CreditCard creditCard = new CreditCard(uuid);
events.forEach(ev -> creditCard.handle(ev));
return creditCard;
}
private void handle(DomainEvent event) {
modelRunner.reactTo(event);
}
At that point, I could get rid of the dependency to vavr.io.
Transition complete. Now I could get some more simplifying done.
I revisited the withdraw
method:
public void withdraw(BigDecimal amount) {
if(notEnoughMoneyToWithdraw(amount)) {
throw new IllegalStateException();
}
if(tooManyWithdrawalsInCycle()) {
throw new IllegalStateException();
}
cardWithdrawn(new CardWithdrawn(uuid, amount, Instant.now()));
}
The check tooManyWithdrawalsInCycle()
didn't depend on the data of the event. It only depended on the state of the CreditCard
.
State checks like this can be represented in the model as conditions
.
After I moved all state checks for all methods to the model, it looked like this:
this.eventHandlingModel =
Model.builder()
.condition(this::limitNotAssigned)
.on(LimitAssigned.class).system(this::limitAssigned)
.condition(this::limitAlreadyAssigned)
.on(LimitAssigned.class).system(this::throwsException)
.condition(this::notTooManyWithdrawalsInCycle)
.on(CardWithdrawn.class).system(this::cardWithdrawn)
.condition(this::tooManyWithdrawalsInCycle)
.on(CardWithdrawn.class).system(this::throwsException)
.on(CardRepaid.class).system(this::cardRepaid)
.on(CycleClosed.class).system(this::cycleWasClosed)
.build();
For this to work, I needed to replace the direct calls to methods that change the state with the handle
method. After that, the assignLimit
and withdraw
methods looked like this:
public void assignLimit(BigDecimal amount) {
handle(new LimitAssigned(uuid, amount, Instant.now()));
}
private void limitAssigned(LimitAssigned event) {
this.initialLimit = event.getAmount();
pendingEvents.add(event);
}
public void withdraw(BigDecimal amount) {
if(notEnoughMoneyToWithdraw(amount)) {
throw new IllegalStateException();
}
handle(new CardWithdrawn(uuid, amount, Instant.now()));
}
private void cardWithdrawn(CardWithdrawn event) {
this.usedLimit = usedLimit.add(event.getAmount());
withdrawals++;
pendingEvents.add(event);
}
As you can see, most of the conditional logic has moved out of the methods into the model. This makes the methods easier to understand.
One thing that bothered me is that you must not forget to add the event to the pending events. Every time. Or your code won't work.
Requirements as code allows you to control how the system handles the events. So I extracted pendingEvents.add(event)
from the methods as well:
modelRunner.handleWith(this::addingPendingEvents);
...
public void addingPendingEvents(StepToBeRun stepToBeRun) {
stepToBeRun.run();
DomainEvent domainEvent = (DomainEvent) stepToBeRun.getEvent().get();
pendingEvents.add(domainEvent);
}
I could have gone further and extract the validation logic as well.
But I leave that as a thought exercise to you, dear reader.
What's the point?
What I tried to achieve is a clear separation of concerns:
- The state dependent execution of methods is defined in the model
- The data validation and state changes are in the implementations of the methods
- The events are automatically added to the pending events. In general: the infrastructure code is clearly separated from the business logic.
Simplifying an example that is already very simple is good for explaining.
But that's not the point I want to make.
The point is: having such a clear separation of concerns pays out in practice.
Especially, if you work with multiple teams. On complicated problems.
Separation of concerns helps with changing different parts of code at a different pace. You have simple rules where to find something. The code is easier to understand. And it's easier to isolate units for testing purposes.
Conclusion
I hope you enjoyed my article. Please give me feedback in the comments.
Have you been working on event sourcing applications?
What were your experiences like?
Can you relate to what I wrote in this article?
I also want to invite you to look at my library that I used throughout the article. I would be thrilled if you try it out in practice, and tell me what you think.
Edited November 21, 2018: fix one copy-paste error in example code
Posted on April 27, 2018
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.