Event-Driven Microservices with Axon Framework: Mastering CQRS and Async Persistence

A comprehensive guide to building event-driven microservices using AxonIQ Framework, implementing CQRS patterns with async JPA persistence for scalable, loosely-coupled architectures.

GT
Gonnect Team
January 14, 202412 min readView on GitHub
Axon FrameworkSpring BootCQRSEvent SourcingJPAEclipseLink

Introduction

In the evolving landscape of distributed systems, event-driven microservices have emerged as a powerful paradigm for building scalable, loosely-coupled applications. At the heart of this architecture lies the CQRS (Command Query Responsibility Segregation) pattern, which fundamentally changes how we think about data access and state management.

This article explores a production-ready implementation using the AxonIQ Framework - a battle-tested toolkit for building event-driven applications in Java. We will demonstrate how to separate read and write models, implement async persistence, and achieve location transparency through event-based module interactions.

Key Insight: The starting point of CQRS design is differentiating between READ and WRITE models. This separation enables independent scaling and optimization of each side.

Understanding the Core Concepts

Event-Driven Architecture vs CQRS

Before diving into implementation, it is crucial to understand that Event-Driven Architecture is not CQRS, though they complement each other exceptionally well.

Event-Driven Architecture

Loading diagram...
ConceptDescription
Event-DrivenArchitecture where components communicate through events
CQRSPattern that separates read and write operations into different models
Event SourcingStoring state changes as a sequence of events

Events vs Messages

A common misconception is conflating events with messages:

AspectEventMessage
NatureFact about something that happenedContainer for data transfer
ImmutabilityAlways immutableMay be mutable
TemporalPast tense (OrderPlaced)Imperative or neutral
DeletionNever physically deletedCan be deleted

Important: Events are never physically deleted. Deletions become new events, enabling full audit trails and analytics capabilities.

Architecture Overview

The architecture separates concerns into distinct layers with event-based communication:

Microservices Architecture

Loading diagram...
┌─────────────────────────────────────────────────────────────────┐
│                      API Gateway (REST)                         │
│                    Swagger UI @ :8888                           │
└─────────────────────────────┬───────────────────────────────────┘
                              │
              ┌───────────────┴───────────────┐
              │                               │
              ▼                               ▼
┌─────────────────────────┐     ┌─────────────────────────┐
│     Command Side        │     │      Query Side         │
│   (Write Model)         │     │    (Read Model)         │
├─────────────────────────┤     ├─────────────────────────┤
│  - Command Handlers     │     │  - Query Handlers       │
│  - Aggregate Roots      │     │  - Projections          │
│  - Domain Validation    │     │  - Materialized Views   │
└───────────┬─────────────┘     └─────────────┬───────────┘
            │                                 │
            ▼                                 ▼
┌─────────────────────────────────────────────────────────────────┐
│                   Axon Event Store                              │
│              (Embedded AxonIQ Database)                         │
└─────────────────────────────────────────────────────────────────┘
            │
            ▼
┌─────────────────────────────────────────────────────────────────┐
│              Async JPA Persistence Layer                        │
│              (EclipseLink + Derby DB)                           │
└─────────────────────────────────────────────────────────────────┘

Technology Stack

The implementation leverages a robust set of technologies:

TechnologyPurpose
Spring Boot 2Application framework and dependency injection
Axon FrameworkCQRS and Event Sourcing infrastructure
EclipseLink JPAAsync persistence layer
Derby DatabaseEmbedded database for storage
Swagger UIAPI documentation and testing
KotlinEnhanced domain modeling (7.6% of codebase)

Implementation Deep Dive

Domain Model Design

The architecture maintains clear separation between domain objects and entities:

// Command - Request for state change
public class CreateOrderCommand {
    @TargetAggregateIdentifier
    private final String orderId;
    private final String customerId;
    private final List<OrderItem> items;

    public CreateOrderCommand(String orderId, String customerId,
                              List<OrderItem> items) {
        this.orderId = orderId;
        this.customerId = customerId;
        this.items = items;
    }

    // Getters...
}

// Event - Fact about what happened
public class OrderCreatedEvent {
    private final String orderId;
    private final String customerId;
    private final List<OrderItem> items;
    private final Instant timestamp;

    public OrderCreatedEvent(String orderId, String customerId,
                             List<OrderItem> items) {
        this.orderId = orderId;
        this.customerId = customerId;
        this.items = items;
        this.timestamp = Instant.now();
    }

    // Getters...
}

Aggregate Root Implementation

The Aggregate Root handles commands and produces events:

@Aggregate
public class OrderAggregate {

    @AggregateIdentifier
    private String orderId;
    private OrderStatus status;
    private List<OrderItem> items;

    // Required by Axon
    protected OrderAggregate() {}

    @CommandHandler
    public OrderAggregate(CreateOrderCommand command) {
        // Validate business rules
        validateItems(command.getItems());

        // Apply the event (this persists and notifies)
        AggregateLifecycle.apply(
            new OrderCreatedEvent(
                command.getOrderId(),
                command.getCustomerId(),
                command.getItems()
            )
        );
    }

    @EventSourcingHandler
    public void on(OrderCreatedEvent event) {
        // Update aggregate state from event
        this.orderId = event.getOrderId();
        this.status = OrderStatus.CREATED;
        this.items = event.getItems();
    }

    @CommandHandler
    public void handle(ConfirmOrderCommand command) {
        if (status != OrderStatus.CREATED) {
            throw new IllegalStateException(
                "Order cannot be confirmed in status: " + status);
        }
        AggregateLifecycle.apply(
            new OrderConfirmedEvent(orderId)
        );
    }

    @EventSourcingHandler
    public void on(OrderConfirmedEvent event) {
        this.status = OrderStatus.CONFIRMED;
    }

    private void validateItems(List<OrderItem> items) {
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException(
                "Order must contain at least one item");
        }
    }
}

Query Side Projections

Projections build optimized read models from events:

@Component
@ProcessingGroup("order-projections")
public class OrderProjection {

    private final OrderViewRepository repository;

    public OrderProjection(OrderViewRepository repository) {
        this.repository = repository;
    }

    @EventHandler
    public void on(OrderCreatedEvent event) {
        OrderView view = new OrderView();
        view.setOrderId(event.getOrderId());
        view.setCustomerId(event.getCustomerId());
        view.setStatus("CREATED");
        view.setItemCount(event.getItems().size());
        view.setCreatedAt(event.getTimestamp());

        repository.save(view);
    }

    @EventHandler
    public void on(OrderConfirmedEvent event) {
        repository.findById(event.getOrderId())
            .ifPresent(view -> {
                view.setStatus("CONFIRMED");
                view.setConfirmedAt(Instant.now());
                repository.save(view);
            });
    }

    @QueryHandler
    public List<OrderView> handle(FindOrdersByCustomerQuery query) {
        return repository.findByCustomerId(query.getCustomerId());
    }

    @QueryHandler
    public Optional<OrderView> handle(FindOrderByIdQuery query) {
        return repository.findById(query.getOrderId());
    }
}

Async JPA Persistence Configuration

The implementation uses a custom async JPA layer with EclipseLink:

@Configuration
@EnableJpaRepositories
public class PersistenceConfig {

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(
            DataSource dataSource) {

        LocalContainerEntityManagerFactoryBean em =
            new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan("com.gonnect.order");
        em.setJpaVendorAdapter(new EclipseLinkJpaVendorAdapter());

        Properties properties = new Properties();
        properties.setProperty(
            "eclipselink.weaving", "false");
        properties.setProperty(
            "eclipselink.logging.level", "FINE");
        // Enable async persistence
        properties.setProperty(
            "eclipselink.persistence-context.flush-mode", "COMMIT");

        em.setJpaProperties(properties);
        return em;
    }

    @Bean
    public PlatformTransactionManager transactionManager(
            EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }
}

REST API with Swagger

