Skip to content

Added class loader option to the project#4159

Open
ThomasWega wants to merge 5 commits intoMorphiaOrg:2.5.xfrom
ThomasWega:2.5_classloader
Open

Added class loader option to the project#4159
ThomasWega wants to merge 5 commits intoMorphiaOrg:2.5.xfrom
ThomasWega:2.5_classloader

Conversation

@ThomasWega
Copy link

@ThomasWega ThomasWega commented Feb 12, 2026

Found this issue out when creating a Paper (Minecraft) plugin. Minecraft plugins each have their own ClassLoader. This makes it impossible for me to map classes as the class loader morphia is using is just a different one.

There was a workaround in earlier version as I was able to find here
https://stackoverflow.com/a/63698526
Even a suggested feature here: #1394

If I'm not mistaken, that's no longer the case. This pretty recent issue on github was a similar thing, but it suggested to use Thread.currentThread().getContextClassLoader() which 2.5.2 does I think, however that just does not solve my issue as you can see from this screenshot.
image
The classloader is just different.

I've tried adding the option to input custom classloader in the project. Take this more as a draft than a finished thing. I'm looking for feedback and will make any change you request. There is also still one place that uses the old classloader which I've marked with FIXME. I'm quite honestly not sure if all places require to use the custom class loader.

I have tested this version on my server with my project and it seems to work completely fine. It fixed the previous issues where no classes could be mapped and when they were mapped manually they would not resolve when being taken from the database.

I also would've done this change for 3.0.0 but I just wasn't able to get it to work with my maven. It's using the maven 4 which is in preview and seems like my PC is having issues loading it even though I've upgraded.

I'm also available on the discord server as ThomasWega if you want to discuss anything there

@ThomasWega ThomasWega marked this pull request as draft February 12, 2026 14:54
Copy link
Member

@evanchooly evanchooly left a comment

Choose a reason for hiding this comment

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

Thanks for the PR and the detailed write-up — the motivation is solid. Environments like Minecraft Paper plugins (and OSGi, etc.) where the context classloader doesn't match the application classloader are a real pain point, and Morphia should support custom classloaders.

That said, I found several issues with the current implementation that would need to be addressed before this can be merged.


Critical Issues

1. MorphiaConfigHelper reflection bug — will crash at runtime

The change in MorphiaConfigHelper.getEntry():

.getDeclaredConstructor(morphiaConfig.getClass())
.newInstance(morphiaConfig);

morphiaConfig.getClass() returns the runtime class (e.g., ManualMorphiaConfig), but all the converters declare their constructor parameter as MorphiaConfig (the interface). So getDeclaredConstructor(ManualMorphiaConfig.class) will throw NoSuchMethodException. This should be getDeclaredConstructor(MorphiaConfig.class).

2. SmallRye config loading is broken

The converters (ClassNameConverter, DiscriminatorFunctionConverter, NamingStrategyConverter, etc.) are referenced via @WithConverter annotations and instantiated by SmallRye Config using no-arg constructors. This PR removes all no-arg constructors by adding a required MorphiaConfig parameter. SmallRye won't be able to instantiate them, which would break MorphiaConfig.load() entirely.

3. Circular dependency in config loading

MorphiaConfig.load(path, classLoader) needs to create a config, but the converters used during loading now require a MorphiaConfig instance that doesn't exist yet — chicken-and-egg problem.

4. classLoader() on the MorphiaConfig interface is incompatible with SmallRye

MorphiaConfig is a @ConfigMapping interface. Adding ClassLoader classLoader() without @WithDefault means SmallRye would try to map it from the config file, but ClassLoader isn't a serializable config value. This needs to be excluded from SmallRye mapping or handled differently.


Design Issues

5. getClassLoader() on public Datastore interface

This is added without @MorphiaInternal, making it part of the public API contract. This is an implementation detail that shouldn't be exposed publicly — users shouldn't need to call datastore.getClassLoader().

6. DiscriminatorLookup holds a full Mapper reference

This introduces a bidirectional dependency: MapperDiscriminatorLookupMapper. It would be cleaner to pass just the ClassLoader or a Supplier<ClassLoader> rather than the entire Mapper.

7. Incomplete implementation (the FIXME)

As you noted, Conversions.java still uses the old classloader:

// FIXME still old classloader!
return Thread.currentThread().getContextClassLoader().loadClass(className);

8. ManualMorphiaConfig.classLoader() default is evaluated lazily each call

return orDefault(classLoader, Thread.currentThread().getContextClassLoader());

Each call could return a different classloader depending on which thread calls it. The default should be captured once at construction time.


Suggested Approach

Rather than threading MorphiaConfig through SmallRye converters (which breaks config loading), consider setting the classloader on the config after loading and using it only at runtime — not during config parsing. The converters don't need a custom classloader during config load since Morphia's own classes are being resolved at that point. The custom classloader is primarily needed at runtime for discriminator lookup, entity scanning, reference resolution, and proxy creation.

@ThomasWega
Copy link
Author

ThomasWega commented Feb 14, 2026

Hey. Thank you for the feedback, it's deeply appreciated. Before I start digging into it, is it okay that I'm doing this on the 2.5 branch, or will I need to do these changes on the master branch for it to get merged? As I was saying, I'm not able to get the master branch working properly on any of my PCs.

@ThomasWega
Copy link
Author

ThomasWega commented Feb 14, 2026

I have reverted the previous commit and went with a new approach. I think it's now much better and you'll like it more. Should not disrupt the other parts of the code that much. I will be looking into the Conversions class now. That one will probably need quite a lot of changes for it's usages, so that's why I want to do it in a separate commit

@evanchooly
Copy link
Member

sounds good. those comments were claude's (i'm not that verbose) but i was working on my own response as well. i'll wait for the follow up changes, though. :)

@ThomasWega
Copy link
Author

ThomasWega commented Feb 14, 2026

I suppose ready for review now. I've tried to keep it as clean and simple. Everything that's not super internal should be backwards compatible. Just added new methods with new parameters and kept the old ones.

All usages of Thread.currentThread().getContextClassLoader() have now been replaced, except if you are using the default (current) approach in which case it defaults to that.
Also converters use the contextClassLoader as it was said above those don't need custom class loader.

Let me know if there's anything more you need me to do :)

@ThomasWega ThomasWega marked this pull request as ready for review February 14, 2026 20:45
Copy link
Member

@evanchooly evanchooly left a comment

Choose a reason for hiding this comment

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

Overall, I like the direction. There have been a few "using the wrong classloader" bugs "recently." They're hard to suss out because they require operating in contexts I don't have access to (glassfish/wildfly apps, minecraft plugins, etc.) and this change would help close some of those gaps I think we've gotten lucky so far to have not run in to. There are some simplifications in the comments that I think might serve to shrink this PR down even further. if I get time before you get to them, i'll see about pushing a patch to this PR showing what i'm thinking. But if you beat me to it, I won't complain either. Thanks for all the work you've put in so far. This revision is definitely cleaner than the first one.

}

private static class MappingCursor<R> implements MongoCursor<R> {
private class MappingCursor<R> implements MongoCursor<R> {
Copy link
Member

Choose a reason for hiding this comment

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

Turns out this class isn't even used. I'll delete it on the 2.5 branch, though, so don't worry about it just now.

* @param classLoader the classloader to use when loading resources from the classpath
* @return the loaded config
*/
static MorphiaConfig load(ClassLoader classLoader) {
Copy link
Member

Choose a reason for hiding this comment

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

Adding new methods to an API in a patch release is verboten by semver. I'm trying to decide how much semver matters to me with the 2.5. On the one hand, semver is important. On the other hand, 3.0 is much slower in coming than I'd hoped (though I'm actually closer than I thought to what might be considered a beta or RC if I downscope a little and push things to a 3.1...) and adhering to such a rule seems a little counterproductive in that light. Maybe make a 2.6 out of all this and satisfy both? That gets close to too many active branches, though, so maybe mark 2.4 and 2.5 as "done" and any future fixes go in to 2.6 alone? Or maybe just 2.4 and 2.6 since I think 2.5 is mildly breaking because of the driver upgrade and some internal incompatibilities... Thoughts?

* @since 3.0
*/
static MorphiaConfig load(String path, ClassLoader classLoader) {
List<ConfigSource> configSources = classPathSources(path, classLoader);
Copy link
Member

Choose a reason for hiding this comment

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

Same comment on semver here but one additional. I would add a classLoader(ClassLoader) method to MorphiaConfig (if we're ditching semver 👎🏻 or bumping to 2.6 👍🏻 ) and then pass this class loader to the config once it's constructed.) Something similar used to exist for MapperOptions but was there primarily for the play framework which effectively doesn't exist anymore (for most of us anyway...).

private final PropertyModel idProperty;
private final PropertyModel versionProperty;
private final List<EntityListener<?>> listeners = new ArrayList<>();
private final ClassLoader classLoader;
Copy link
Member

Choose a reason for hiding this comment

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

Not sure that is strictly necessary. Or that I like it. EntityModels should be pretty agnostic about such state. When I can finally implement multi tenancy this gets much more important.

return type;
}

public Mapper mapper() {
Copy link
Member

Choose a reason for hiding this comment

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

a builder shouldn't need this reference. when the builder still exists, that context should have access to the mapper.

if (idProperty != null) {
if (ObjectId.class.equals(idProperty.getType()) || String.class.equals(idProperty.getType())) {
idProperty.setValue(entity, convert(new ObjectId(), idProperty.getType()));
idProperty.setValue(entity, convert(new ObjectId(), idProperty.getType(), datastore.getClassLoader()));
Copy link
Member

Choose a reason for hiding this comment

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

same as above. Pretty sure I've removed getClassLoader() from Datastore once already. :)

return newArray;
}
return Conversions.convert(o, type);
return Conversions.convert(o, type, classLoader);
Copy link
Member

Choose a reason for hiding this comment

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

Maybe a clean approach would be to make Conversions.convert() not static and create an instance of it from the classloader configured on MorphiaConfig. then the convert method signature doesn't really change and the interesting parts of the API that are nominally available for non-Morphia usage stays smaller (a partial focus of 3.0 is paring down the API surface area. so many overloads....) Then we just pass in the Conversions instance (stored on the Datastore?) when we create these kinds of classes. It reduce the amount of classloader parameters being passed from class to class, i think.

Mapper getMapper();

@MorphiaInternal
default ClassLoader getClassLoader() {
Copy link
Member

Choose a reason for hiding this comment

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

nein! 😁

.next();

refreshCodec.decode(new DocumentReader(id), DecoderContext.builder().checkedDiscriminator(true).build());
refreshCodec.decode(new DocumentReader(id, getClassLoader()), DecoderContext.builder().checkedDiscriminator(true).build());
Copy link
Member

Choose a reason for hiding this comment

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

this argument would be the Conversions instance field we'd add to the DatastoreImpl, e.g.

* @return a Datastore that you can use to interact with MongoDB
* @since 3.0.0
*/
public static Datastore createDatastore(MongoClient mongoClient, MorphiaConfig config, ClassLoader classLoader) {
Copy link
Member

Choose a reason for hiding this comment

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

This wouldn't be necessary. We'd just use config.classLoader() inside DatastoreImpl to get what we need.

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