Repository navigation
Make EntityListener a marker; add per-event listener interfaces - #4432
Conversation
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.
There was a problem hiding this comment.
🟡 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
Migrate anonymous listener implementations · New Migrate inherited listener interfaces and subclass overrides · New Update calls to listener methods when rewriting declarations · New Remove only the retired Class-parameter hasAnnotation overload · New Match only valid listener signatures before migrating annotated methods · New Resolve generic bindings from parameterized owner types · New
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
PreLoadListenerreceives aDocument. - 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.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved migration defects can break compilation through incorrect generic types, signature collisions, and unhandled functional listeners, while inherited or annotated callbacks may be silently disabled or executed twice.
5 open issues
Migrate or flag functional EntityListener implementations · New Preserve the resolved EntityListener type argument · New Detect signature collisions before migrating listener overloads · New Remove event annotation when Datastore parameter is already present · New Handle inherited listener implementations without interface override chains · New
6 resolved since last review
Match only valid listener signatures before migrating annotated methods Remove only the retired Class-parameter hasAnnotation overload Update calls to listener methods when rewriting declarations Migrate inherited listener interfaces and subclass overrides Migrate anonymous listener implementations Resolve generic bindings from parameterized owner types
Review effort: Balanced
- 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.
There was a problem hiding this comment.
🟡 Changes recommended
The migration can produce uncompilable code through missing imports and incorrect generic types, leave inherited callbacks unmigrated, and silently discard document mutations.
6 open issues
Resolve inherited marker type arguments before generating interfaces · New Add the EntityListener import for generated lambda replacements · New Preserve target type arguments in fallback listener generation · New Add the event interface when the superclass event does not migrate · New Flag document argument expressions that would be discarded · New Match inherited callback overrides by signature, not method name · New
5 resolved since last review
Detect signature collisions before migrating listener overloads Preserve the resolved EntityListener type argument Migrate or flag functional EntityListener implementations Handle inherited listener implementations without interface override chains Remove event annotation when Datastore parameter is already present
Review effort: Balanced
…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.
There was a problem hiding this comment.
🔵 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
Flag document argument expressions that would be discarded Add the event interface when the superclass event does not migrate Preserve target type arguments in fallback listener generation Add the EntityListener import for generated lambda replacements Resolve inherited marker type arguments before generating interfaces Match inherited callback overrides by signature, not method name
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.
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.
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.
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.


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
EntityListeneris opaque. Each of its four methods takes theDocument, 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 ausesDocument()opt-in).hasAnnotation, which has to be kept in sync by hand. For@EntityListenersclasses,hasAnnotationisn't even consulted: Morphia scans the annotated methods.addInterceptorlisteners overrideEntityListener's methods, while@EntityListenersclasses declare annotated methods (and still have to implementEntityListener, because the annotation'svalue()requires it).Change
EntityListener<T>is now an empty marker. It stays inmorphia-annotations, since@EntityListenersrefers to it.dev.morphia:PrePersistListener<T>,PostPersistListener<T>,PreLoadListener<T>andPostLoadListener<T>, each with one method that takes the entity and theDatastore. OnlypreLoadalso takes theDocument, as it exists to inspect or change the raw document. Whether a listener needs theDocumentis now visible in its type.PrePersistListener<Auditable>seesAuditableentities alone. The new internalTypedListenersworks out each event's entity type once per listener class (aClassValue), 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.addInterceptorlisteners are dispatched throughTypedListeners.MorphiaDatastore.hasLifecyclechecks the interfaces instead ofhasAnnotation.@EntityListenersclasses can implement the interfaces too.EntityListenerAdaptercalls a class's annotated methods, then its interface methods, on the same instance. Annotated methods keep working as before,Documentparameter included.ValidationExtensionimplementsPrePersistListener<Object>.TestInterceptors,TestLifecyclesandTestDatastore(as the recipe would).DocumentReaderTestandListenerBenchmarkusedEntityListeneras a lambda, which no longer compiles; they use an empty anonymous class.Migration recipe
dev.morphia.rewrite.recipes.lifecycle.EntityListenerMigration, added toUpgradeToMorphia30. A method counts as a listener method only if it overrides one ofEntityListener's old methods, directly, through superclasses, or by erasure, so unrelated overloads are left alone. The recipe:hasAnnotationoverrides;prePersist/postPersist/preLoad/postLoadto its interface, retyping the last parameter asDatastorewhether it wasDatastore,Object(current 3.0 snapshot) or the deprecated 2.xMapper:EntityListener<T>gets, e.g.,implements PrePersistListener<T>, PreLoadListener<T>instead;new PrePersistListener<T>() {…}(or whichever event);EntityListener<X>type argument when there is one, resolved through superclasses and superinterfaces when it's inherited;EntityListener, which could only implementhasAnnotation, with an empty anonymous marker (new EntityListener<>() {});@EntityListenersclass isn't called twice for it;supercalls, dropping theDocumentargument when it's a plain name or a literal;It marks, with a search result explaining what to do, what it can't migrate: a
prePersist,postPersistorpostLoadthat reads itsDocument(passing it on to another migrated call doesn't count), a method that uses itsMapperparameter, an anonymous listener with several events or a superclass, a method reference implementingEntityListener, an event overridden more than once (such as the 2.xMapperandDatastoreoverloads), a listener inheriting old event methods from a class that isn't a listener, and calls it can't safely rewrite (through anEntityListenerreference, to a method in another file, with aDocumentargument that does something, or tohasAnnotation).Tests
TestTypedListeners, for both mappers:addInterceptorlistener gets all four events, with theDocumentonly inpreLoad;@EntityListenersclass with both an annotated method and an interface method has both called, on one instance;PostLoadListener<Self>is called;Outer<Plain>.Inner).EntityListenerMigrationTest(21 cases): migrating several events, removing an event annotation, flagging a method that reads theDocument, retyping an unused 2.xMapperparameter, 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, theEntityListener<X>type argument with an erased override, an event overridden twice, an unchangedpreLoad's annotation, events inherited from a non-listener (also past an unrelated overload), a lambda in a file that doesn't importEntityListener, an inherited type argument, a subclass whose superclass event doesn't migrate, and aDocumentargument that does something.-Dmorphia.mapper=critterand with reflection (1555 tests each); validation and Kotlin pass with both; all 194 rewrite tests pass; benchmarks compile.Decisions to review
Documentaround a save or after a load.PrePersistListener,PostPersistListenerandPostLoadListenerdon't take it. Annotated methods on the entity or on an@EntityListenersclass still can, so per-entity cases are covered. Document-taking variants can be added later without breaking anything if there's demand.morphia-annotations, and the interfaces needDatastore, 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.EntityListenerAdapterhandling entities and@EntityListenersclasses alike, and it's tested, but it's a new capability to commit to. Easy to drop if unwanted.EntityListenerAdapterstill implements the marker, only so it can be named in@EntityListeners(the critter parser tests do).(PrePersistListener<Plain>) (e, ds) -> …throwsClassCastExceptionfrom 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.aTypedLambdaSeesEveryEntityTooandaTypedAnonymousClassKeepsItsTypeArgumentpin both behaviors.life-cycle-methodsinmorphia-docs) will be updated once Configure global entity listeners with MorphiaConfig.listeners #4431 settles, to cover both changes together.