Best practice using Axon

Hello everyone,

The more I read about Axon best practices, the more I realize there are many possible approaches — including some that go against recommended usage.

I’m currently working on a project with three microservices (each with 2 pods) for:

  • Aggregates
  • Sagas
  • Queries

But unfortunately, we’re not following all the best practices.

Here’s the current structure:

  • 2 Aggregates
  • 1 Saga Manager, responsible only for workflow orchestration (e.g., EventA received → send CommandB, etc.)
  • Query side with 2 projectors, persisting data into a database

I’m evaluating three architectural options, and I’d like your opinion on which one is best:


1. Aggregate holds all information
The aggregate stores all necessary data and directly calls the external service.
(This is what we currently use.)

2. Minimal aggregate, Saga handles side effects
The aggregate stores only the minimal data needed to validate commands.
The Saga fetches extra data from the query side (via QueryGateway) and calls the external service.

3. Same as #2, but with an intermediate command handler
Instead of the Saga calling the external service, it sends a command (e.g., SendInvoiceToExternalSystemCommand) to a dedicated command handler that performs the REST call.
This command handler belongs to the same microservice as the aggregate.


From what I’ve read, option 2 or 3 seems more aligned with Axon best practices, especially in terms of separation of concerns and scalability.

However, we’re already facing some latency on the query side.
So here’s my open question:
:face_with_monocle: How should we deal with situations where the Saga needs data that’s not yet available in the query model due to projection delay or service downtime?

First solution

[Controller]
     │
     ▼
CommandGateway.send(SendInvoiceCommand)
     │
     ▼
[OrderAggregate]
     ├─ handle(SendInvoiceCommand)
     └─ apply(InvoiceReadyToBeSentEvent)
               │
               ▼
     ┌─────────┴──────────────┐
     │                        │
[Projection / Query]     [InvoiceSaga]
@EventHandler             @SagaEventHandler InvoiceReadyToBeSentEvent
→ saves to database       → queryGateway.query(GetInvoiceDetailQuery)
                          → receives InvoiceDetailView
                          → calls REST external service
                          → if success: send(InvoiceSentCommand)
                          → if failure: send(InvoiceSendFailedCommand)
                                      ▼
                              [OrderAggregate]
                              └─ updates state via apply(...)

Second one

[Controller]
     │
     ▼
CommandGateway.send(SendInvoiceCommand)
     │
     ▼
[OrderAggregate]
     ├─ handle(SendInvoiceCommand)
     ├─ calls REST external service directly (sync/blocking)
     ├─ if success: apply(InvoiceSentCommand)
     └─ if failure: apply(InvoiceFailedEvent)
               │
               ▼
[EventStore persists events]
               │
               ▼
[Projection / Query]
@EventHandler
→ updates read model in database
               │
               ▼
[InvoiceSaga]
@SagaEventHandler listens to InvoiceSentEvent or InvoiceFailedEvent
→ triggers follow-up processes (notifications, compensations, etc.)

Third

[Controller]
     │
     ▼
CommandGateway.send(SendInvoiceCommand)
     │
     ▼
[OrderAggregate]
     ├─ handle(SendInvoiceCommand)
     └─ apply(InvoiceReadyToBeSentEvent)
               │
               ▼
     ┌─────────┴──────────────┐
     │                        │
[Projection / Query]     [InvoiceSaga]
@EventHandler             @SagaEventHandler InvoiceReadyToBeSentEvent
→ saves to database       → queryGateway.query(GetInvoiceDetailQuery)
                          → receives InvoiceDetailView
                          → sends SendInvoiceToExternalServiceCommand (to external command handler)
                                      ▼
                           [ExternalCommandHandler]
                           ├─ calls REST external service
                           ├─ if success: send(InvoiceSentCommand)
                           └─ if failure: send(InvoiceSendFailedCommand)
                                      ▼
                              [OrderAggregate]

Hi Alexandre,

I’ll try to answer based on the following assumptions (correct me if I’m wrong):

  • The external service is responsible for sending invoices. (You don’t explicitly say so in your post, but the event names seem to indicate it.)
  • Each invoice is based on data from a single order (and nothing else). (Because you are sending the SendInvoiceCommand to the OrderAggregate.)

The options you list don’t seem to match up with your ASCII art diagrams (e.g., your first diagram shows the saga fetching data from the query model, but this seems to be option 2?). So I won’t try to evaluate them directly but rather give you another option and explain my reasoning so you can judge how applicable it is to your situation. Here it is:

4. Pass data in event

[Controller]  
 └─ CommandGateway.send(SendInvoiceCommand)  
     │  
     ▼  
[OrderAggregate]  
 └─ handle(SendInvoiceCommand)  
    ├─ (reads data from the command model)  
    ├─ verify all data required for sending the invoice is present  
    └─ apply(InvoiceReadyToBeSentEvent) ← contains all data required for sending the invoice  
        │
        ▼
     ┌──┴─────────────────────┐  
     │                        │  
  [Projection / Query]   [InvoiceSaga]
   @EventHandler          ├─ @SagaEventHandler handle(InvoiceReadyToBeSentEvent)  
   → saves to database    └─ call external REST service  
                             ├─  if success: send(InvoiceSentEvent)  
                             └─  if failure: send(InvoiceSendFailedEvent)  
                                  │  
                                  ▼  
              [OrderAggregate & InvoiceSaga]
               └─ handle(Invoice*Event)
                  (update state in aggregate & orchestrate follow-up processes in saga)

Rationale: Command handlers should do validation and keep the aggregate consistent. You need to perform necessary checks in the command handler anyway so you might as well use it to look up all data required for the invoice. Because the data is contained in the command model of the OrderAggregate, this is strongly consistent. Events should be self-contained. The InvoiceReadyToBeSentEvent contains all necessary data. The saga is responsible for the orchestration of the invoice sending process. Therefore, its event handler calls the external service. It can do this without relying on a potentially out-of-date read model because all required data is contained in the event. There may still be event handlers that update query models for other purposes but the InvoiceSaga does not rely on them. I think of calling the external service as passing on the InvoiceReadyToBeSentEvent to this service. There is no need for a separate command – and thus for an external command handler. On success or failure, the saga publishes an event as appropriate, and this event is handled by the aggregate (by updating state as necessary) and the saga (by continuing the invoicing process).

I suggest this option because it is very similar to what you are already considering and does not require changing your service architecture. However, I think it might be worth considering a different approach to slicing services (not by technical types but by domain functionality). This would allow for better separation of concerns and probably improve scalability, too.

1 Like