Skip to content

Make annotated lifecycle methods entity-only (plan item 4.14) - #4434

Merged
evanchooly merged 4 commits into
masterfrom
entity-only-lifecycle-methods
Nov 6, 2026
Merged

evanchooly merged 4 commits into
masterfrom
entity-only-lifecycle-methods

Conversation

@evanchooly

Copy link
Copy Markdown
Member

Plan item 4.14: annotated lifecycle methods (@PrePersist, @PostPersist, @PreLoad, @PostLoad) become entity-only. Follows #4432, #4431 and #4429.

Changes

  • Core
    • An @EntityListeners class must implement at least one per-event interface and may not declare annotated lifecycle methods. Mapping fails with a MappingException that names the method and points to the interfaces, rather than silently ignoring it.
    • @EntityListeners instances are created when the entity's model is set up, not on first use, and are called through TypedListeners with no reflection.
    • EntityListenerAdapter now handles only the entity's own methods; OnEntityListenerAdapter is folded into it.
    • A listener constructor that throws is reported with its cause, not as a missing no-arg constructor.
    • Javadoc on EntityListener, @EntityListeners and the four event annotations says so.
  • Recipe: new AnnotatedListenerMethodMigration, in UpgradeToMorphia30 right after EntityListenerMigration. For each class listed in @EntityListeners, or implementing EntityListener, that isn't itself an entity:
    • it implements each event's interface (replacing a directly implemented EntityListener), with a method that calls the class's annotated methods for the event in declaration order, and removes the annotations;
    • the type argument is the class's EntityListener<T> argument, else the most specific entity parameter type, else Object;
    • a method already shaped like the interface's is reused;
    • a method annotated for several events is called from each.
    • It marks: a @PrePersist method that uses its Document (always empty), a checked exception, an entity parameter the type argument doesn't fit, a name clash with the generated method, an event the class already implements, unrelated entity types, and a listener whose superclass is a listener or has annotated methods.

Decisions to review

  1. Removed in 3.0, not deprecated. 3.0 already breaks listeners and ships a recipe for them. With 4.2's Document listeners every annotated method has an interface to move to, so the plan's expected cost (Document-using methods with nowhere to go) no longer applies.
  2. The recipe delegates rather than rewriting methods. The generated interface method calls the existing methods, so their names, parameter order, visibility and return values stay as they are, and callers are untouched.
  3. Inherited listeners are marked, not migrated. A generated method would override the superclass's, or two classes in a hierarchy would implement one interface with different type arguments.
  4. Event masks use the listener's type argument. A model sets an event bit only when an @EntityListeners instance may accept it or a subtype. A PostLoadDocumentListener<Ring> on Figure no longer gives the sibling Block a lifecycle codec at all (TestLifecycleStreaming updated).

Testing

  • Core with critter and reflection; Kotlin, validation and rewrite (217 tests, 15 new).

  • Local Copilot CLI review over four rounds before pushing. Valid findings fixed:

    • inherited listener superclasses;
    • constructor errors;
    • a second interface for an implemented event;
    • multi-event methods.

    Static annotated methods were never called and aren't rejected, so they're left alone.

The morphia-docs pages are updated after this merges.

An @EntityListeners class now implements the per-event listener interfaces only.
Mapping rejects one that declares @PrePersist/@PostPersist/@PreLoad/@PostLoad
methods or implements none of the interfaces. Its instances are created when
the model is set up and called through TypedListeners, so EntityListenerAdapter
handles just the entity's own methods (OnEntityListenerAdapter is folded in).

The new AnnotatedListenerMethodMigration recipe, part of UpgradeToMorphia30,
makes such a class implement each event's interface with a method that calls
its annotated methods, and marks what it can't migrate.
Copilot AI balanced review requested due to automatic review settings November 5, 2026 21:38

Copilot AI 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.

🟡 Changes recommended

The migration can generate uncompilable listener classes, invoke the wrong callback overload, and miss interface conflicts or inherited annotations that cause mapping failures.

5 open issues
What changed in this PR

Makes annotated lifecycle callbacks entity-only for Morphia 3.0, completing plan item 4.14. External listeners use per-event interfaces, with an OpenRewrite recipe to help migrate existing callbacks.

Changes:

  • Validates and creates external listeners during model setup, then dispatches them through TypedListeners.
  • Adds annotated-listener migration and regression tests.
  • Updates lifecycle documentation, test fixtures, and subtype event-mask expectations.
