<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Petr Macek</title>
    <description>The latest articles on Forem by Petr Macek (@petrmacek).</description>
    <link>https://forem.com/petrmacek</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3780157%2F5214a040-0361-4559-8679-c985be36d964.jpg</url>
      <title>Forem: Petr Macek</title>
      <link>https://forem.com/petrmacek</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/petrmacek"/>
    <language>en</language>
    <item>
      <title>Propagating User Identity in Axon 5 Query Handlers</title>
      <dc:creator>Petr Macek</dc:creator>
      <pubDate>Thu, 19 Feb 2026 09:00:00 +0000</pubDate>
      <link>https://forem.com/petrmacek/propagating-user-identity-in-axon-5-query-handlers-4jd8</link>
      <guid>https://forem.com/petrmacek/propagating-user-identity-in-axon-5-query-handlers-4jd8</guid>
      <description>&lt;p&gt;During internal testing of our latest release, we hit a puzzling bug: owners couldn't see their own entities on a management page. The error message was simply "Unable to load" — despite the record clearly existing in the database.&lt;/p&gt;

&lt;p&gt;Our application uses &lt;strong&gt;Axon Framework 5&lt;/strong&gt; with &lt;strong&gt;Spring WebFlux&lt;/strong&gt; and &lt;strong&gt;Netflix DGS&lt;/strong&gt; (GraphQL). The query handler was doing something seemingly reasonable — checking whether the authenticated user was the owner before returning INACTIVE records:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@QueryHandler&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Venue&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FindVenueByIdQuery&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;venueRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByIdWithAllRelationships&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;venueId&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filterWhen&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;venue&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;canViewVenueReactive&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;venue&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;VenueNode:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;toDomain&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;canViewVenueReactive&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;VenueNode&lt;/span&gt; &lt;span class="n"&gt;venue&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ReactiveSecurityContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContext&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAuthentication&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;isOwner&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;venue&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;venue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isActive&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultIfEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;venue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isActive&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Can you spot the problem?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why ReactiveSecurityContextHolder Is Always Empty Inside Axon Handlers
&lt;/h2&gt;

&lt;p&gt;The reactive security context in Spring WebFlux is propagated through the &lt;strong&gt;Reactor Context&lt;/strong&gt; — a subscriber-scoped mechanism attached to the reactive chain. It works within a single reactive pipeline. But Axon queries introduce two critical disruptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disruption 1: The Scheduler Hop
&lt;/h3&gt;

&lt;p&gt;Our original &lt;code&gt;ReactiveQueryGateway&lt;/code&gt; wrapped Axon's &lt;code&gt;QueryGateway&lt;/code&gt; (which returns &lt;code&gt;CompletableFuture&lt;/code&gt;) in a &lt;code&gt;Mono&lt;/code&gt; with a dedicated blocking scheduler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromFuture&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;queryGateway&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;responseType&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;subscribeOn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blockingScheduler&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;subscribeOn(blockingScheduler)&lt;/code&gt; shifts execution to a different thread pool. While Reactor's &lt;code&gt;Hooks.enableAutomaticContextPropagation()&lt;/code&gt; handles MDC propagation across scheduler hops, the &lt;strong&gt;security context&lt;/strong&gt; requires explicit ThreadLocal restoration — which Axon doesn't do.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(As it turns out, this scheduler hop is unnecessary in Axon 5 — but the &lt;code&gt;toFuture()&lt;/code&gt; boundary below breaks context propagation regardless.)&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Disruption 2: The toFuture() Boundary
&lt;/h3&gt;

&lt;p&gt;Even if the security context survived the scheduler hop, Axon internally calls &lt;code&gt;.toFuture()&lt;/code&gt; on the Mono returned by query handlers. This creates an &lt;strong&gt;independent subscription&lt;/strong&gt; — a completely separate reactive chain that has no knowledge of the original subscriber's context.&lt;/p&gt;

&lt;p&gt;The result: &lt;code&gt;ReactiveSecurityContextHolder.getContext()&lt;/code&gt; inside any Axon query handler returns &lt;code&gt;Mono.empty()&lt;/code&gt;. Always.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Approaches We Considered
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Approach A: Add callerId to Query Objects
&lt;/h3&gt;