The application exposes REST endpoints documented via Swagger:

@RestController
@RequestMapping("/api/orders")
@Api(tags = "Order Management")
public class OrderController {

    private final CommandGateway commandGateway;
    private final QueryGateway queryGateway;

    public OrderController(CommandGateway commandGateway,
                          QueryGateway queryGateway) {
        this.commandGateway = commandGateway;
        this.queryGateway = queryGateway;
    }

    @PostMapping
    @ApiOperation("Create a new order")
    public CompletableFuture<String> createOrder(
            @RequestBody CreateOrderRequest request) {

        String orderId = UUID.randomUUID().toString();

        return commandGateway.send(
            new CreateOrderCommand(
                orderId,
                request.getCustomerId(),
                request.getItems()
            )
        );
    }

    @GetMapping("/{orderId}")
    @ApiOperation("Get order by ID")
    public CompletableFuture<OrderView> getOrder(
            @PathVariable String orderId) {

        return queryGateway.query(
            new FindOrderByIdQuery(orderId),
            ResponseTypes.optionalInstanceOf(OrderView.class)
        ).thenApply(opt -> opt.orElseThrow(
            () -> new OrderNotFoundException(orderId)));
    }

    @GetMapping("/customer/{customerId}")
    @ApiOperation("Get orders by customer")
    public CompletableFuture<List<OrderView>> getCustomerOrders(
            @PathVariable String customerId) {

        return queryGateway.query(
            new FindOrdersByCustomerQuery(customerId),
            ResponseTypes.multipleInstancesOf(OrderView.class)
        );
    }
}

Running the Application

Prerequisites

  1. Clone and install the jpa-eclipselink dependency locally
  2. Java 8 or higher
  3. Maven 3.x

Build and Run

# Clone the repository
git clone https://github.com/mgorav/EventDrivenMicroServiceUsingAxonIQ.git
cd EventDrivenMicroServiceUsingAxonIQ

# Install the custom JPA library
cd jpa-eclipselink
mvn clean install

# Build the microservice
cd ..
mvn clean install

# Run the application
java -jar target/event-driven-microservice.jar

Access Points

Testing the API

# Create an order
curl -X POST http://localhost:8888/api/orders \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "CUST-001",
    "items": [
      {"productId": "PROD-001", "quantity": 2, "price": 29.99}
    ]
  }'

# Query the order
curl http://localhost:8888/api/orders/{orderId}

# Get customer orders
curl http://localhost:8888/api/orders/customer/CUST-001

Cloud Deployment

The project includes PCF (Pivotal Cloud Foundry) deployment configuration:

# manifest.yml
applications:
  - name: event-driven-service
    memory: 1G
    instances: 1
    path: target/event-driven-microservice.jar
    buildpack: java_buildpack
    env:
      SPRING_PROFILES_ACTIVE: cloud
# Deploy to PCF
cf push

Benefits and Trade-offs

Benefits

BenefitDescription
ScalabilityRead and write sides scale independently
FlexibilityMultiple read models optimized for different queries
Audit TrailComplete history through event sourcing
Loose CouplingModules communicate via events, not direct calls
Location TransparencyServices can be distributed across the network

Trade-offs

ChallengeMitigation
Eventual ConsistencyDesign for async; use read-your-writes where critical
ComplexityInvest in tooling and team training
Event Schema EvolutionUse schema registry; design for forward compatibility
DebuggingImplement comprehensive logging and tracing

Conclusion

The Axon Framework provides a robust foundation for building event-driven microservices with CQRS. By separating command and query responsibilities, implementing async persistence, and leveraging event-based communication, organizations can build systems that are:

  • Scalable: Handle varying read and write loads independently
  • Resilient: Recover from failures through event replay
  • Evolvable: Add new read models without changing the command side
  • Auditable: Maintain complete history for compliance and analytics

The EventDrivenMicroServiceUsingAxonIQ repository provides a complete reference implementation that you can use as a starting point for your own event-driven architecture journey.


Further Reading