Skip to content

fix(messaging): align QualifiedName(Class) naming with annotation defaults for nested classes#4630

Open
MateuszNaKodach wants to merge 2 commits into
axon-5.1.xfrom
fix/MessageType_nestedclass
Open

fix(messaging): align QualifiedName(Class) naming with annotation defaults for nested classes#4630
MateuszNaKodach wants to merge 2 commits into
axon-5.1.xfrom
fix/MessageType_nestedclass

Conversation

@MateuszNaKodach

@MateuszNaKodach MateuszNaKodach commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Problem

The framework derives a message's qualified name from a Class through three paths — and they disagreed for nested classes:

Path Derivation pkg.Outer.Inner became
new QualifiedName(Class) / new MessageType(Class) Class.getName() pkg.Outer$Inner
@Command / @Event annotation defaults (AnnotationMessageTypeResolver) getPackageName() + getSimpleName() pkg.Inner
ClassBasedMessageTypeResolver Class.getName()contradicting its own javadoc, which documents getPackageName() + getSimpleName() pkg.Outer$Inner

For top-level classes all three coincide, so the inconsistency stays invisible — until a payload class is nested.

Worth highlighting: ClassBasedMessageTypeResolver's own usage was different from the constructor sitting right next to it. It did not call new MessageType(payloadType, version) — it spelled the name itself through the String-based constructor:

return Optional.of(new MessageType(payloadType.getName(), version));   // binary name, with '$'

which is exactly how its implementation drifted from its javadoc: the documented policy (getPackageName() + getSimpleName()) lived in the Class-based constructor's intent, while the resolver bypassed it with a hand-spelled getName() string.

How it manifested

Any QualifiedName-keyed comparison built with new MessageType(MyNested.class).qualifiedName() could never match a message dispatched through annotation-based naming. There is no error — the comparison is just silently false.

Concrete case that surfaced this: a MessageHandlerInterceptor matching commands by qualified name,

public QualifiedName supportedCommand() {
    return new MessageType(MyCommand.class).qualifiedName();   // pkg.Outer$MyCommand
}

against a dispatched @Command-annotated nested record (pkg.MyCommand). The interceptor silently no-opped: commands that should have been rejected succeeded. The identical resolver pattern worked for a top-level command class purely by luck of class placement — which is exactly what makes this such a sharp edge.

This is particularly easy to hit from Kotlin, where nesting commands/events in sealed hierarchies is idiomatic (sealed interface DwellingCommand { data class Build(..) : DwellingCommand } — every member is a nested class).

Even this repository's own test suite contained the footgun: AsyncMessageHandlerTest's declarative registrations subscribed handlers via new QualifiedName(CheckIfPrime.class.getName()) while dispatching through a resolver.

Workaround needed before this fix

Application code had to avoid the Class-based constructors for nested classes and hand-build the annotation-compatible spelling:

// instead of: new MessageType(MyCommand.class).qualifiedName()
new QualifiedName(MyCommand.class.getPackageName(), MyCommand.class.getSimpleName());

— provided the developer realised the mismatch existed at all, since nothing fails loudly.

The workaround was in fact already visible in this repository's own tests, where authors sidestepped the Class-based constructor rather than relying on it:

  • DefaultCommandGatewayTest defines an ad-hoc resolver instead of the Class-derived name:
    payloadType -> Optional.of(new MessageType(payloadType.getSimpleName()));
  • AsyncMessageHandlerTest's declarative registrations subscribed with the raw binary-name string to match the dispatch spelling of the day:
    commandBus.subscribe(new QualifiedName(CheckIfPrime.class.getName()), ...);

Both are symptoms of the same root cause: the Class-based constructor couldn't be trusted to produce the name a message would actually carry.

Fix