&lt;p&gt;The quick fix: just put the user ID in the query record.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;FindVenueByIdQuery&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;VenueId&lt;/span&gt; &lt;span class="n"&gt;venueId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;callerId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;We rejected this.&lt;/strong&gt; Query objects are domain concepts — they express &lt;em&gt;what&lt;/em&gt; you want to find, not &lt;em&gt;who&lt;/em&gt; is asking. Polluting every query with authentication concerns violates CQRS principles and creates a leaky abstraction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Approach B: SecurityContext ThreadLocalAccessor
&lt;/h3&gt;

&lt;p&gt;Register the security context as a &lt;code&gt;ThreadLocalAccessor&lt;/code&gt; with Micrometer's &lt;code&gt;ContextRegistry&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;ContextRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;registerThreadLocalAccessor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"security"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;SecurityContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContext&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
    &lt;span class="nl"&gt;SecurityContextHolder:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;setContext&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;SecurityContextHolder:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;clearContext&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;We rejected this too.&lt;/strong&gt; Axon 5 deliberately moved away from thread-local-based patterns. The &lt;code&gt;toFuture()&lt;/code&gt; boundary still breaks this approach — ThreadLocalAccessors only help with &lt;code&gt;subscribeOn&lt;/code&gt; hops within a single Reactor chain, not across independent subscriptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Approach C: Axon MetaData (What We Chose)
&lt;/h3&gt;

&lt;p&gt;Axon has a first-class mechanism for propagating cross-cutting concerns with messages: &lt;strong&gt;MetaData&lt;/strong&gt;. Every Axon message (commands, queries, events) can carry a &lt;code&gt;Map&amp;lt;String, String&amp;gt;&lt;/code&gt; of metadata alongside the payload.&lt;/p&gt;

&lt;p&gt;This is the CQRS-correct approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Query objects remain pure domain objects&lt;/strong&gt; — no authentication concerns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity travels WITH the message&lt;/strong&gt; — not as ambient thread-local state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Works regardless of threading model&lt;/strong&gt; — scheduler hops, &lt;code&gt;toFuture()&lt;/code&gt;, serialization boundaries... none of it matters&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Solution: Auth-Injecting Query Gateway
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Key Axon 5 API Insight
&lt;/h3&gt;

&lt;p&gt;Axon 5's &lt;code&gt;QueryGateway&lt;/code&gt; doesn't expose a metadata parameter directly (unlike &lt;code&gt;CommandGateway.send(command, metadata)&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;But there's a workaround. Looking at &lt;code&gt;DefaultQueryGateway.asQueryMessage()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;Q&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;R&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;QueryMessage&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;Q&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;R&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;asQueryMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Q&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Class&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;R&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;responseType&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;QueryMessage&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?,&lt;/span&gt; &lt;span class="o"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;queryMessage&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;QueryMessage&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;Q&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;R&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;)&lt;/span&gt; &lt;span class="n"&gt;queryMessage&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Used directly!&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ... wraps in GenericQueryMessage otherwise&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the query object is already a &lt;code&gt;QueryMessage&lt;/code&gt;, Axon uses it directly — metadata and all. So we construct a &lt;code&gt;GenericQueryMessage&lt;/code&gt; with metadata attached and pass it to the gateway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementation
&lt;/h3&gt;

&lt;p&gt;We enhanced our existing &lt;code&gt;ReactiveQueryGateway&lt;/code&gt; — the single choke point that all query callers go through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DefaultReactiveQueryGateway&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ReactiveQueryGateway&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;QueryGateway&lt;/span&gt; &lt;span class="n"&gt;queryGateway&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ReactiveAuthenticationSupplier&lt;/span&gt; &lt;span class="n"&gt;authenticationSupplier&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;R&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Q&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;R&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Q&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Class&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;R&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;responseType&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;buildAuthMetadata&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flatMap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;queryWithMetadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wrapWithMetadata&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromFuture&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                            &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;queryGateway&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queryWithMetadata&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;responseType&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
                &lt;span class="o"&gt;});&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Metadata&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;buildAuthMetadata&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;authenticationSupplier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAuthentication&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="nc"&gt;Metadata&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Metadata&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"userId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
                    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAuthorities&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;GrantedAuthority:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getAuthority&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;collect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Collectors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;joining&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;","&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;and&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"roles"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                    &lt;span class="o"&gt;}&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
                &lt;span class="o"&gt;})&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultIfEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Metadata&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;emptyInstance&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;Q&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="nf"&gt;wrapWithMetadata&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Q&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Metadata&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="nc"&gt;MessageType&lt;/span&gt; &lt;span class="n"&gt;messageType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MessageType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClass&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;GenericQueryMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;GenericMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messageType&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copyOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;)),&lt;/span&gt;
                &lt;span class="kc"&gt;null&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical ordering: &lt;code&gt;buildAuthMetadata()&lt;/code&gt; runs &lt;strong&gt;in the DGS reactive chain&lt;/strong&gt; where &lt;code&gt;ReactiveSecurityContextHolder&lt;/code&gt; works. The result is captured in the &lt;code&gt;flatMap&lt;/code&gt; closure before &lt;code&gt;Mono.fromFuture()&lt;/code&gt; crosses the async boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Query Handler Side
