Axon Framework | Java | Spring Boot

You have a working Spring Boot CRUD application. It does the job. Customers are created, read, updated, and deleted. The database is normalized. Then the requirements grow. Operations wants a complete audit trail. The new restaurant service needs customer data, but calling the customer API synchronously feels fragile.

You have heard about Event Sourcing and CQRS. The concepts sound powerful, but the leap from @Service + @Repository to aggregates, event buses, and projectors feels overwhelming. A rewrite is out of the question. A gradual migration sounds impossible.

It is not. This article walks through an incremental migration from a traditional Spring Boot CRUD application to an event-driven, CQRS/ES architecture using Axon Framework. Step by step, with real code, starting from where you are right now.

We will use a food ordering system as our example. We begin with a single customer service backed by MySQL. By the end, we have three microservices communicating through events for data synchronization, each with its own database. Synchronous calls still have their place—validation, payment, and inventory checks—but the data-sharing backbone becomes event-driven.

TL;DR

Step-by-step migration from a traditional Spring Boot CRUD app to CQRS/Event Sourcing with Axon Framework.

Starting point: Single customer service with Controller → Service → Repository → MySQL.

Migration steps (each is reversible):

  1. Add Axon dependency to pom.xml — nothing changes yet
  2. Extract shared events into a separate module (axon-event-commons)
  3. Replace service layer with command model — aggregate + command handlers
  4. Add a projector for the read model — @EventHandler updates the DB, @QueryHandler serves reads
  5. Add cross-service event consumption — food-ordering-service listens to the same events, writes to its own MongoDB

End result: 3 microservices, 3 databases, event-driven data sync replacing most inter-service REST calls. Synchronous calls stay for validation/payment/inventory.

Key insight: You can stop at any step and have a working system.

What Is Axon Framework?

Axon is a Java framework that implements CQRS and Event Sourcing out of the box. Instead of writing the infrastructure yourself, you annotate your classes and Axon wires the rest.

The core concepts you will encounter:

  • Commands — an intent to change state (e.g., AddCustomerCommand)
  • Aggregates — command-processing entities that emit events
  • Events — immutable facts recording what happened (e.g., CustomerAddedEvent)
  • Projectors — event handlers that update your read-optimized database
  • Queries — a request for data (e.g., GetCustomersQuery)
  • Axon Server — the runtime that routes messages and stores events

You do not need to master all of these at once. The migration introduces them one at a time.

Where We Start: A Traditional CRUD Customer Service

Our starting point is a conventional Spring Boot application. The customer service has a REST controller, a service layer, a JPA repository, and MySQL.

@RestController
@RequestMapping("/api/customers")
public class CustomerController {

    private final CustomerService customerService;

    @PostMapping
    public Customer create(@RequestBody @Valid AddCustomerRequest request) {
        return customerService.create(request);
    }

    @GetMapping
    public List<Customer> list() {
        return customerService.findAll();
    }
}

The service handles the business logic:

@Service
public class CustomerService {

    private final CustomerRepository customerRepository;

    public Customer create(AddCustomerRequest request) {
        Customer customer = new Customer();
        customer.setName(request.name());
        customer.setAddress(request.address());
        return customerRepository.save(customer);
    }

    public List<Customer> findAll() {
        return customerRepository.findAll();
    }
}

And the repository:

public interface CustomerRepository extends JpaRepository<Customer, String> {
}

One entity, one database, one flow (controller → service → repository). Clean, familiar, maintainable.

None

The Migration Steps

All the steps below keep your existing code working as you go. You can stop at any point and have a working system.

Add Axon Dependencies

Add Axon to your pom.xml:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.axonframework</groupId>
            <artifactId>axon-bom</artifactId>
            <version>4.13.1</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.axonframework</groupId>
        <artifactId>axon-spring-boot-starter</artifactId>
    </dependency>
</dependencies>

Configure the connection:

axon.axonserver.servers=${AXON_SERVER_HOST:localhost}:${AXON_SERVER_PORT:8124}

At this point, nothing has changed in your application. Axon is on the classpath, but your code still runs as before.

Extract Shared Events into a Separate Module

Events are a contract between services. They belong in a shared module that every service depends on.

Create a Maven module called axon-event-commons and define your first event:

public class CustomerAddedEvent implements CustomerEvent {

    private String id;
    private String name;
    private String address;

    public CustomerAddedEvent() {}

    public CustomerAddedEvent(String id, String name, String address) {
        this.id = id;
        this.name = name;
        this.address = address;
    }
}