Single-source the naming policy and make every Class-derived path agree with the annotation defaults:

  • QualifiedName(Class) now combines getPackageName() and getSimpleName() (delegating to the existing combineNames), aligning with the @Command/@Event defaults. Primitive-wrapper resolution is preserved. Classes without a simple name (e.g. anonymous classes) fall back to Class.getName(). Javadoc updated.
  • ClassBasedMessageTypeResolver now routes through the Class-based MessageType constructor — a one-line change that makes the implementation finally match its documented behavior.
  • AsyncMessageHandlerTest declarative registrations updated to the Class-based constructor.
  • AnnotationMessageTypeResolver (second commit) no longer spells its class-derived defaults (getPackageName()/getSimpleName()) itself — it derives them from QualifiedName(Class), so the naming policy now lives in exactly one place. Partial annotation overrides (namespace without name, and vice versa) overlay the same class-derived name as every other path. Behavior is unchanged for regular classes (pinned by new default-name tests); the exotic edge of an annotated type without a simple name now falls back to the binary-name-derived local name instead of failing on an empty local name.

After the change, new MessageType(Class), ClassBasedMessageTypeResolver, and the annotation defaults produce the same name for the same class — nested or not — and all of them derive it from the single policy in QualifiedName(Class). The String-based constructors remain verbatim by design.

Tests

Written TDD — they fail on the previous implementation with:

expected: <org.axonframework.messaging.core.NestedPayload>
 but was: <org.axonframework.messaging.core.MessageTypeTest$NestedPayload>

See MessageTypeTest.WithNestedClasses:

  • classConstructorUsesPackageAndSimpleNameForNestedClasses — pins the concrete namespace/localName/name for a nested payload.
  • classConstructorMatchesAnnotationBasedNamingForNestedClasses — pins the contract: MessageType(Class) equals the annotation-default spelling.
  • classConstructorMatchesAnnotationBasedNamingForTopLevelClasses — documents that top-level behavior is unchanged.

And AnnotationMessageTypeResolverTest.CommandMessageResolution:

  • classAnnotatedWithCommandWithoutNameAndNamespaceDefaultsToQualifiedNameOfClass — pins that the annotation defaults equal QualifiedName(Class).
  • classAnnotatedWithCommandWithNamespaceOnlyDefaultsNameToLocalNameOfClass — pins the partial-override case (namespace overridden, name defaulted).

Verification

  • messaging: 3530 tests ✅ (2 unrelated PooledStreamingEventProcessorTest timing flakes pass in isolation)
  • eventsourcing: 489 ✅, modelling: 370 ✅, conversion: 174 ✅
  • The only fallout in ~4,500 tests was the AsyncMessageHandlerTest registration mentioned above — itself a demonstration of the footgun.

Notes for review

  • Behavioral change / stored data: nested, un-annotated payload classes resolved through ClassBasedMessageTypeResolver change their name from pkg.Outer$Inner to pkg.Inner. Events already persisted under the old spelling will no longer match handlers/criteria without a mapping — needs a release note and/or migration guidance. The alternative direction (making the annotation defaults include the enclosing class) was considered, but would change the documented, intentional @Command/@Event behavior instead.
  • Collisions: two nested classes with the same simple name in the same package now map to the same name — the same trade-off the annotation defaults already made; explicit @Command/@Event name/namespace attributes remain the escape hatch.
  • Kotlin: KClass.qualifiedName renders nested classes with dots (pkg.Outer.Inner) and matches neither spelling — Kotlin users should pass ::class.java. A small Kotlin-extension helper (KClass.toQualifiedName()) could be a follow-up.
  • Follow-up candidate: single-source AnnotationMessageTypeResolver's defaultsdone in this PR (second commit); the consolidation is complete.

…aults for nested classes

The framework derived two different qualified names for the same nested class, depending on the path:

- QualifiedName(Class) - and thus MessageType(Class) - used Class.getName(), producing
  "pkg.Outer$Inner" for nested classes.
- Annotation-based resolution (@Command/@event defaults via AnnotationMessageTypeResolver) uses
  getPackageName() + getSimpleName(), producing "pkg.Inner".
- ClassBasedMessageTypeResolver documented getPackageName() + getSimpleName(), but implemented
  Class.getName(), contradicting its own javadoc for nested classes.