&lt;/h3&gt;

&lt;p&gt;Query handlers consume the metadata via &lt;code&gt;@MetadataValue&lt;/code&gt; parameter injection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@QueryHandler&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Venue&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FindVenueByIdQuery&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@MetadataValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"userId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;callerId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@MetadataValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"roles"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;venueRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByIdWithAllRelationships&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;venueId&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;venue&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;canViewVenue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;venue&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;callerId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;VenueNode:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;toDomain&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;canViewVenue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;VenueNode&lt;/span&gt; &lt;span class="n"&gt;venue&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;callerId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;roles&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ROLE_ADMIN"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callerId&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;venue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCreatedBy&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;venue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCreatedBy&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;venue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCreatedBy&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callerId&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;venue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatusEnum&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;VenueStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ACTIVE&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice: &lt;code&gt;canViewVenue&lt;/code&gt; is now a &lt;strong&gt;pure function&lt;/strong&gt;. It takes explicit inputs and returns a deterministic result. No &lt;code&gt;Mono&amp;lt;Boolean&amp;gt;&lt;/code&gt;, no &lt;code&gt;ReactiveSecurityContextHolder&lt;/code&gt;, no ambient state. This is testable, debuggable, and correct by construction.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Query Object Stays Clean
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;FindVenueByIdQuery&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;VenueId&lt;/span&gt; &lt;span class="n"&gt;venueId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;FindVenueByIdQuery&lt;/span&gt; &lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;FindVenueByIdQuery&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;VenueId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;callerId&lt;/code&gt;. No security concerns. Just a domain query.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture at a Glance
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F16qh77n81b97b85eq6tu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F16qh77n81b97b85eq6tu.png" alt=" " width="800" height="860"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond Identity: Propagating Roles
&lt;/h2&gt;

&lt;p&gt;The initial implementation only propagated &lt;code&gt;userId&lt;/code&gt;. This solved the owner-visibility problem but created a subtler bug: &lt;strong&gt;admins who weren't owners couldn't see INACTIVE records&lt;/strong&gt; — not in the detail view, and not in the list view.&lt;/p&gt;

&lt;p&gt;The DGS layer uses &lt;code&gt;@PreAuthorize("hasRole('ADMIN')")&lt;/code&gt; for admin endpoints, so the GraphQL request succeeds. But the query handler's &lt;code&gt;canViewVenue()&lt;/code&gt; couldn't distinguish an admin from a regular user.&lt;/p&gt;

&lt;p&gt;The fix: extend the metadata to include roles. The key insight is that &lt;strong&gt;if a query handler needs any security context to make a decision, that context must travel as metadata.&lt;/strong&gt; The &lt;code&gt;@PreAuthorize&lt;/code&gt; annotation and the &lt;code&gt;canViewVenue()&lt;/code&gt; check serve different purposes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@PreAuthorize&lt;/code&gt;&lt;/strong&gt; is a gate — can this user invoke this operation at all?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;canViewVenue()&lt;/code&gt;&lt;/strong&gt; is a filter — which results should this user see?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both need role information, but they access it from different layers. The gateway bridges the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dropping the Blocking Scheduler
&lt;/h2&gt;

