Event Sourcing with Spring Boot and Axon
Recently I’ve read a few articles and saw some talks about event sourcing. About the same time the first version of Axon 3.0 with support for Spring Boot Autoconfiguration was released and I decided to give it a try.
The basic idea of event sourcing is to store every change in the state of an application as events instead of only storing the current state. The current state can be constructed by applying all past events. In this blog post I want to give an example on how to implement an event sourcing application with the Axon Framework and Spring Boot.
Axon is a framework that helps developers to create such applications by providing the most important building blocks. With Axon you manipulate domain objects (called aggregate) with commands which will lead to events. A command is an intend to change aggregates. It contains all the necessary information to execute it. Each command is handled by a Command Handler. It verifies the command and executes a method on the aggregate to change its state. A command can also be rejected. The change of the aggregate will lead to events. An event is a change of the aggregate that has already happened. You cannot change the past, therefore you should not validate or reject events. Only the events are persisted in the event store. The state of the aggregate is volatile, but can always be reconstructed from the event store.
To show it by example, let’s think of a simple banking application. It will allow the following actions: You can create a new account, withdraw or deposit money and finally close your account. To keep this example simple we will only withdraw or deposit money from one account and will not transfer money between two accounts. All actions apply to the bank account, therefore this is our aggregate.
Note: You can find the complete source code on GitHub: https://github.com/jd1/spring-boot-axon
Project setup
We will start with a new project from https://start.spring.io/ with two dependencies:
- spring-boot-starter-web
- spring-boot-starter-data-mongodb
Then add the Axon dependencies(axon-spring-boot-starter, axon-mongo) manually to the pom.xml. The pom.xml should look similar to this one:
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 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>info.novatec.axon</groupId> <artifactId>banking</artifactId> <version>0.0.1-SNAPSHOT</version> <packaging>jar</packaging> <name>banking</name> <description>Demo project for Axon</description> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>1.5.3.RELEASE</version> <relativePath /> </parent> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-mongodb</artifactId> </dependency> <dependency> <groupId>org.axonframework</groupId> <artifactId>axon-spring-boot-starter</artifactId> <version>3.0.4</version> </dependency> <dependency> <groupId>org.axonframework</groupId> <artifactId>axon-mongo</artifactId> <version>3.0.4</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> |
Aggregates, Commands and Events
With these basic project setup we can start to create our aggregate, commands and events:
To allow Axon to identify the bank account as an aggregate, the class must be annotated with @Aggregate and contain a field with the @AggregateIdentifier annotation. This is the identifier of the aggregate. Our bank account aggregate will look like this:
1 2 3 4 5 6 7 8 9 10 11 12 |
@Aggregate public class BankAccount implements Serializable { private static final long serialVersionUID = 1L; @AggregateIdentifier private String id; private double balance; private String owner; } |
As mentioned previously, our bank account will support four actions/commands. In general one command can result in one or more events, but to keep it simple each command will only produce one event:
Command | Event | Description |
---|---|---|
CreateAccountCommand | AccountCreatedEvent | Create a new account |
DepositMoneyCommand | MoneyDepositedEvent | Deposit money to an bank account |
WithdrawMoneyCommand | MoneyWithdrawnEvent | Withdraw money from a bank account |
CloseAccountCommand | AccountClosedEvent | Close an existing account |
Sending commands
A command must contain all the information that is necessary for a command handler to execute it, but at least contain the id of the aggregate that should be updated. The CreateAccountCommand contains the account id and the name of the account creator. The annotation @TargetAggregateIdentifier must be placed on a field or method that contains the identifier of the aggregate.
1 2 3 4 5 6 7 8 9 10 |
public class CreateAccountCommand { @TargetAggregateIdentifier public final String id; public final String accountCreator; public CreateAccountCommand(String id, String accountCreator) { this.id = id; this.accountCreator = accountCreator; } } |
The other three commands look similar:
1 2 3 4 5 6 7 8 9 10 |
public class DepositMoneyCommand { @TargetAggregateIdentifier public final String id; public final double amount; public DepositMoneyCommand(String id, double amount) { this.id = id; this.amount = amount; } } |
1 2 3 4 5 6 7 8 9 10 |
public class WithdrawMoneyCommand { @TargetAggregateIdentifier public final String id; public final double amount; public WithdrawMoneyCommand(String id, double amount) { this.id = id; this.amount = amount; } } |
1 2 3 4 5 6 7 8 |
public class CloseAccountCommand { @TargetAggregateIdentifier public final String id; public CloseAccountCommand(String id) { this.id = id; } } |
To make the banking application accessible to the rest of the world I created a REST interface that will create commands and publish them via the CommandGateway to the command bus.
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 41 42 43 44 45 46 |
@RequestMapping("/accounts") @RestController public class AccountApi { private final CommandGateway commandGateway; public AccountApi(CommandGateway commandGateway) { this.commandGateway = commandGateway; } @PostMapping public CompletableFuture<String> createAccount(@RequestBody AccountOwner user) { String id = UUID.randomUUID().toString(); return commandGateway.send(new CreateAccountCommand(id, user.name)); } static class AccountOwner { public String name; } @PutMapping(path = "{accountId}/balance") public CompletableFuture<String> deposit(@RequestBody double amount, @PathVariable String accountId) { if (amount > 0) { return commandGateway.send(new DepositMoneyCommand(accountId, amount)); } else { return commandGateway.send(new WithdrawMoneyCommand(accountId, -amount)); } } @DeleteMapping("{id}") public CompletableFuture<String> delete(@PathVariable String id) { return commandGateway.send(new CloseAccountCommand(id)); } @ExceptionHandler(AggregateNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public void notFound() { } @ExceptionHandler(BankAccount.InsufficientBalanceException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public String insufficientBalance(BankAccount.InsufficientBalanceException exception) { return exception.getMessage(); } } |
Deposit and withdraw money is nearly the same(only with negative sign), but I separated this in two commands, because the handling will be different.
The exception handler maps a AggregateNotFoundException to an HTTP status code 404 if a command cannot be dispatched to an aggregate. If an account does not contain enough money to perform an operation, an InsufficientBalanceException will be thrown which is also handled.
Reacting to commands
The command bus distributes commands to event handlers. A command handler is a method with the annotation @CommandHandler that accepts one command as a parameter. In this method the command can be validated and an event can be publish as a reaction to the state change(via AggregateLifecycle.apply()). If the @CommandHandler annotation is placed inside an aggregate, the correct aggregate is chosen by the @TargetAggregateIdentifier of the command and the @AggregateIdentifier of the aggregate.
CreateAccountCommand
To create a new aggregate the annotation @CommandHandler can be placed on the constructor:
1 2 3 4 5 6 7 8 9 10 |
@CommandHandler public BankAccount(CreateAccountCommand command) { String id = command.id; String creator = command.accountCreator; Assert.hasLength(id, "Missing id"); Assert.hasLength(creator, "Missing account creator"); AggregateLifecycle.apply(new AccountCreatedEvent(id, creator, 0)); } |
With this constructor an CreateAccountCommand will create an new instance of BankAccount. The constructor verifies that the command has an id and an account creator. If the command is valid, an AccountCreatedEvent will be published with the account id, owner and an initial balance of 0.
1 2 3 4 5 6 7 8 9 10 11 |
public class AccountCreatedEvent { public final String id; public final String accountCreator; public final double balance; public AccountCreatedEvent(String id, String accountCreator, double balance) { this.id = id; this.accountCreator = accountCreator; this.balance = balance; } } |
To create event sourced aggregates all changes of the aggregate are described as events and state changes are only performed in @EventSourcingHandler annotated methods. This methods will also be called when the aggregate’s state is reconstructed from the events in the event store.
To react to the AccountCreatedEvent we will add a new method to BankAccount:
1 2 3 4 5 6 |
@EventSourcingHandler protected void on(AccountCreatedEvent event) { this.id = event.id; this.owner = event.accountCreator; this.balance = event.balance; } |
DepositMoneyCommand
It is always possible to deposit money to a bank account, therefore no special validation is necessary for the DepositMoneyCommand. It is only verified that the amount of money is positive:
1 2 3 4 5 6 7 |
@CommandHandler protected void on(DepositMoneyCommand command) { double amount = command.amount; Assert.isTrue(amount > 0.0, "Deposit must be a positiv number."); AggregateLifecycle.apply(new MoneyDepositedEvent(id, amount)); } |
1 2 3 4 5 6 7 8 9 |
public class MoneyDepositedEvent { public final String id; public final double amount; public MoneyDepositedEvent(String id, double amount) { this.id = id; this.amount = amount; } } |
In the event handler the new account balance is calculated:
1 2 3 4 |
@EventSourcingHandler protected void on(MoneyDepositedEvent event) { this.balance += event.amount; } |
WithdrawMoneyCommand
It is only possible to withdraw money if there is enough money in the account. Otherwise an InsufficientBalanceException will be thrown:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
@CommandHandler protected void on(WithdrawMoneyCommand command) { double amount = command.amount; Assert.isTrue(amount > 0.0, "Withdraw must be a positiv number."); if(balance - amount < 0) { throw new InsufficientBalanceException("Insufficient balance."); } AggregateLifecycle.apply(new MoneyWithdrawnEvent(id, amount)); } public static class InsufficientBalanceException extends RuntimeException { InsufficientBalanceException(String message) { super(message); } } |
1 2 3 4 5 6 7 8 9 |
public class MoneyWithdrawnEvent { public final String id; public final double amount; public MoneyWithdrawnEvent(String id, double amount) { this.id = id; this.amount = amount; } } |
And similar to the MoneyDepositedEvent the new balance is calculated:
1 2 3 4 |
@EventSourcingHandler protected void on(MoneyWithdrawnEvent event) { this.balance -= event.amount; } |
CloseAccountCommand
The CloseAccountCommand is special because it marks the aggregate as delete in the event store. Deleted aggregates still exist, but cannot be modified.
1 2 3 4 5 6 7 8 9 |
@CommandHandler protected void on(CloseAccountCommand command) { AggregateLifecycle.apply(new AccountClosedEvent(id)); } @EventSourcingHandler protected void on(AccountClosedEvent event) { AggregateLifecycle.markDeleted(); } |
1 2 3 4 5 6 |
public class AccountClosedEvent { public final String id; public AccountClosedEvent(String id) { this.id = id; } } |
This is the final BankAccount class:
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 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
package info.novatec.axon.account; import java.io.Serializable; import org.axonframework.commandhandling.CommandHandler; import org.axonframework.commandhandling.model.AggregateIdentifier; import org.axonframework.commandhandling.model.AggregateLifecycle; import org.axonframework.eventsourcing.EventSourcingHandler; import org.axonframework.spring.stereotype.Aggregate; import org.springframework.util.Assert; import info.novatec.axon.account.command.CloseAccountCommand; import info.novatec.axon.account.command.CreateAccountCommand; import info.novatec.axon.account.command.DepositMoneyCommand; import info.novatec.axon.account.command.WithdrawMoneyCommand; import info.novatec.axon.account.event.AccountClosedEvent; import info.novatec.axon.account.event.AccountCreatedEvent; import info.novatec.axon.account.event.MoneyDepositedEvent; import info.novatec.axon.account.event.MoneyWithdrawnEvent; @Aggregate public class BankAccount implements Serializable { private static final long serialVersionUID = 1L; @AggregateIdentifier private String id; private double balance; private String owner; @CommandHandler public BankAccount(CreateAccountCommand command) { String id = command.id; String name = command.accountCreator; Assert.hasLength(id, "Missin id"); Assert.hasLength(name, "Missig account creator"); AggregateLifecycle.apply(new AccountCreatedEvent(id, name, 0)); } public BankAccount() { // constructor needed for reconstruction } @EventSourcingHandler protected void on(AccountCreatedEvent event) { this.id = event.id; this.owner = event.accountCreator; this.balance = event.balance; } @CommandHandler protected void on(CloseAccountCommand command) { AggregateLifecycle.apply(new AccountClosedEvent(id)); } @EventSourcingHandler protected void on(AccountClosedEvent event) { AggregateLifecycle.markDeleted(); } @CommandHandler protected void on(DepositMoneyCommand command) { double amount = command.amount; Assert.isTrue(amount > 0.0, "Deposit must be a positiv number."); AggregateLifecycle.apply(new MoneyDepositedEvent(id, amount)); } @EventSourcingHandler protected void on(MoneyDepositedEvent event) { this.balance += event.amount; } @CommandHandler protected void on(WithdrawMoneyCommand command) { double amount = command.amount; Assert.isTrue(amount > 0.0, "Withdraw must be a positiv number."); if(balance - amount < 0) { throw new InsufficientBalanceException("Insufficient balance."); } AggregateLifecycle.apply(new MoneyWithdrawnEvent(id, amount)); } public static class InsufficientBalanceException extends RuntimeException { InsufficientBalanceException(String message) { super(message); } } @EventSourcingHandler protected void on(MoneyWithdrawnEvent event) { this.balance -= event.amount; } } |
Event Store
There is still one thing left to use our banking application: We have store the events into a database which is called event store. Axon ships with different EventStorageEngines:
- InMemoryEventStorageEngine
- JdbcEventStorageEngine
- JpaEventStorageEngine
- MongoEventStorageEngine
To configure an event store all you have to do is to create a bean of type EventStorageEngine and Axon will use it to store events. JpaEventStorageEngine is even configured automatically when a bean of type EntityManagerFactory is found.
In this example I will use MongoDB as an event store. The connection to the MongoDB is configured in the known spring way: https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-nosql.html#boot-features-connecting-to-mongodb Spring provides a MongoClient which then can be used to create a MongoEventStorageEngine:
1 2 3 4 |
@Bean public EventStorageEngine eventStore(MongoClient client) { return new MongoEventStorageEngine(new DefaultMongoTemplate(client)); } |
Calling the API
With the database configured we are now able to call our REST API to verify that the application works as expected. First, let’s create a new account:
1 |
curl --data {\"name\":\"Paul\"} -H "Content-Type: application/json" localhost:8080/accounts |
The response is the account id, e.g. a9fb4b34-1852-4a9a-b81b-1f0d144c67fa.
With the new account you can deposit money:
1 |
curl -X PUT --data 10 -H "Content-Type: application/json" localhost:8080/accounts/a9fb4b34-1852-4a9a-b81b-1f0d144c67fa/balance |
and withdraw money:
1 |
curl -X PUT --data -10 -H "Content-Type: application/json" localhost:8080/accounts/a9fb4b34-1852-4a9a-b81b-1f0d144c67fa/balance |
Both requests will only return HTTP Status 202 and no body. At the moment there is no way to request the current balance of an account.
It is also possible to verify that you cannot overdraw the account:
1 |
curl -X PUT --data -1000 -H "Content-Type: application/json" localhost:8080/accounts/a9fb4b34-1852-4a9a-b81b-1f0d144c67fa/balance |
This will result in error with the message: Insufficient balance.
And finally you can close the bank account:
1 |
curl -X DELETE localhost:8080/accounts/a9fb4b34-1852-4a9a-b81b-1f0d144c67fa |
Each further call for this account will be answered with HTTP status 404.
Conclusion
Finally, I can say that it is really easy and fun to create event sourcing applications with Axon. To start with a simple application you have to create aggregates, commands and events and bring them together. You can focus on the business code and Axon will handle the technical details. In combination with Spring Boot it is even easier because the Axon Spring Boot Starter configures the infrastructure and you can use it in a familiar environment with Spring Boot.
Update 2017-10-18: The Class AccountApi contained an error: The deposit and delete methods must return CompletableFuture<String> instead of CompletableFuture.
Update 2018-03-27: You can now see the complete project on GitHub: https://github.com/jd1/spring-boot-axon
Comment article
Recent posts






Comments
Johannes Dilli
Thanks for your feedback. I added a link to GitHub so you can see the complete project.
Cal
Great article, though I must say it’s hard to follow your code without the full class being shown here.
Gustavo
It is possible to share the complete project?
Johannes Dilli
Hi Harish,
Thank you for pointing out the error. The Class AccountApi contained an error: The deposit and delete methods must return CompletableFuture instead of CompletableFuture.
I update the code and I should work now. Let me know if you have further questions.
Harish
Hi,
Excellent article, I followed along as described, however when I execute Deposit API. I received exception as below
{“timestamp”:1508072250923,”status”:500,”error”:”Internal Server Error”,”exception”:”java.lang.NullPointerException”,”message”:”No message available”,”path”:”/accounts/
-56a758e20293/balance”}