Spring Boot Axon and CQRS

Prateek
5 min readJun 11, 2021

In this tutorials, we’ll learn how to make the use of CQRS. We would clearly explain the following and how they relate:

  1. Commands
  2. Events
  3. Aggregate (the Command Model)
  4. Command Handlers
  5. EventSourcing Handlers
  6. Entity (The Query Model)
  7. The Queries
  8. The Projection, EventHandlers and QueryHandlers

1. Commands

A command is defined as “an expression of intent to perform an operation”. A command is a class data class in kotlin or Value class in java) with fields needed to execute the command. A command are normally intended for some aggregate. The identifier in a command is annotated with @TargetAggregateIdentifier. This indicates the particular aggregate targeted by the command.

In the case of the Giftcard, there are two commands:

IssueCommand and RedeemCommand

2. Events

Events are “notifications that an event have occurred”. In the case of the Giftcard, there are two events:

IssuedEvent and RedeemedEvent

Note: each command has a corresponding event

3. Aggregate(the Command Model)

An aggregate is simply an object with states(fields) and methods to alter those states. An aggregate is annotated with @Aggregate identifier. This tells Axon that the class is an aggregate and therefore would be capable of handling commands. Also this makes the framework know that this class would publish events which would be sourced from itself.

An aggregate must have a field that represents the identifier. The aggregate is annotated with @AggregateIdentifier.

Also, an aggregate requires a NoArgConstructor.

4. Command Handlers

The command handler is a function written in the aggregate class that specifies what happens when a command is executed. A command handler is normally a void function with the name handle() except for the constructor. It takes a parameter which represents the command to be executed.

Then it is annotated with the @CommandHandler annotation.

A command may usually do some validation.

At the end, a command handler would publish an event using the AggregateLifeCycle.apply() method.

5. EventSourcing Handlers

The EventSourcing handler is a function that is called when an aggregate is sourced from its events. That means changes in the state of the aggregate occurs here. Also, the events sourcing handlers combine to build the aggregate. Therefore, the state changes is implemented in the aggregate.

the AggregateIdentifier must be set in the eventsourcing handler of the very first event that occurs in the aggregate. An eventsourcing handler is annotated with @EventSourcinHandler annotation.

Components of the command side is shown below:

CQRS with AxonFramework — Command Side of the GiftCard Demo

6. Entity (the Query Model)

Also called Summary or View. Here we now talk about the particular object we are going to return when a query is issued. This object will be an entity and therefore the class will be annotated with @Entity annotation.

In the case of the Giftcard demo, this class is called CardSummary. So this class would have an id field such that, the object can be returned when a requested using the id. I would also have other field you will need to show.

7. The Queries

Now we need to define the queries that can be sent to the query model.

In the same way you have commands associated with the command model, you also need queries associated with the query model. In the case of the GiftCard, we have two queries:

DataQuery(offset, limit): that returns a list of CardSummaries

SizeQuery: that returns the number of items

8. The Projection, EventHandlers and QueryHandlers

This is the actual component that is responsible for handling the queries to update the query model and to return it. This class should be a component. This class would project the CardSummary

This class would make use of the repository(or entityManager).

This class would contain the EventHandlers for the following:

  • IssuedEvent: Create and persist a new CardSummary
  • RedeemedEvent: Find the specific CardSummary from the repository and update it’s balance

The following QueryHandlers should also be provided:

  • Get a list of CardSummaries: this is a method with name handle(). use the entityManager createQuery() method to do a custom query
  • Get a count of CardSummaries: same here but a different query.

Components of the query side is given below:

CQRS with AxonFramework — Query Side of the GiftCard Demo

pom.xml

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.3</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>demo-complaints</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo-complaints</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-spring-boot-starter</artifactId>
<version>4.4.7</version>
</dependency>
<dependency>
<groupId>org.axonframework.extensions.amqp</groupId>
<artifactId>axon-amqp</artifactId>
<version>4.4</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.17</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</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>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

DemoComplaintsApplication.java

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoComplaintsApplication {
public static void main(String[] args) {
SpringApplication.run(DemoComplaintsApplication.class, args);
}
}

Complaint.java