&lt;p&gt;Our original &lt;code&gt;ReactiveQueryGateway&lt;/code&gt; used &lt;code&gt;subscribeOn(blockingScheduler)&lt;/code&gt; to avoid tying up Netty event-loop threads — a reasonable precaution when &lt;code&gt;queryGateway.query()&lt;/code&gt; might block. But in Axon 5 with &lt;code&gt;SimpleQueryBus&lt;/code&gt;, the entire query dispatch path is non-blocking:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;queryGateway.query()&lt;/code&gt; does lightweight synchronous work — routing, message wrapping — and returns a &lt;code&gt;CompletableFuture&lt;/code&gt; immediately&lt;/li&gt;
&lt;li&gt;The query handler returns &lt;code&gt;Mono&amp;lt;T&amp;gt;&lt;/code&gt;, which Axon converts via &lt;code&gt;toFuture()&lt;/code&gt; — a non-blocking operation that just wires up the completion signal&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;CompletableFuture&lt;/code&gt; completes when the handler's &lt;code&gt;Mono&lt;/code&gt; emits, on whatever scheduler the reactive chain was already using&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's no blocking I/O anywhere in this path. The &lt;code&gt;subscribeOn(blockingScheduler)&lt;/code&gt; only added an unnecessary context switch — and one fewer moving part means one fewer thing that can break context propagation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caveat&lt;/strong&gt;: If you use &lt;code&gt;AxonServerQueryBus&lt;/code&gt; (connecting to Axon Server), there's serialization and gRPC I/O involved. Even that is mostly async in Axon 5, but if you observe Netty thread starvation under high load, a bounded scheduler for gateway calls might still make sense. Profile first — don't add it preemptively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Messages should be self-contained.&lt;/strong&gt; In a CQRS system, cross-cutting concerns belong in message metadata, not in reactive context or thread-locals. Start with &lt;code&gt;userId&lt;/code&gt;, but plan for roles and other security context.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Extract context early, propagate explicitly.&lt;/strong&gt; The DGS layer is the last point where the reactive security context is available. Extract what you need there and pass it forward — don't rely on it surviving framework boundaries.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Gateway layers are powerful choke points.&lt;/strong&gt; By modifying a single class, we transparently added auth injection to all query calls without touching any caller. Infrastructure concerns belong in infrastructure code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pure functions beat reactive context lookups.&lt;/strong&gt; Replacing &lt;code&gt;canViewVenueReactive()&lt;/code&gt; (Mono-returning, context-dependent) with &lt;code&gt;canViewVenue()&lt;/code&gt; (boolean-returning, explicit inputs) made the code more testable, more debuggable, and provably correct.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Axon 5's API has gaps — but workarounds exist.&lt;/strong&gt; The &lt;code&gt;QueryGateway&lt;/code&gt; doesn't support metadata parameters directly, but the &lt;code&gt;instanceof QueryMessage&lt;/code&gt; check in &lt;code&gt;asQueryMessage()&lt;/code&gt; provides a clean workaround.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Axon 5 Migration Note
&lt;/h2&gt;

&lt;p&gt;If you're migrating from Axon 4:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;@MetaDataValue&lt;/code&gt; (Axon 4) is now &lt;code&gt;@MetadataValue&lt;/code&gt; (Axon 5) — note the lowercase 'd'&lt;/li&gt;
&lt;li&gt;Package changed from &lt;code&gt;org.axonframework.messaging.annotation&lt;/code&gt; to &lt;code&gt;org.axonframework.messaging.core.annotation&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MessageType&lt;/code&gt; is now a record with a &lt;code&gt;MessageType(Class&amp;lt;?&amp;gt;)&lt;/code&gt; constructor&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GenericMessage&lt;/code&gt; constructor signature: &lt;code&gt;GenericMessage(MessageType, Object, Map&amp;lt;String, String&amp;gt;)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The reactive security context doesn't survive Axon query handler boundaries in Spring WebFlux applications. Rather than fighting the framework with thread-local propagation hacks, lean into Axon's own messaging model: extract identity &lt;em&gt;and roles&lt;/em&gt; at the edge, attach them as MetaData, and read them with &lt;code&gt;@MetadataValue&lt;/code&gt; in your handlers. Your query objects stay clean, your handlers become pure functions, and the fix is transparent to every caller in the system.&lt;/p&gt;

</description>
      <category>java</category>
      <category>cqrs</category>
      <category>security</category>
      <category>axonframework</category>
    </item>
    <item>
      <title>Migrating from Axon Framework 4 to 5: What We Learned</title>
      <dc:creator>Petr Macek</dc:creator>
      <pubDate>Thu, 19 Feb 2026 05:00:00 +0000</pubDate>
      <link>https://forem.com/petrmacek/migrating-from-axon-framework-4-to-5-what-we-learned-50db</link>
      <guid>https://forem.com/petrmacek/migrating-from-axon-framework-4-to-5-what-we-learned-50db</guid>
      <description>&lt;p&gt;We run a CQRS/Event Sourcing backend — now happily on Java 25, Spring Boot 4, and Axon Framework 5. But getting here from Axon 4.12 was a journey. The codebase has dozens of aggregates, multiple sagas, over a hundred event types, and hundreds of annotated handler methods.&lt;/p&gt;

&lt;p&gt;Here's what we learned along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why We Wanted Axon 5
&lt;/h2&gt;

&lt;p&gt;The motivation was simple: &lt;strong&gt;native reactive support in event handlers&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Axon 4.x has a well-known issue with subscribing event processors. When an &lt;code&gt;@EventHandler&lt;/code&gt; returns a &lt;code&gt;Mono&amp;lt;Void&amp;gt;&lt;/code&gt;, the framework never subscribes to it. The reactive chain simply doesn't execute. We discovered this the hard way when projection handlers silently stopped writing to the database.&lt;/p&gt;

&lt;p&gt;The workaround? Add &lt;code&gt;.block()&lt;/code&gt; to every single event handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Axon 4.x — the framework ignores the returned Mono&lt;/span&gt;
&lt;span class="nd"&gt;@EventHandler&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ClimbLoggedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dlqService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withDLQ&lt;/span&gt;&lt;span class="o"&gt;(...)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;subscribeOn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Schedulers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;boundedElastic&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;block&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// Without this, nothing happens&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We had hundreds of these &lt;code&gt;.block()&lt;/code&gt; calls scattered across the codebase. Each one pins a thread in a reactive application. Axon 5.0 promised to fix this with an async-native architecture where the framework subscribes to returned reactive types automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Axon 5.0 — the framework subscribes for you&lt;/span&gt;
&lt;span class="nd"&gt;@EventHandler&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ClimbLoggedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dlqService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withDLQ&lt;/span&gt;&lt;span class="o"&gt;(...);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Beyond reactive support, Axon 5 also removes ThreadLocal usage (which conflicts with Project Reactor and virtual threads), simplifies configuration, and introduces Dynamic Consistency Boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Attempt: Thousands of Compilation Errors
&lt;/h2&gt;

&lt;p&gt;We created a feature branch and bumped the dependencies. The first surprise: Axon 5 uses a different module structure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Axon 4.x&lt;/span&gt;
&lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.axonframework:axon-bom:4.12.2"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.axonframework:axon-spring-boot-starter"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Axon 5.0&lt;/span&gt;
&lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.axonframework:axon-framework-bom:5.0.1"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.axonframework.extensions.spring:axon-spring-boot-starter"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.axonframework.extensions.tracing:axon-tracing-opentelemetry"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Spring modules moved to &lt;code&gt;org.axonframework.extensions.spring&lt;/code&gt;, tracing to &lt;code&gt;org.axonframework.extensions.tracing&lt;/code&gt;. Fair enough.&lt;/p&gt;

&lt;p&gt;Then we compiled. Thousands of errors.&lt;/p&gt;

&lt;p&gt;This isn't a typical major-version bump with a few deprecations. The entire API has been redesigned. Every package you import from Axon has moved or been removed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you used in Axon 4&lt;/th&gt;
&lt;th&gt;Status in Axon 5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;org.axonframework.modelling.command.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;org.axonframework.commandhandling.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;org.axonframework.queryhandling.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;org.axonframework.eventhandling.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@Aggregate&lt;/code&gt; / &lt;code&gt;@AggregateIdentifier&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Replaced by &lt;code&gt;@EventSourced(idType=...)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@Revision("1.0")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replaced by &lt;code&gt;@Event(version = "1.0")&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AggregateLifecycle.apply(event)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replaced by &lt;code&gt;EventAppender.append(event)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Constructor &lt;code&gt;@CommandHandler&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Replaced by static method pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UnitOfWork&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replaced by &lt;code&gt;ProcessingContext&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MetaData&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Renamed to &lt;code&gt;Metadata&lt;/code&gt; (lowercase 'd')&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ResponseType&amp;lt;R&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed — use &lt;code&gt;Class&amp;lt;R&amp;gt;&lt;/code&gt; directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ReactorQueryGateway&lt;/code&gt; (extension)&lt;/td&gt;
&lt;td&gt;Native async in core&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This isn't incremental. It's a rewrite of the developer-facing API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration: Phase by Phase
&lt;/h2&gt;

&lt;p&gt;We tracked progress in phases and wrote shell scripts to automate the mechanical changes. Here's how it went.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: Package Relocations
&lt;/h3&gt;

&lt;p&gt;The bulk of the compilation errors were import statement changes. Command handling, query handling, and event handling classes all moved under &lt;code&gt;org.axonframework.messaging.*&lt;/code&gt;. This is tedious but automatable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Example from our migration scripts&lt;/span&gt;
find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.java"&lt;/span&gt; &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'s/org.axonframework.commandhandling/org.axonframework.messaging.commandhandling/g'&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; +
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Phase 2: Annotation Changes
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@Aggregate&lt;/code&gt; became &lt;code&gt;@EventSourced&lt;/code&gt; with a required &lt;code&gt;idType&lt;/code&gt; attribute. Every aggregate class needs updating, and you may need to create typed ID classes that didn't exist before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Axon 4&lt;/span&gt;
&lt;span class="nd"&gt;@Aggregate&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ClubAggregate&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@AggregateIdentifier&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Axon 5&lt;/span&gt;
&lt;span class="nd"&gt;@EventSourced&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@EventSourcedEntity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tagKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"clubId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ClubAggregate&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@EntityCreator&lt;/span&gt;
    &lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="nf"&gt;ClubAggregate&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;members&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// Must init collections!&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A subtle gotcha: if your aggregate has &lt;code&gt;Map&lt;/code&gt; or &lt;code&gt;Collection&lt;/code&gt; fields with &lt;code&gt;@Builder.Default&lt;/code&gt;, they must also be initialized in the &lt;code&gt;@EntityCreator&lt;/code&gt; no-args constructor. Axon uses reflection to create instances during event replay, bypassing Lombok's builder.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 3: Command Handler Pattern
&lt;/h3&gt;

&lt;p&gt;Constructor-based command handlers are gone. Aggregate creation now uses static factory methods with an explicit &lt;code&gt;EventAppender&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Axon 4&lt;/span&gt;
&lt;span class="nd"&gt;@CommandHandler&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;ClubAggregate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CreateClubCommand&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;AggregateLifecycle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;apply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ClubCreatedEvent&lt;/span&gt;&lt;span class="o"&gt;(...));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Axon 5&lt;/span&gt;
&lt;span class="nd"&gt;@CommandHandler&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CreateClubCommand&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EventAppender&lt;/span&gt; &lt;span class="n"&gt;appender&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;appender&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ClubCreatedEvent&lt;/span&gt;&lt;span class="o"&gt;(...));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a cleaner pattern — no more magic through the static &lt;code&gt;AggregateLifecycle&lt;/code&gt; threadlocal — but it touches every aggregate's creation path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 4: Event Versioning
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@Revision&lt;/code&gt; is gone, replaced by &lt;code&gt;@Event&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Axon 4&lt;/span&gt;
&lt;span class="nd"&gt;@Revision&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;ClimbLoggedEvent&lt;/span&gt;&lt;span class="o"&gt;(...)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;

&lt;span class="c1"&gt;// Axon 5&lt;/span&gt;
&lt;span class="nd"&gt;@Event&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;ClimbLoggedEvent&lt;/span&gt;&lt;span class="o"&gt;(...)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth noting: &lt;strong&gt;upcasters are not available in Axon 5.0&lt;/strong&gt; — they're planned for 5.2.0. If you rely on event upcasting, plan accordingly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 5: Sagas to Stateful Event Handlers
&lt;/h3&gt;

&lt;p&gt;This was the biggest architectural shift. Axon 5 moves away from the traditional saga model. While the &lt;code&gt;@Saga&lt;/code&gt; annotations technically still exist in a moved package, the recommended pattern is to replace sagas with &lt;strong&gt;stateful event handlers&lt;/strong&gt; backed by database persistence.&lt;/p&gt;

