Dependency Management for Modular Applications

While working on a rather cool support for Maven Repositories in Z2 (all based on Aether – the original Maven dependency and repository implementation), I converted several scenarios to be using artifacts stored in Maven central. This little piece is on what I ran into.

It all starts with the most obvious of all questions:

How to best map Java components (Z2 term) from Maven artifacts?

As Java components (see here for more details) are the obvious way of breaking up stuff and sharing it at runtime, it is natural to turn every jar artifact into a Java component – exposing the actual library as its API. But then, given that we have so much dependency information, can we map that as well?

Can we derive class loader references from Maven dependencies?

Maven has different dependency scopes (see here). You have to make some choices when mapping to class loader references. As compile dependencies are just as directed as class loader references, mapping non-optional compile dependencies to class loader references between Java components seems to be a rather conservative and still useful approach.

At first glance this looks indeed useful. I was tempted to believe that a lot of real-world cases would fit. Of course there are those adapter libraries (like spring-context-support), that serve to integrate with a large number of other frameworks and therefore have tons of optional compile dependencies. Using them only with non-optional compile dependencies on the class path completely defeats their purpose. (For example spring-context-support is made to hook up your Spring application context with any subset of their target technologies – and only that needs to be on the effective class path.)

But there are other “bad” cases. Here are two important examples:

  • Consistency: The Hibernate O/R Mapper library integrates with the Java Transaction API (JTA) – and it definitely better should. So it does have a corresponding non-optional compile dependency on JTA, but not on javax.transaction:jta but rather JBOSS’s packaging of it. Spring-tx (the transaction utilities of the Spring framework) however has a compile dependency on javax.transaction:javax.transaction-api. Using both with the trivial mapping would hence lead to some rather unresolvable “type misunderstanding” a.k.a. class cast exception. So there is consistency problems around artifacts that have no clear ownership.
  • Runtime constraints: The spring-aspect library, that enables transparent, automatic Spring configuration for objects that are not instantiated by Spring, associates a Spring application context with a class loading namespace by holding on to an application context by a static class member. That is: Every “instance” of the spring-aspect library can be used with at most one application context. Hence, it makes no sense what so ever to share it.

So there are problems. Then again: Why split and share at runtime with class loader isolation anyway? Check the other article for some reasoning. Long story short: There is good cases for it (but it’s no free lunch).

Assuming we want to and considering the complexities above, we can conclude that we will see some non-trivial assembly.

Here are some cases I came up with:

mod-dep-cases

  1. Direct mapping: Some cases allow to retain the compile dependency graph one to one as class loading references. This does however require to not rely on anything that is expected to be present on the platform. E. g. “provided” dependencies will not be fulfilled and most likely we end up in case 3.
  2. Isolated Inclusion: For the case of of spring-aspects, it assumes to be a class loading singleton. It is one for each application context. So, if you want to have multiple application contexts, you will need multiple copies of spring-aspects. This is true for all cases where some scope that can be instantiated multiple times is bound to some static class data.
  3. Aggregation: the not so rare case that a dependency graph has a single significant root and possibly some dependencies that should be excluded as they will be satisfied by the environment, it is most practical to form a new module by flattening the compile dependency graph (omitting all exclusions) and satisfy other runtime requirements via platform class loading means. The role model for this is Hibernate.
  4. Adapter Inclusion: Finally, we consider the case of a library that is made to be used in a variety of possible scopes. A great example is spring-context-support that provides adapters for using Spring with more other tools than you probably care in any single setup. If spring-context-support would be used via a classloading reference, it could not possibly be useful as it would not “see” the referencing module’s choice of other tools. By including (copying) it into the module, it can.

Conclusion

If you follow the “normal” procedure and simply assemble everything into one single web application class loader scope, you can spare some thinking effort and get around most of the problems outlined above. If you however have the need to modularize with isolated class loading hierarchies, you need to spend some time understanding toolsets and choosing an assembly approach. This does pay off however, as something that happened accidentally has now become a matter of reasoning.

References

  1. Z2 Maven Repo Support
  2. Z2 Java Components
  3. Maven Dependency Scopes