Skip to content

Axon5 query handlers incompatible to Axon4 app when returning empty Optionals #4744

Description

@klauss42

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:

  1. Start AxonServer (docker compose up -d).
  2. Start server5 and client4.
  3. curl http://localhost:8083/customer/1 → customer exists → 200 OK
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions