Skip to content

Make EntityListener a marker; add per-event listener interfaces - #4432

Merged
evanchooly merged 7 commits into
masterfrom
entity-listener-marker
Nov 5, 2026
Merged

evanchooly merged 7 commits into
masterfrom
entity-listener-marker

Conversation

@evanchooly

@evanchooly evanchooly commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

This is a prerequisite for #4431, which will be rebased onto it and should shrink considerably. It's a breaking change for anyone implementing EntityListener, and comes with an OpenRewrite recipe to migrate them.

The problem

  • EntityListener is opaque. Each of its four methods takes the Document, so Morphia can't tell whether a listener needs one, and has to build it (the problem Stream lifecycle entities unless a listener takes the Document (plan item 4.2) #4429 works around with a usesDocument() opt-in).
  • Which events a listener handles is declared twice: once by overriding the method, and again in hasAnnotation, which has to be kept in sync by hand. For @EntityListeners classes, hasAnnotation isn't even consulted: Morphia scans the annotated methods.
  • Global and per-entity listeners work differently: addInterceptor listeners override EntityListener's methods, while @EntityListeners classes declare annotated methods (and still have to implement EntityListener, because the annotation's value() requires it).

Change

  • EntityListener<T> is now an empty marker. It stays in morphia-annotations, since @EntityListeners refers to it.
  • Four new interfaces in core, dev.morphia: PrePersistListener<T>, PostPersistListener<T>, PreLoadListener<T> and PostLoadListener<T>, each with one method that takes the entity and the Datastore. Only preLoad also takes the Document, as it exists to inspect or change the raw document. Whether a listener needs the Document is now visible in its type.
  • A listener is called only with entities of its type argument: a PrePersistListener<Auditable> sees Auditable entities alone. The new internal TypedListeners works out each event's entity type once per listener class (a ClassValue), following generic superclasses and superinterfaces. A raw implementation, such as a lambda, sees every entity. Calls are direct, with no reflection, so a listener's exception reaches the caller as it is.
  • addInterceptor listeners are dispatched through TypedListeners. MorphiaDatastore.hasLifecycle checks the interfaces instead of hasAnnotation.
  • @EntityListeners classes can implement the interfaces too. EntityListenerAdapter calls a class's annotated methods, then its interface methods, on the same instance. Annotated methods keep working as before, Document parameter included.
  • ValidationExtension implements PrePersistListener<Object>.
  • Migrated in this repo: the test listeners in TestInterceptors, TestLifecycles and TestDatastore (as the recipe would). DocumentReaderTest and ListenerBenchmark used EntityListener as a lambda, which no longer compiles; they use an empty anonymous class.

Migration recipe

dev.morphia.rewrite.recipes.lifecycle.EntityListenerMigration, added to UpgradeToMorphia30. A method counts as a listener method only if it overrides one of EntityListener's old methods, directly, through superclasses, or by erasure, so unrelated overloads are left alone. The recipe:

  • removes hasAnnotation overrides;
  • moves each overriding prePersist/postPersist/preLoad/postLoad to its interface, retyping the last parameter as Datastore whether it was Datastore, Object (current 3.0 snapshot) or the deprecated 2.x Mapper:
    • a class implementing EntityListener<T> gets, e.g., implements PrePersistListener<T>, PreLoadListener<T> instead;
    • a subclass's overrides are migrated too, and it adds the interface of any event its superclass doesn't migrate;
    • an anonymous listener that handles one event becomes new PrePersistListener<T>() {…} (or whichever event);
    • the interfaces take the EntityListener<X> type argument when there is one, resolved through superclasses and superinterfaces when it's inherited;
  • replaces a lambda implementing EntityListener, which could only implement hasAnnotation, with an empty anonymous marker (new EntityListener<>() {});
  • drops the event annotation from a migrated method, so an @EntityListeners class isn't called twice for it;
  • updates calls to methods it migrated in the same file, including super calls, dropping the Document argument when it's a plain name or a literal;
  • leaves classes with only annotated methods implementing the marker;
  • is idempotent.

It marks, with a search result explaining what to do, what it can't migrate: a prePersist, postPersist or postLoad that reads its Document (passing it on to another migrated call doesn't count), a method that uses its Mapper parameter, an anonymous listener with several events or a superclass, a method reference implementing EntityListener, an event overridden more than once (such as the 2.x Mapper and Datastore overloads), a listener inheriting old event methods from a class that isn't a listener, and calls it can't safely rewrite (through an EntityListener reference, to a method in another file, with a Document argument that does something, or to hasAnnotation).