As a result, a MessageType built from a nested payload class could never match messages dispatched
through annotation-based naming - a silent mismatch, e.g. a QualifiedName-keyed interceptor or handler
lookup that simply never matches. Nested payload classes are idiomatic in Kotlin (sealed command/event
hierarchies), making this especially easy to hit there.

Changes:
- QualifiedName(Class) now combines getPackageName() and getSimpleName(), aligning with the annotation
  defaults. Primitive wrapper resolution is preserved. Classes without a simple name (e.g. anonymous
  classes) fall back to Class.getName(). Javadoc updated accordingly.
- ClassBasedMessageTypeResolver now routes through the Class-based MessageType constructor, making the
  implementation match its documented behavior.
- MessageTypeTest gains nested-class tests (written first, TDD) pinning that MessageType(Class) equals
  the package + simple name spelling for nested and top-level classes.
- AsyncMessageHandlerTest's declarative registrations used the raw Class.getName() String and are
  updated to the Class-based QualifiedName constructor.

Verified: messaging (3530 tests), eventsourcing (489), modelling (370), and conversion (174) suites pass.

Notes for review:
- Behavioral change: nested, un-annotated payload classes resolved through ClassBasedMessageTypeResolver
  change their name from "pkg.Outer$Inner" to "pkg.Inner". Events already stored under the old spelling
  will no longer match handlers/criteria without a mapping - needs a release note or migration guidance.
- Nested classes with the same simple name in the same package now collide, mirroring the trade-off the
  annotation defaults already made; explicit @Command/@event name/namespace attributes remain the escape
  hatch.
- String-based constructors (QualifiedName(String), MessageType(String, ...)) remain verbatim by design.
  Kotlin users should pass ::class.java rather than KClass.qualifiedName, which renders nested classes
  with a dotted spelling that does not match either.
@MateuszNaKodach MateuszNaKodach requested a review from a team as a code owner June 3, 2026 21:43
@MateuszNaKodach MateuszNaKodach requested review from hatzlj, smcvb and zambrovski and removed request for a team June 3, 2026 21:43
@MateuszNaKodach MateuszNaKodach self-assigned this Jun 3, 2026
@MateuszNaKodach MateuszNaKodach added Type: Bug Use to signal issues that describe a bug within the system. Priority 2: Should High priority. Ideally, these issues are part of the release they’re assigned to. labels Jun 3, 2026
@MateuszNaKodach MateuszNaKodach added this to the Release 5.1.2 milestone Jun 3, 2026
@MateuszNaKodach MateuszNaKodach requested a review from hjohn June 3, 2026 21:56
…nnotationMessageTypeResolver

The annotation-based resolver spelled its class-derived defaults (getPackageName() / getSimpleName())
itself, duplicating the naming policy that QualifiedName(Class) owns. Derive the defaults from
QualifiedName(Class) instead, so the policy lives in exactly one place and partial annotation overrides
(namespace without name, and vice versa) overlay the same class-derived name as every other path.

Behavior is unchanged for regular classes, pinned by the new default-name tests in
AnnotationMessageTypeResolverTest. For the exotic edge of an annotated type without a simple name, the
local name now falls back to the binary-name-derived local name instead of failing on an empty local name.

@hjohn hjohn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure is the right trade-off?

two nested classes with the same simple name in the same package now map to the same name

I think if we're going to qualify the class with the entire package structure, we should also qualify it with the outer type, like how Java would do it, and not create some "new" not-Java scheme that allows collisions.

@MateuszNaKodach

Copy link
Copy Markdown
Contributor Author

Are we sure is the right trade-off?

two nested classes with the same simple name in the same package now map to the same name

I think if we're going to qualify the class with the entire package structure, we should also qualify it with the outer type, like how Java would do it, and not create some "new" not-Java scheme that allows collisions.

I made the behaviors consistent, but you're right - we should discuss what to do in this case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Priority 2: Should High priority. Ideally, these issues are part of the release they’re assigned to. Type: Bug Use to signal issues that describe a bug within the system.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants