Basic information
Steps to reproduce
The reproducer contains three Spring Boot / Kotlin applications that all connect to AxonServer:
| App |
Axon version |
Role |
server5 |
5.2.0 |
@QueryHandler returning Optional<CustomerDto> |
client4 |
4.13.2 |
issues a direct query via QueryGateway.query(...) |
client5 |
5.2.0 |
same as client4 but with Axon 5 (works correctly) |
Query handler on server5:
@QueryHandler
fun handle(query: CustomerFindOneQuery): Optional<CustomerDto> {
return Optional.ofNullable(customers[query.customerId])
}
Query dispatch on client4:
queryGateway.query(
CustomerFindOneQuery(customerId),
ResponseTypes.optionalInstanceOf(CustomerDto::class.java),
)
Steps:
- Start AxonServer (
docker compose up -d).
- Start
server5 and client4.
curl http://localhost:8083/customer/1 → customer exists → 200 OK ✅
curl http://localhost:8083/customer/11 → customer does not exist → handler returns Optional.empty() → 500 Internal Server Error ❌
Expected behaviour
When the @QueryHandler on an Axon 5 server returns Optional.empty() for a direct query, the Axon 4
client should receive a response that allows it to treat the result as "not found" — consistent with
the behaviour of a homogeneous Axon 4 deployment where an empty/null result was serialized as a single
QueryResponse with a null payload, allowing defaultIfEmpty(...) to return HTTP 404.
Actual behaviour
The Axon 4 client throws AxonServerQueryDispatchException with the message
"Query did not yield the expected number of results."
2026-07-13T17:48:43.053+02:00 ERROR 64713 --- [client4] [QueryResponse-0]
a.w.r.e.Ab....PriorityRunnable.run(PriorityRunnable.java:58)
org.axonframework.axonserver.connector.query.AxonServerQueryDispatchException:
Query did not yield the expected number of results.
at org.axonframework.axonserver.connector.query.AxonServerQueryBus
$ResponseProcessingTask.run(AxonServerQueryBus.java:1032)
...
No error is logged on the server5 side — the handler executed successfully and returned
Optional.empty() without throwing.
Root cause analysis
(With the help of Claude+Gemini)
This is a behavioral change introduced in Axon 5's messaging layer that is incompatible with the
Axon 4 wire protocol for direct queries.
Step 1 — MessageStreamResolverUtils maps Optional.empty() to an empty MessageStream (zero items)
org.axonframework.messaging.core.annotation.MessageStreamResolverUtils.resolveToStream()
in axon-messaging-5.2.0:
case Optional<?> optional when optional.isPresent() -> {
Object r = optional.get();
MessageStream.just(new GenericMessage(typeResolver.resolveOrThrow(r),r));
}
case Optional<?> empty ->MessageStream.empty(); // ← zero items on the stream
Step 2 — The Axon 5 server connector closes the stream without sending any QueryResponse
FlowControlledResponseSender.sendResponses() in axon-server-connector-5.2.0:
when the upstream MessageStream is empty and completed it invokes downstream::complete without
writing a single QueryResponse message. AxonServer then relays the closed stream with zero responses
to the client.
Step 3 — The Axon 4 client treats a closed stream with zero responses as an error
AxonServerQueryBus$ResponseProcessingTask.run() in axon-server-connector-4.13.2:
QueryResponse nextAvailable = result.nextIfAvailable();
if (nextAvailable != null) { /* complete future */ }
} else if (result.isClosed() && !queryTransaction.isDone()) {
throw new AxonServerQueryDispatchException(
...,"Query did not yield the expected number of results.");
}
The Axon 4 direct-query protocol requires at least one QueryResponse message per query.
Contrast with Axon 4 server behaviour
In Axon 4, SimpleQueryBus.buildCompletableFuture() always calls
GenericQueryResponseMessage.asNullableResponseMessage(declaredType, result).
For a null/empty result this produces one QueryResponseMessage with a null payload, which
is serialized and sent as exactly one QueryResponse on the wire. The Axon 4 client completes the
future with null and the caller handles it (e.g. via defaultIfEmpty). The "zero responses"
situation never arises in a homogeneous Axon 4 deployment.
There is no client-side workaround: changing the response type from instanceOf to optionalInstanceOf
does not help because ResponseProcessingTask is always used for point-to-point queries and errors on
zero responses regardless of the declared response type.
Basic information
Steps to reproduce
The reproducer contains three Spring Boot / Kotlin applications that all connect to AxonServer:
server5@QueryHandlerreturningOptional<CustomerDto>client4QueryGateway.query(...)client5Query handler on
server5:Query dispatch on
client4:Steps:
docker compose up -d).server5andclient4.curl http://localhost:8083/customer/1→ customer exists →200 OK✅curl http://localhost:8083/customer/11→ customer does not exist → handler returnsOptional.empty()→500 Internal Server Error❌Expected behaviour
When the
@QueryHandleron an Axon 5 server returnsOptional.empty()for a direct query, the Axon 4client should receive a response that allows it to treat the result as "not found" — consistent with
the behaviour of a homogeneous Axon 4 deployment where an empty/null result was serialized as a single
QueryResponsewith a null payload, allowingdefaultIfEmpty(...)to return HTTP 404.Actual behaviour
The Axon 4 client throws
AxonServerQueryDispatchExceptionwith the message"Query did not yield the expected number of results."
No error is logged on the
server5side — the handler executed successfully and returnedOptional.empty()without throwing.Root cause analysis
(With the help of Claude+Gemini)
This is a behavioral change introduced in Axon 5's messaging layer that is incompatible with the
Axon 4 wire protocol for direct queries.
Step 1 —
MessageStreamResolverUtilsmapsOptional.empty()to an emptyMessageStream(zero items)org.axonframework.messaging.core.annotation.MessageStreamResolverUtils.resolveToStream()in
axon-messaging-5.2.0:Step 2 — The Axon 5 server connector closes the stream without sending any
QueryResponseFlowControlledResponseSender.sendResponses()inaxon-server-connector-5.2.0:when the upstream
MessageStreamis empty and completed it invokesdownstream::completewithoutwriting a single
QueryResponsemessage. AxonServer then relays the closed stream with zero responsesto the client.
Step 3 — The Axon 4 client treats a closed stream with zero responses as an error
AxonServerQueryBus$ResponseProcessingTask.run()inaxon-server-connector-4.13.2:The Axon 4 direct-query protocol requires at least one
QueryResponsemessage per query.Contrast with Axon 4 server behaviour
In Axon 4,
SimpleQueryBus.buildCompletableFuture()always callsGenericQueryResponseMessage.asNullableResponseMessage(declaredType, result).For a
null/empty result this produces oneQueryResponseMessagewith a null payload, whichis serialized and sent as exactly one
QueryResponseon the wire. The Axon 4 client completes thefuture with
nulland the caller handles it (e.g. viadefaultIfEmpty). The "zero responses"situation never arises in a homogeneous Axon 4 deployment.
There is no client-side workaround: changing the response type from
instanceOftooptionalInstanceOfdoes not help because
ResponseProcessingTaskis always used for point-to-point queries and errors onzero responses regardless of the declared response type.