The Silent Metadata Gap: Why Your Axon 5 Events Have No User Attribution

When we migrated from Axon Framework 4 to 5, we carefully updated aggregates to use EventAppender, rewrote command handlers with static factory methods, and adjusted our event sourcing handlers. Everything compiled. Tests passed. Events flowed. Then during an internal QA round, we spotted something odd: the aggregate history timeline showed events with no author — just timestamps and event types floating in a void.
The root cause? Axon command metadata does not automatically propagate to events. And when you’re focused on the big Axon 5 migration changes, this is exactly the kind of thing that slips through.
The Setup: User Attribution via Command Metadata
Our architecture uses an AuthAwareCommandGateway — a thin wrapper around Axon’s CommandGateway that extracts the authenticated user from Spring Security’s reactive context and attaches userId and userName as command metadata:
@Service
@RequiredArgsConstructor
public class AuthAwareCommandGateway {
private final CommandGateway commandGateway;
private final ReactiveAuthenticationSupplier authenticationSupplier;
public <R> Mono<R> send(Object command, Class<R> resultType) {
return authenticationSupplier.getAuthentication()
.map(auth -> {
Metadata metadata = Metadata.with("userId", auth.getName());
String userName = extractUserName(auth);
if (userName != null && !userName.equals(auth.getName())) {
metadata = metadata.and("userName", userName);
}
return metadata;
})
.defaultIfEmpty(Metadata.emptyInstance())
.flatMap(metadata -> {
CommandResult result = commandGateway.send(command, metadata);
return Mono.fromFuture(result.resultAs(resultType));
});
}
}On the read side, our aggregate history service reads events from Axon Server via gRPC and extracts userId/userName from the event metadata to render a timeline:
● Record Updated — Petr — 5 minutes ago
● Record Created — Sofia — 23 hours agoExcept it was rendering:
● Record Updated — 5 minutes ago
● Record Created — 23 hours agoNo user. For any event. For any entity type.
The Root Cause: Two Separate Message Envelopes
The following diagram shows the full message flow from the GraphQL mutation down to the event store — and where the metadata gap occurs:
ReactiveSecurityContext GW->>GW: Attach metadata:
{userId, userName} GW->>Bus: dispatch(command + metadata) Bus->>Agg: handle(UpdateRecordCommand, EventAppender) Note over Agg: eventAppender.append(
new RecordUpdatedEvent(...)) rect rgb(255, 230, 230) Note over Agg,ES: ❌ WITHOUT CorrelationDataProvider
Event metadata = {} (empty!) end Agg->>ES: store event Note over ES: Event stored with
NO user attribution
In Axon Framework, a command and the events it produces are fundamentally different messages with separate metadata envelopes. When your aggregate’s command handler creates events via EventAppender.append():
@CommandHandler
public void handle(UpdateRecordCommand command, EventAppender eventAppender) {
eventAppender.append(new RecordUpdatedEvent(command.recordId(), command.data()));
}The RecordUpdatedEvent starts with a fresh, empty metadata map. The userId and userName that were attached to the UpdateRecordCommand stay on the command — they don’t magically transfer to the event.
This is by design. Not all command metadata should be persisted into the event store. Auth tokens, tracing correlation IDs, retry counters — you probably don’t want those stored permanently with every event. Axon makes metadata propagation an explicit, opt-in concern.
The Fix: CorrelationDataProvider
Axon provides the CorrelationDataProvider interface specifically for this purpose. It answers: “When a message is being handled, which metadata keys should be copied from the incoming message to any messages produced during handling?”
The simplest implementation is SimpleCorrelationDataProvider, which copies named metadata keys:
@Configuration
public class AxonCorrelationDataConfig {
@Bean
public CorrelationDataProvider userMetadataCorrelationDataProvider() {
return new SimpleCorrelationDataProvider("userId", "userName");
}
}That’s it. One bean. Axon 5’s CorrelationDataProviderAutoConfiguration automatically discovers CorrelationDataProvider beans in the Spring context and registers them with the CorrelationDataProviderRegistry. From that point on, every event produced while handling a command that carries userId or userName metadata will inherit those values.
How It Works Under the Hood
When a command arrives at the aggregate, Axon’s infrastructure wraps the handling in a ProcessingContext. Before the command handler executes, the CorrelationDataInterceptor extracts correlation data from the incoming command using all registered providers. When EventAppender.append() is called, the appended event’s metadata is enriched with the correlation data before it’s committed to the event store.
{userId, userName} Bus->>CDP: intercept before handling Note over CDP: Extract correlation keys
from command metadata CDP->>Agg: handle(UpdateRecordCommand, EventAppender) Agg->>EA: append(RecordUpdatedEvent) rect rgb(230, 255, 230) Note over EA: ✅ Enrich event metadata
with correlation data:
{userId, userName} end EA->>ES: store event with full metadata Note over ES: Event stored WITH
user attribution ✅
Why This Was Hard to Catch
Several factors made this bug particularly stealthy:
No error, no exception. Events are stored perfectly fine with empty metadata. The aggregate works. Projections update. The only symptom is missing user attribution in the UI, which is easy to overlook during development.
Axon 4 had the same requirement. This isn’t new to Axon 5. In Axon 4, you also needed a
CorrelationDataProvider— typically configured viaConfigurer.configureCorrelationDataProviders(). If you never configured one in Axon 4 either, you never had user attribution in events, and the migration wouldn’t change that.The command gateway “works.”
AuthAwareCommandGatewaycorrectly attaches metadata to commands. Logging confirms it. If you debug the command dispatching, everything looks right. The gap is invisible until you inspect the stored events in Axon Server.Tests mock the event store. Unit tests for command handlers verify that the correct events are emitted, but they don’t test metadata propagation — that’s infrastructure behavior outside the aggregate’s responsibility.
Beyond User Identity: What Else to Propagate
Once you have the pattern established, consider propagating other cross-cutting concerns:
@Bean
public CorrelationDataProvider auditCorrelationDataProvider() {
return new SimpleCorrelationDataProvider(
"userId",
"userName",
"traceId", // Distributed tracing correlation
"sourceSystem" // Web UI vs API vs scheduled job
);
}The traceId is particularly valuable for correlating events back to the original HTTP request in distributed tracing systems like OpenTelemetry. And sourceSystem lets you distinguish whether a change was made through the web UI, a scheduled job, or an API integration — useful for both audit trails and debugging.
Key Takeaways
Command metadata does not propagate to events automatically. This is true in both Axon 4 and 5. Register a
CorrelationDataProviderbean for any metadata keys you want on your events.Silent data loss is the worst kind of bug. No exceptions, no failed tests — just empty fields in your UI. Add assertions in integration tests that verify event metadata contains expected keys.
Axon 5’s Spring Boot autoconfiguration is your friend. Just define
CorrelationDataProviderbeans and they’ll be picked up automatically — no manualConfigurercalls needed.Historical events won’t be retroactively fixed. Events already stored without user metadata stay that way. Only new events produced after the fix will carry user attribution. If you need retroactive attribution, you’d need to upcaste historical events — or accept the gap.
Cover photo by Conny Schneider on Unsplash