File Description
rewrite/​src/​test/​java/​dev/​morphia/​rewrite/​recipes/​test/​lifecycle/​AnnotatedListenerMethodMigrationTest.java Tests callback migration and manual-migration markers.
rewrite/​src/​main/​resources/​META-INF/​rewrite/​rewrite.yml Registers the new upgrade recipe.
rewrite/​src/​main/​java/​dev/​morphia/​rewrite/​recipes/​lifecycle/​EntityListenerMigration.java Exposes helpers shared by the new recipe.
rewrite/​src/​main/​java/​dev/​morphia/​rewrite/​recipes/​lifecycle/​AnnotatedListenerMethodMigration.java Migrates annotated callbacks to event interfaces.
design/​mapping-game-plan.md Records completion of item 4.14.
core/​src/​test/​java/​dev/​morphia/​test/​TestDatastore.java Converts a test listener to PrePersistListener.
core/​src/​test/​java/​dev/​morphia/​test/​models/​ExampleListener.java Adds a valid listener fixture for generator tests.
core/​src/​test/​java/​dev/​morphia/​test/​lifecycle/​TestTypedListeners.java Tests eager creation and listener validation.
core/​src/​test/​java/​dev/​morphia/​test/​lifecycle/​invalid/​InvalidListeners.java Adds invalid-listener fixtures.
core/​src/​test/​java/​dev/​morphia/​mapping/​codec/​pojo/​TestLifecycleStreaming.java Verifies unrelated subtypes need no lifecycle decoder.
core/​src/​test/​java/​dev/​morphia/​critter/​sources/​Example.java Uses the new listener fixture.
core/​src/​test/​java/​dev/​morphia/​critter/​parser/​generator/​TestGeneration.java Updates expected listener metadata.
core/​src/​main/​resources/​sofia.properties Adds and updates listener validation messages.
core/​src/​main/​java/​dev/​morphia/​mapping/​lifecycle/​OnEntityListenerAdapter.java Removes the separate entity callback adapter.
core/​src/​main/​java/​dev/​morphia/​mapping/​lifecycle/​EntityListenerAdapter.java Handles entity callbacks and validates external listeners.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​EntityModel.java Creates typed listeners and computes applicable event masks.
annotations/​src/​main/​java/​dev/​morphia/​EntityListener.java Documents the interface-only listener contract.
annotations/​src/​main/​java/​dev/​morphia/​annotations/​PrePersist.java Documents the entity-only annotation restriction.
annotations/​src/​main/​java/​dev/​morphia/​annotations/​PreLoad.java Documents the entity-only annotation restriction.
annotations/​src/​main/​java/​dev/​morphia/​annotations/​PostPersist.java Documents the entity-only annotation restriction.
annotations/​src/​main/​java/​dev/​morphia/​annotations/​PostLoad.java Documents the entity-only annotation restriction.
annotations/​src/​main/​java/​dev/​morphia/​annotations/​EntityListeners.java Documents listener interfaces and eager construction.

Review effort: Balanced

…ated overload

The recipe reuses a method as the interface's only if it returns void and the class
doesn't implement the event's other interface, recognizes interfaces through
subinterfaces and superclasses, marks a listener that only inherits annotated
methods from a class that isn't a listener, and casts the entity to the annotated
method's parameter type so an overload taking the entity's own type isn't called.

Copilot AI 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.

🟡 Changes recommended

The migration can produce uncompilable listener classes by dropping required type qualification, mishandling generic callbacks and method-scoped type variables, and overlooking inherited final-method collisions.

4 open issues
5 resolved since last review

Review effort: Balanced

…types; import cast types

The recipe marks an annotated method when a superclass already has the event's
interface method, when the method that would be reused is generic, or when its
entity parameter is the method's own type variable, and imports the type a cast
names.

Copilot AI 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.

🔵 Needs a closer look

Two critical migration defects generate uncompilable code through colliding type names in listener signatures and conflicting raw and parameterized EntityListener inheritance via subinterfaces.

No open issues 🎉

2 previously missed issues

In code that hasn't changed since last review

High severity Generated listener signatures use ambiguous type names

rewrite/​src/​main/​java/​dev/​morphia/​rewrite/​recipes/​lifecycle/​AnnotatedListenerMethodMigration.java:227

For EntityListener<com.acme.Document> with @PostLoad void check(com.acme.Document entity, org.bson.Document document), this generates postLoad(Document entity, Document document, Datastore datastore), although the two parameters require different classes. The migrated class cannot compile, and entities named Datastore have the same problem. Use collision-safe qualified type references in generated signatures and interface arguments, and add regression coverage for these names.

High severity Raw listener inheritance conflicts with parameterized event interface

rewrite/​src/​main/​java/​dev/​morphia/​rewrite/​recipes/​lifecycle/​AnnotatedListenerMethodMigration.java:283

For interface Audit extends EntityListener {} and Stamping implements Audit with @PostLoad void check(Order order), the recipe retains Audit and adds PostLoadListener<Order>. The result cannot compile because it inherits both raw EntityListener and EntityListener<Order>. Detect raw marker inheritance through subinterfaces and mark the class for manual migration before adding a parameterized event interface.

4 resolved since last review

Review effort: Balanced

…w EntityListener

The recipe marks a class when a type the generated code names by its simple name
is hidden by another of that name in the file, or when the class implements an
interface that inherits EntityListener raw or with another type argument.
@evanchooly

Copy link
Copy Markdown
Member Author

Copilot's third review flagged two missed cases in its summary; both are fixed in ded8924164:

  • Ambiguous type names: a class is now marked when a type the generated code names by its simple name (the entity type, a cast type, Document or Datastore) is hidden by another type of that name in the file. Test: flagsAnEntityTypeNamedLikeTheDocument.
  • Raw EntityListener through a subinterface: a class that implements an interface inheriting EntityListener raw, or with a type argument other than the event interfaces would get, is marked. Test: flagsAClassThatInheritsTheRawEntityListener.

This was the third review cycle, so I'm stopping the Copilot loop here.

@evanchooly
evanchooly merged commit 1ef42e8 into master Nov 6, 2026
4 checks passed
@evanchooly
evanchooly deleted the entity-only-lifecycle-methods branch November 6, 2026 00:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants