saker.nest Documentation TaskDoc JavaDoc Packages
  1. saker.nest
  2. Development guide
  3. Dependency hell

Dependency hell

Dependency hell is the scenario when multiple versions of a given package is present in a system and relationships between the packages cause different versions of a package to be loaded. This can cause compatibility issues between packages as the semantically same classes may be loaded multiple time by the runtime therefore making them incompatible.

Saker.nest doesn't attempt to provide a direct solution to this problem, but presents development guidelines that help avoiding these issues. As a baseline, see the issue example in presented in the Multiple ClassLoaders section.

Separation of concerns

A solution for the mentioned problem is by separating the concerns when developing bundles. When creating a package, we recommend separating the bundle classes in the following way:

  • API bundle: This bundle contains the classes that other packages can depend on. This contains the classes that provide access to the functionality provided by the package. Generally, this contains classes and interfaces that have a stable binary interface that other bundles can use.
  • Implementation bundle: Contains the implementational details of a given package. Implementational details shouldn't leak into the API bundle, and classes in this bundle shouldn't be used directly by external agents. The binary interface of the classes in this bundle is not required to be stable.
  • Main bundle: Contains implementational classes similar to the implementation bundle, but they are responsible for providing a fronted for accessing via build scripts. This bundle should contain the build task classes that are loaded during build execution. The classes from the main bundle shouldn't escape the main bundle domain.

Based on the above, let's see how the dependencies are between these bundles:

example.bundle
        |
        | [0)
        |
        V
example.bundle-api
    |          /\
    | [this]    |
    |           | [this]
    V           |
example.bundle-impl

The point of this architecture is to have the classes that are interchangeable by different packages to be only loaded once by the repository runtime. The dependency schema as above will solve this problem, as the [0) dependency version range ensures that only the most recent version of the -api bundle is loaded.

The [0) version range stands for minimum version of 0, with unbounded maximum. This basically specifies no restriction on the used version of the dependent bundle. When depending on -api bundles, we strongly recommend using only the [0) version range for the dependency, as this way the loaded bundles can be shared by different packages.

The [this] range specifies that the -api bundle and -impl bundle dependencies should be resolved from the same package version release as they are in. This ensures that there won't be binary incompatibility between the -api and -impl bundles.

Converting the above dependency schema into dependency file format:

example.bundle:

example.bundle-api
    classpath: [0)

example.bundle-api:

example.bundle-impl
    classpath: [this]

example.bundle-impl:

example.bundle-api
    classpath: [this]

Restrictions

The proposed development practice comes with the following restrictions.

Use the a version range that doesn't have an upper bound when depending on -api bundles. This is the most important restriction that is the base of the proposed solution. Using version ranges that have no upper bounds will always cause the most recent release of the bundle to be loaded, therefore avoiding multiple class loader problems. The [0) dependency version range as suggested in the above example is the recommended version range when depending on -api bundles.

Don't make incompatible changes in the -api bundle. Based on the above, we can say that always the most recent version of the -api and -impl bundles will be loaded in the runtime. This means that both the oldest and newest releases of the consumers must work with the package. You cannot just simply remove classes, interfaces, methods, and fields from the -api bundle, as that could break consumer package functionality.

We recommend that you gradually deprecate features in your packages over time in a timely manner that makes sense from consumer perspective.

Don't depend directly on -impl bundles. Implementation bundles may be subject to breaking changes without notice. Depending on them may cause your package to break when the dependent package is updated.

Don't depend on the main bundle from -api or -impl. The dependency from the main bundle should be one way to the -api bundle, and the -api bundle mustn't depend on the main bundle. If the -api bundle depended on the main bundle, that would risk the -api bundle being loaded multiple times, which is what we're trying to avoid.

Notes & exceptions

The following notes and exceptions apply when employing the above development practice.

You can specify bounded dependency on the main bundle. You can depend on the main bundle of a package in a version bounded way. As depending on it won't change the way the -api bundles are loaded, it makes no difference from class sharing perspective..

Classes from the main bundle shouldn't be shared. If you use a class from the main bundle of a package, then that class shouldn't be passed to other bundles for use. The classes in the main bundle serve the purpose of transforming the user input into classes from the -api and -impl bundles. You can use the classes from a main bundle, but they shouldn't be passed along the processing pipeline.

You don't always need to break up your bundles. If you don't intend for others to directly use the classes in your bundle, then you don't need to employ the separation of concerns. In this cases, your classes may be loaded multiple times by the repository runtime, however, it won't be a problem, as the class conflicts can't occur, because others aren't depending on your bundle.

You don't always need an -impl bundle. If your use-case don't include others instantiating your implementational classes, you can avoid the need to create an -impl bundle. This can be a scenario when others would want to consume the output of your build task, but don't want to invoke the task itself in a programatic manner. In this case you don't need to separate the implementation to an -impl bundle, but only need to have the task output implement an appropriate interface from the -api bundle.

For simple use-cases, you don't even need an -api bundle. If you don't want others to interact with your classes in any way, you don't need to separate the bundles. However, note that if you plan on using the output of your build task as an input to another, then you are strongly recommended to create an -api bundle. Not doing so may cause incompatibility between your own tasks.
For example if your bundle contains build tasks with the names first.task and second.task:

$firstout = first.task-v1.0()
second.task-v2.0($firstout)

The above will most likely cause incompatibility if you don't have an -api bundle. The output of the first.task should implement an interface from the -api bundle, that the second.task should use to interpret its input.

Native libraries

If your bundle includes native libraries, you must develop your bundle in a very specific way. This is due to the fact that the JVM can't load a native library multiple times. If there can be multiple instances of your bundle classloader (as seen in Multiple ClassLoaders) then the library loading for the second classloader will fail. This can cause unexpected and hard to solve issues in the runtime.

The solution for this is that a bundle that loads native libraries must not have any dependencies. If your bundle doesn't have any dependencies, then the runtime will never construct a second classloader for your bundle. The bundle should have only one single purpose, that is providing access to the native library functionality.

Any operations that deal with the native library, and use third party dependencies should be exported to another bundle, like the following dependency graph:

example.bundle------>dependency.bundle
      |
      |
      V
example.bundle-native
    (no dependencies at all)

In the above example, the example.bundle depends on the example.bundle-native that contains the native library to be loaded. The -native bundle provides access to the functionality of the native library, and the lack of dependencies of it ensures that it will be loaded at most once.

Any operations and resource management should be present in the example.bundle. It may also depend on other packages, but the -native bundle must not.

Note: Different versions of a bundle that contains native libraries may be loaded by the repository runtime. This is an acceptable scenario, unlike the case with -api bundles. You are not required to use an unbounded version dependency on native bundles.