&lt;p&gt;We deprecated all of our sagas and rewrote them as plain Spring &lt;code&gt;@Component&lt;/code&gt; classes with &lt;code&gt;@EventHandler&lt;/code&gt; methods. State that was previously managed by Axon's saga infrastructure is now explicitly persisted in the database via a &lt;code&gt;ProcessStateService&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Slf4j&lt;/span&gt;
&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SiteDataImportEventHandler&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;PROCESS_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"SITE_DATA_IMPORT"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;SiteBatchImportService&lt;/span&gt; &lt;span class="n"&gt;siteBatchImportService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ProcessStateService&lt;/span&gt; &lt;span class="n"&gt;processStateService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@EventHandler&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SiteDataImportRequestedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"siteId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;siteId&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="s"&gt;"format"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;processStateService&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createProcess&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;PROCESS_TYPE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;importId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestedBy&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doOnSuccess&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;process&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="n"&gt;siteBatchImportService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;processSiteImportAsync&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;importId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;siteId&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestedBy&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fileContent&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@EventHandler&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SiteBatchProcessedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;processStateService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getProcess&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;PROCESS_TYPE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;importId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flatMap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;process&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;processStateService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markItemCompleted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;PROCESS_TYPE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;importId&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@EventHandler&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SiteDataImportCompletedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;processStateService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getProcess&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;PROCESS_TYPE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;importId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flatMap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;process&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;processStateService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;deleteProcess&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;PROCESS_TYPE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;importId&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this is better than sagas:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;State is explicit and queryable.&lt;/strong&gt; It lives in your database, not buried in Axon's serialized saga store. You can query process state, build admin dashboards, and debug issues directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fully reactive.&lt;/strong&gt; Handlers return &lt;code&gt;Mono&amp;lt;Void&amp;gt;&lt;/code&gt; and the framework subscribes natively — no &lt;code&gt;.block()&lt;/code&gt; hacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simpler testing.&lt;/strong&gt; No need for saga test fixtures. It's a regular Spring component that you can unit test with mocked dependencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No saga serialization issues.&lt;/strong&gt; Sagas serialize their entire state into Axon's token store. Change a field type and you're dealing with deserialization failures on replay. Database-backed state doesn't have this problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration pattern is straightforward: for each saga, create a &lt;code&gt;@Component&lt;/code&gt; event handler class, move the logic from &lt;code&gt;@SagaEventHandler&lt;/code&gt; methods to &lt;code&gt;@EventHandler&lt;/code&gt; methods, and replace &lt;code&gt;SagaLifecycle.associateWith()&lt;/code&gt; / &lt;code&gt;end()&lt;/code&gt; with explicit database state management.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 6: Event Tagging for State Reconstruction
&lt;/h3&gt;

&lt;p&gt;This was the most surprising required change. Axon 5 needs &lt;code&gt;@EventTag&lt;/code&gt; annotations on events for the framework to route events to the correct entity during replay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;ClubCreatedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@EventTag&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"clubId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;clubId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the aggregate must declare which tag key it uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@EventSourced&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@EventSourcedEntity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tagKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"clubId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ClubAggregate&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, &lt;code&gt;given().events(...)&lt;/code&gt; in tests silently fails to reconstruct state. It took us a while to figure out why test fixtures weren't replaying events.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 7: Test Fixture Overhaul
&lt;/h3&gt;

&lt;p&gt;The test API changed significantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Axon 4&lt;/span&gt;
&lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;givenNoPriorActivity&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;expectEvents&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedEvent&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Axon 5&lt;/span&gt;
&lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;given&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;noPriorActivity&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;events&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedEvent&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other test changes: &lt;code&gt;CommandGateway.send()&lt;/code&gt; now returns &lt;code&gt;CommandResult&lt;/code&gt; instead of &lt;code&gt;CompletableFuture&lt;/code&gt;, &lt;code&gt;EventGateway.publish()&lt;/code&gt; takes a &lt;code&gt;List&amp;lt;?&amp;gt;&lt;/code&gt;, and &lt;code&gt;queryMany()&lt;/code&gt; replaces &lt;code&gt;ResponseTypes.multipleInstancesOf()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The new &lt;code&gt;AxonTestFixture&lt;/code&gt; also requires explicit &lt;code&gt;fixture.stop()&lt;/code&gt; in cleanup — without it you'll leak resources across tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;We completed the migration and now run Axon 5 in production. The &lt;code&gt;.block()&lt;/code&gt; calls are gone, event handlers are truly reactive, and the saga-to-stateful-handler refactoring actually improved our architecture by making process state explicit and queryable.&lt;/p&gt;

&lt;p&gt;The migration was substantial — we wrote shell scripts to automate the mechanical parts (package renames, annotation swaps) and tackled the architectural changes (sagas, command handler pattern, event tagging) one module at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We'd Recommend
&lt;/h2&gt;

&lt;p&gt;If you're considering the same migration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Automate the mechanical work.&lt;/strong&gt; Package relocations and annotation renames account for the majority of compilation errors but are trivially scriptable. Don't do these by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tackle sagas early.&lt;/strong&gt; The move from sagas to stateful event handlers is the biggest architectural change. It's also the most valuable — you'll end up with a cleaner, more testable design. Start with a simple saga as a proof of concept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't forget &lt;code&gt;@EventTag&lt;/code&gt;.&lt;/strong&gt; This is underdocumented and will silently break your aggregate test fixtures. Every event needs the tag on its entity ID field, and every aggregate needs &lt;code&gt;@EventSourcedEntity(tagKey = ...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for the upcaster gap.&lt;/strong&gt; Upcasters are not available until Axon 5.2.0. If your event store has events that require upcasting, you'll need a strategy for this gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consider waiting for OpenRewrite recipes&lt;/strong&gt; if your codebase is large. Axon 5.1 is expected to ship with OpenRewrite recipes that automate the bulk of the transformation. For smaller codebases, manual migration with scripts is manageable.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Quick Reference: Key API Changes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;Axon 4&lt;/th&gt;
&lt;th&gt;Axon 5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Aggregate annotation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@Aggregate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@EventSourced(idType = X.class)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aggregate ID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@AggregateIdentifier&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Set in &lt;code&gt;@EventSourcingHandler&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Creation handler&lt;/td&gt;
&lt;td&gt;Constructor &lt;code&gt;@CommandHandler&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Static method + &lt;code&gt;EventAppender&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apply events&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AggregateLifecycle.apply()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EventAppender.append()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event versioning&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@Revision("1.0")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@Event(version = "1.0")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event routing&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@EventTag&lt;/code&gt; + &lt;code&gt;@EventSourcedEntity(tagKey)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metadata&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MetaData&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Metadata&lt;/code&gt; (lowercase 'd')&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response types&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ResponseType&amp;lt;R&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Class&amp;lt;R&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query for lists&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ResponseTypes.multipleInstancesOf()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;queryMany()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message context&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UnitOfWork&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ProcessingContext&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reactive gateway&lt;/td&gt;
&lt;td&gt;Extension module&lt;/td&gt;
&lt;td&gt;Native in core&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sagas&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@Saga&lt;/code&gt; + &lt;code&gt;@SagaEventHandler&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Stateful &lt;code&gt;@Component&lt;/code&gt; + &lt;code&gt;@EventHandler&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reconstitution&lt;/td&gt;
&lt;td&gt;Default constructor&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@EntityCreator&lt;/code&gt; no-args constructor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test fixture setup&lt;/td&gt;
&lt;td&gt;&lt;code&gt;new AggregateTestFixture&amp;lt;&amp;gt;(X.class)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AxonTestFixture.with(configurer, ...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test given/when/then&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fixture.given().when().expect()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fixture.given().when().command().then()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/AxonFramework/AxonFramework/releases/tag/axon-5.0.2" rel="noopener noreferrer"&gt;Axon Framework 5.0 Release Notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/AxonFramework/AxonFramework/blob/master/axon-5/api-changes.md" rel="noopener noreferrer"&gt;Axon 5 API Changes Reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.axoniq.io/axon-framework-reference/5.0/migration/prerequisites/" rel="noopener noreferrer"&gt;Axon 5 Migration Prerequisites&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.moderne.ai/blog/how-axon-framework-handles-breaking-changes-through-openrewrite" rel="noopener noreferrer"&gt;OpenRewrite Migration Approach (Moderne Blog)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cqrs</category>
      <category>java</category>
      <category>springboot</category>
      <category>axonframework</category>
    </item>
  </channel>
</rss>
