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:
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]