package com.example.demo.command;
import org.axonframework.commandhandling.CommandHandler;
import org.axonframework.eventsourcing.EventSourcingHandler;
import org.axonframework.modelling.command.AggregateIdentifier;
import org.axonframework.modelling.command.AggregateLifecycle;
import org.axonframework.spring.stereotype.Aggregate;
import org.springframework.util.Assert;
import com.example.demo.events.ComplaintFiledEvent;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
@Aggregate
@NoArgsConstructor
@AllArgsConstructor
public class Complaint{
@AggregateIdentifier
private String compalintId;

@CommandHandler
public Complaint(FileComplaintCommand cmd) {
Assert.hasLength(cmd.getCompany(), "CompanyId must be present !");
AggregateLifecycle.apply(new ComplaintFiledEvent(cmd.getId(), cmd.getCompany(), cmd.getDescription()));
}

@EventSourcingHandler
public void on(ComplaintFiledEvent event) {
this.compalintId = event.getId();
}
}

FileComplaintCommand.java

package com.example.demo.command;
import org.axonframework.modelling.command.TargetAggregateIdentifier;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class FileComplaintCommand {
@TargetAggregateIdentifier
private String id;
private String company;
private String description;
}

ComplaintController.java

package com.example.demo.controller;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import org.axonframework.commandhandling.gateway.CommandGateway;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import com.example.demo.command.FileComplaintCommand;
import com.example.demo.query.ComplaintQueryObject;
import com.example.demo.query.repository.ComplaintQueryObjectRepository;
@RestController
public class ComplaintController {
@Autowired
private ComplaintQueryObjectRepository repository;

// Sends the given command and returns a CompletableFuture immediately, without waiting for the command to execute.
// The caller will therefore not receive any immediate feedback on the command'sexecution. Instead hooks can be
// added to the returned CompletableFuture to react on success or failure of command execution.
@Autowired
private CommandGateway commandGateway;
@PostMapping
public CompletableFuture<String> fileComplaint(@RequestBody Map<String, String> request) {
String id = UUID.randomUUID().toString();
return commandGateway.send(new FileComplaintCommand(id, request.get("company"), request.get("description")));
}
@GetMapping
public List<ComplaintQueryObject> findAll() {
return repository.findAll();
}
@GetMapping("/{id}")
public ComplaintQueryObject findById(@PathVariable String id) {
return repository.findById(id).orElse(null);
}
}

ComplaintFiledEvent.java

package com.example.demo.events;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ComplaintFiledEvent {
private String id;
private String company;
private String description;
}

ComplaintQueryObject.java

package com.example.demo.query;
import javax.persistence.Entity;
import javax.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class ComplaintQueryObject {
@Id
private String complaintId;
private String company;
private String description;
}

ComplaintQueryObjectUpdater.java

package com.example.demo.query;
import org.axonframework.eventhandling.EventHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.example.demo.events.ComplaintFiledEvent;
import com.example.demo.query.repository.ComplaintQueryObjectRepository;
@Component
public class ComplaintQueryObjectUpdater {
@Autowired
private ComplaintQueryObjectRepository repository;
@EventHandler
public void on(ComplaintFiledEvent event) {
repository.save(new ComplaintQueryObject(event.getId(), event.getCompany(), event.getDescription()));
}
}

ComplaintQueryObjectRepository.java

package com.example.demo.query.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import com.example.demo.query.ComplaintQueryObject;
public interface ComplaintQueryObjectRepository extends JpaRepository<ComplaintQueryObject, String>{
}

application.properties

#### H2 DB
#spring.h2.console.enabled=true
#spring.jpa.hibernate.ddl-auto=update
#spring.datasource.platform=h2
#spring.datasource.url=jdbc:h2:mem:mytestdb
#server.port=8081
#### MYSQL
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.password=root
spring.datasource.username=root
spring.datasource.url=jdbc:mysql://localhost:3306/test?serverTimezone=UTC
spring.jpa.hibernate.ddl-auto=create-drop
spring.datasource.platform=MYSQL

Make sure to start the Axon Server

C:\Softwares\AXON\AxonServer-4.4.9>java -jar axonserver.jar

Make the CURL command

curl --location --request POST 'http://localhost:8080' \
--header 'Content-Type: application/json' \
--data-raw '{
"company" : "Apple",
"description" : "Nice Features"
}'

Then execute — http://localhost:8080/

[{"complaintId":"0d77395d-03b1-415d-96c7-669b3f83710d","company":"Apple","description":"Nice Features"},{"complaintId":"572ba704-f89e-4cbf-a362-2cd876e26444","company":"Apple","description":"Nice Features"},{"complaintId":"b61acea3-8235-46cd-8287-1120d53a22a2","company":"Apple","description":"Nice Features"},{"complaintId":"d0cf8f9d-6953-44a9-982a-961336a2cf66","company":"Apple","description":"Nice Features"}]

--

--