Tests

  • New TestTypedListeners, for both mappers:
    • an addInterceptor listener gets all four events, with the Document only in preLoad;
    • listeners see only the entities their type argument accepts, including through a generic superclass, and do see such an entity embedded in another;
    • a lambda sees every entity, including one typed for a narrower entity (which then fails its cast), while a typed anonymous class keeps its type argument;
    • a listener's exception reaches the caller unwrapped;
    • an @EntityListeners class with both an annotated method and an interface method has both called, on one instance;
    • an entity implementing PostLoadListener<Self> is called;
    • the entity type is found through a generic superclass and through an enclosing type (Outer<Plain>.Inner).
  • New EntityListenerMigrationTest (21 cases): migrating several events, removing an event annotation, flagging a method that reads the Document, retyping an unused 2.x Mapper parameter, leaving annotated-only listeners on the marker, a single-event anonymous listener, flagging a multi-event anonymous listener, subclass overrides, updating and flagging calls, leaving unrelated overloads alone, lambdas, method references, the EntityListener<X> type argument with an erased override, an event overridden twice, an unchanged preLoad's annotation, events inherited from a non-listener (also past an unrelated overload), a lambda in a file that doesn't import EntityListener, an inherited type argument, a subclass whose superclass event doesn't migrate, and a Document argument that does something.
  • core passes with -Dmorphia.mapper=critter and with reflection (1555 tests each); validation and Kotlin pass with both; all 194 rewrite tests pass; benchmarks compile.

Decisions to review

  1. No global way to change the Document around a save or after a load. PrePersistListener, PostPersistListener and PostLoadListener don't take it. Annotated methods on the entity or on an @EntityListeners class still can, so per-entity cases are covered. Document-taking variants can be added later without breaking anything if there's demand.
  2. The marker can't be sealed: it has to stay in morphia-annotations, and the interfaces need Datastore, which is in core. A class implementing only the marker compiles but does nothing; Configure global entity listeners with MorphiaConfig.listeners #4431 rejects such a class when it's configured.
  3. Entities can implement the interfaces. It falls out of EntityListenerAdapter handling entities and @EntityListeners classes alike, and it's tested, but it's a new capability to commit to. Easy to drop if unwanted.
  4. EntityListenerAdapter still implements the marker, only so it can be named in @EntityListeners (the critter parser tests do).
  5. Lambdas lose their type argument. A lambda's class implements the raw interface, so a lambda sees every entity, and (PrePersistListener<Plain>) (e, ds) -> … throws ClassCastException from its bridge method when another entity is saved. There's no reliable way to recover the type at runtime, and catching the exception would also hide a listener's own cast errors, so the interfaces' javadoc says to type lambdas <Object> (and check the type in the body) or use a named or anonymous class, which keeps its type argument. aTypedLambdaSeesEveryEntityToo and aTypedAnonymousClassKeepsItsTypeArgument pin both behaviors.
  6. Docs (life-cycle-methods in morphia-docs) will be updated once Configure global entity listeners with MorphiaConfig.listeners #4431 settles, to cover both changes together.

EntityListener is now an empty marker. Listeners implement one or more
of PrePersistListener, PostPersistListener, PreLoadListener and
PostLoadListener, and Morphia calls them for those events alone, only
with entities of the interface's type argument. Only PreLoadListener
takes the Document, so a listener's need for one is visible in its type.

hasAnnotation and the Document-taking default methods are gone. The
EntityListenerMigration recipe, part of UpgradeToMorphia30, moves
existing implementations to the new interfaces and marks the ones it
can't migrate.
Copilot AI balanced review requested due to automatic review settings November 5, 2026 03:03

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

Unresolved listener-migration defects can produce uncompilable code or silently disable callbacks, while incorrect generic type resolution can dispatch events to unintended entities.

6 open issues
What changed in this PR

This PR replaces Morphia’s callback-bearing EntityListener with a marker and four typed, per-event interfaces. It prepares lifecycle dispatch for configurable global listeners (#4431) and document-free streaming (#4429), while providing an OpenRewrite migration for the breaking API change.

Changes:

  • Adds typed event dispatch with cached generic-type resolution; only PreLoadListener receives a Document.
  • Supports interface callbacks alongside annotated callbacks and migrates validation, existing tests, and benchmarks.
  • Adds the listener migration recipe to UpgradeToMorphia30, with migration and lifecycle tests.
File Description
validation/​src/​main/​java/​dev/​morphia/​validation/​ValidationExtension.java Migrates validation to PrePersistListener.
rewrite/​src/​test/​java/​dev/​morphia/​rewrite/​recipes/​test/​lifecycle/​EntityListenerMigrationTest.java Tests automatic migration and manual-review markers.
rewrite/​src/​main/​resources/​META-INF/​rewrite/​rewrite.yml Registers migration in the 3.0 upgrade recipe.
rewrite/​src/​main/​java/​dev/​morphia/​rewrite/​recipes/​lifecycle/​EntityListenerMigration.java Converts legacy listener declarations to event interfaces.
core/​src/​test/​java/​dev/​morphia/​test/​TestDatastore.java Removes the obsolete listener predicate.
core/​src/​test/​java/​dev/​morphia/​test/​mapping/​codec/​DocumentReaderTest.java Replaces a marker-incompatible lambda.
core/​src/​test/​java/​dev/​morphia/​test/​lifecycle/​TestTypedListeners.java Tests typed dispatch, inheritance, exceptions, and callback combinations.
core/​src/​test/​java/​dev/​morphia/​test/​lifecycle/​TestLifecycles.java Migrates the validation test listener.
core/​src/​test/​java/​dev/​morphia/​test/​callbacks/​TestInterceptors.java Migrates the interceptor test to PrePersistListener.
core/​src/​main/​java/​dev/​morphia/​PrePersistListener.java Defines the document-free pre-persist callback.
core/​src/​main/​java/​dev/​morphia/​PreLoadListener.java Defines the document-aware pre-load callback.
core/​src/​main/​java/​dev/​morphia/​PostPersistListener.java Defines the document-free post-persist callback.
core/​src/​main/​java/​dev/​morphia/​PostLoadListener.java Defines the document-free post-load callback.
core/​src/​main/​java/​dev/​morphia/​MorphiaDatastore.java Detects lifecycle support through event interfaces.
core/​src/​main/​java/​dev/​morphia/​mapping/​Mapper.java Updates interceptor registration documentation.
core/​src/​main/​java/​dev/​morphia/​mapping/​lifecycle/​TypedListeners.java Resolves accepted entity types and dispatches callbacks directly.
core/​src/​main/​java/​dev/​morphia/​mapping/​lifecycle/​EntityListenerAdapter.java Invokes annotated and interface callbacks on the same target.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​EntityModel.java Integrates adapters and typed interceptor dispatch.
benchmarks/​src/​main/​java/​dev/​morphia/​benchmarks/​ListenerBenchmark.java Replaces the no-op listener lambda with a marker instance.
annotations/​src/​main/​java/​dev/​morphia/​EntityListener.java Reduces EntityListener to a documented marker interface.

Review effort: Balanced

- A method is migrated only if its chain of overridden methods leads to
  EntityListener's. Unrelated overloads (hasAnnotation(String), an
  annotated prePersist(MyEntity, Document, Datastore)) are left alone.
- Subclass overrides are migrated too; a subclass adds the interfaces of
  the events no superclass migrates.
- Anonymous listeners that handle one event are migrated; those with
  several, or that extend a class, are marked.
- Calls to methods migrated in the same file drop the Document argument;
  other calls, and calls to hasAnnotation, are marked. A Document only
  passed on to such a call no longer blocks a migration.
- The recipe is idempotent.
- TypedListeners binds type parameters from enclosing types.

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.

- Lambdas implementing EntityListener could only implement
  hasAnnotation, so they become an empty anonymous marker; method
  references are marked.
- Interfaces are parameterized by the EntityListener<X> type argument
  when there is one, not by a possibly erased method parameter.
- Overrides that match only by erasure are recognized.
- An event overridden more than once (e.g. 2.x Mapper and Datastore
  overloads) is marked rather than migrated to clashing signatures.
- An unchanged preLoad still has its @preload removed.
- A listener inheriting old event methods from a class that isn't a
  listener is marked.

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.

…uments

- An inherited EntityListener's type argument is resolved through
  superclasses and superinterfaces, substituting type variables.
- Lambda replacements use a diamond and import EntityListener.
- A subclass adds an event's interface unless its superclass's override
  migrates in the same file.
- A Document argument other than a name or a literal is never dropped:
  the call is marked instead.
- Inherited events are compared by signature, not by name.
- Calls already marked are left alone, keeping the recipe idempotent.

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

Human assessment is needed because listener-migration edge cases can leave uncompilable code, and typed-lambda dispatch can throw runtime type errors.

No open issues 🎉

6 resolved since last review

Review effort: Balanced

A lambda's class implements the raw interface, so a lambda sees every
entity, and one typed for a narrower entity fails in its bridge cast
when given another. The interfaces tell users to type lambdas Object or
use a named or anonymous class. Tests pin both behaviors.
@evanchooly
evanchooly merged commit 781829a into master Nov 5, 2026
4 checks passed
@evanchooly
evanchooly deleted the entity-listener-marker branch November 5, 2026 04:32
evanchooly added a commit that referenced this pull request Nov 5, 2026
With #4432 merged, MorphiaConfig.listeners() takes EntityListener
instances that implement the per-event listener interfaces, and
dispatches them through TypedListeners. In a config file,
morphia.listeners names classes, which EntityListenerConverter loads
and instantiates. The mapper uses the configured instances directly and
rejects one that implements none of the interfaces; copies share them.

GlobalListenerAdapter, the mapper's class loading and the name-based
copy sharing are gone, as are the changes to EntityListenerAdapter and
CritterMapper.
evanchooly added a commit that referenced this pull request Nov 5, 2026
MorphiaConfig.listeners() takes EntityListener instances implementing the per-event listener interfaces from #4432, run for every entity they accept through TypedListeners. In a config file, morphia.listeners names classes, which EntityListenerConverter loads and instantiates. The mapper rejects a listener implementing none of the interfaces, and copies share the instances. Codecs use the lifecycle encoder or decoder only when something handles that side's events for the entity. ValidationExtension documents the configuration, and Mapper.addInterceptor's deprecation points to it.
evanchooly added a commit that referenced this pull request Nov 5, 2026
#4432 and #4431 replaced the annotated global listeners this branch was built on, so this takes master's tree; the
streaming work is redone on top of it.
evanchooly added a commit that referenced this pull request Nov 6, 2026
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.
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