Add CustomerUpdatedEvent and CustomerDeletedEvent following the same pattern. These three events represent the entire lifecycle of a customer.

Add the shared module as a dependency:

<dependency>
    <groupId>com.ivanfranchin</groupId>
    <artifactId>axon-event-commons</artifactId>
    <version>1.0.0</version>
</dependency>

Replace the Service Layer with a Command Model

Instead of CustomerService.create(), we send a command.

@Data
@AllArgsConstructor
@NoArgsConstructor
public class AddCustomerCommand {

    @TargetAggregateIdentifier private String id;
    private String name;
    private String address;
}

Next, create the aggregate:

@NoArgsConstructor
@Aggregate
public class CustomerAggregate {

    @AggregateIdentifier private String id;
    private String name;
    private String address;

    @CommandHandler
    public CustomerAggregate(AddCustomerCommand command) {
        AggregateLifecycle.apply(
            new CustomerAddedEvent(command.getId(), command.getName(), command.getAddress()));
    }

    @EventSourcingHandler
    public void handle(CustomerAddedEvent event) {
        this.id = event.getId();
        this.name = event.getName();
        this.address = event.getAddress();
    }
}

The aggregate's state is never written to a database directly. It is rebuilt from the event stream every time it is loaded. The @EventSourcingHandler methods are like setter methods, but they derive state from history instead of a table row.

Aggregates also enforce business rules. The command handler checks that the operation is valid before calling AggregateLifecycle.apply(). If the rule is violated, the handler throws an exception, and no event is stored.

Now update the controller:

@RestController
@RequestMapping("/api/customers")
public class CustomerController {

    private final CommandGateway commandGateway;

    @PostMapping
    public CompletableFuture<String> create(@RequestBody @Valid AddCustomerRequest request) {
        return commandGateway.send(
            new AddCustomerCommand(UUID.randomUUID().toString(), request.name(), request.address()));
    }
}

The return type is now CompletableFuture<String> — the aggregate ID, not the saved entity. The controller sends a command and returns a future. The command itself may still execute synchronously inside the aggregate, but the controller is no longer coupled to the write path.

At this point, CustomerService is no longer needed for writes. Keep it for queries or move to the next step.

Add a Projector for the Read Model

Commands handle writes. A projector stores data in a queryable database.

@Service
public class CustomerRepositoryProjector {

    private final CustomerRepository customerRepository;

    @EventHandler
    public void handle(CustomerAddedEvent event) {
        Customer customer = new Customer();
        customer.setId(event.getId());
        customer.setName(event.getName());
        customer.setAddress(event.getAddress());
        customerRepository.save(customer);
    }

    @QueryHandler
    public List<Customer> handle(GetCustomersQuery query) {
        return customerRepository.findAll();
    }

    @QueryHandler
    public Customer handle(GetCustomerQuery query) {
        return customerRepository.findById(query.getId())
            .orElseThrow(() -> new CustomerNotFoundException(query.getId()));
    }
}

The @EventHandler methods listen for events and update the database. The @QueryHandler methods serve queries from the same database. The Customer entity stays the same JPA entity—it is just now populated through events.

Replay safety. Projections can be rebuilt by replaying the event stream. Design handlers to handle the same event twice without causing duplicates. JPA's save() is effectively an upsert when IDs match, and the CustomerUpdatedEvent/CustomerDeletedEvent handlers use findById().ifPresent(), which is naturally safe on replay.

Update the controller to query through QueryGateway:

@GetMapping
public CompletableFuture<List<CustomerResponse>> list() {
    return queryGateway
        .query(new GetCustomersQuery(), ResponseTypes.multipleInstancesOf(Customer.class))
        .thenApply(customers -> customers.stream().map(CustomerResponse::from).toList());
}

The service layer is now gone. The controller sends commands for writes and queries for reads.

None

Add Cross-Service Event Consumption

A second service, food-ordering-service, needs customer data. Instead of calling the customer API, it listens for events.

@Service
public class CustomerRepositoryProjector {

    private final CustomerRepository customerRepository;

    @EventHandler
    public void handle(CustomerAddedEvent event) {
        Customer customer = new Customer();
        customer.setId(event.getId());
        customer.setName(event.getName());
        customer.setAddress(event.getAddress());
        customerRepository.save(customer);
    }
}

This projector writes to MongoDB. The food-ordering service has its own customer data without ever calling the customer service API. The customer service does not need to be reachable for reads—the data arrives through events.

The same pattern works for updates and deletes:

@EventHandler
public void handle(CustomerUpdatedEvent event) {
    customerRepository.findById(event.getId()).ifPresent(c -> {
        c.setName(event.getName());
        c.setAddress(event.getAddress());
        customerRepository.save(c);
    });
}

@EventHandler
public void handle(CustomerDeletedEvent event) {
    customerRepository.findById(event.getId()).ifPresent(customerRepository::delete);
}

Synchronous REST calls still make sense for things like payment validation or inventory checks. Event-driven data sync removes the read-time coupling between services—it does not replace every API call.

Add Order Aggregates and Cross-Service Order Events

Orders show how events carry enough data for multiple consumers without RPC calls.

@NoArgsConstructor
@Aggregate
public class OrderAggregate {

    @AggregateIdentifier private String id;
    private OrderStatus status;
    private BigDecimal total;
    private String customerId;
    private String customerName;
    private String restaurantId;
    private String restaurantName;
    private Set<OrderItem> items;

    @CommandHandler
    public OrderAggregate(CreateOrderCommand command) {
        Set<OrderCreatedEvent.OrderItem> evtItems = new LinkedHashSet<>();
        BigDecimal bdTotal = BigDecimal.ZERO;
        for (OrderItem orderItem : command.getItems()) {
            evtItems.add(new OrderCreatedEvent.OrderItem(
                orderItem.getDishId(), orderItem.getDishName(),
                orderItem.getDishPrice(), orderItem.getQuantity()));
            bdTotal = bdTotal.add(
                orderItem.getDishPrice().multiply(BigDecimal.valueOf(orderItem.getQuantity())));
        }
        AggregateLifecycle.apply(new OrderCreatedEvent(
            command.getId(), command.getCustomerId(), command.getCustomerName(),
            command.getCustomerAddress(), command.getRestaurantId(), command.getRestaurantName(),
            OrderStatus.CREATED.name(), bdTotal, ZonedDateTime.now(), evtItems));
    }

    @EventSourcingHandler
    public void handle(OrderCreatedEvent event) {
        this.id = event.getId();
        this.status = OrderStatus.valueOf(event.getStatus());
        this.total = event.getTotal();
        this.customerId = event.getCustomerId();
        this.customerName = event.getCustomerName();
        this.restaurantId = event.getRestaurantId();
        this.restaurantName = event.getRestaurantName();
        this.items = event.getItems().stream()
            .map(i -> new OrderItem(i.getDishId(), i.getDishName(), i.getDishPrice(), i.getQuantity()))
            .collect(Collectors.toSet());
    }
}

The OrderCreatedEvent carries customer and restaurant details so consumers do not need to make round trips. Both customer-service and restaurant-service subscribe to this event:

@EventHandler
public void handle(OrderCreatedEvent event) {
    customerRepository.findById(event.getCustomerId()).ifPresent(c -> {
        Order order = new Order();
        order.setId(event.getId());
        order.setRestaurantName(event.getRestaurantName());
        order.setTotal(event.getTotal());
        order.setItems(mapItems(event.getItems()));
        c.getOrders().add(order);
        customerRepository.save(c);
    });
}
None

What Changes

At the end of this migration, the system looks very different. What was a single service with synchronous REST endpoints has become three services communicating through events. Every state change is an immutable event stored in Axon Server.

A few things get harder. Debugging spans multiple services and asynchronous handlers — distributed tracing becomes essential. Projections must be rebuilt when you add or fix a projector, so handlers need to be replay-safe. And events live forever: old schemas must still deserialize, so plan for backward compatibility and upcasting from the start.

Conclusion

Migrating from traditional CRUD to an event-driven architecture with Axon Framework is a journey of small, reversible steps. You add Axon to the classpath. You extract shared events. You introduce command handlers. You add projectors.

Each step removes a limitation of the previous architecture. The controller no longer performs database writes directly. The service layer is replaced by event handlers. The read model becomes independently scalable. Cross-service communication becomes asynchronous.

The food ordering system runs three microservices and three databases—with event-driven data synchronization replacing most inter-service REST calls.

The code is open source at github.com/ivangfr/axon-springboot-websocket.

Thanks for Reading

If you found this article useful, here are a few ways you can support my work:

  • 🔁 Repost.
  • 👏 Clap, highlight, and respond.
  • ✉️ Subscribe to my newsletter.
  • 🔔 Follow me on Medium | LinkedIn | X | GitHub.
  • Support my writing
None