udoprog.github.io

Semantic Versioning and Java

In this post, about semantic versioning, and how I believe it can be efficiently applied for the benefit of long-term interoperability of Java libraries.

Let us introduce the basic premise of semantic versioning (borrowed from their page), namely version numbers and the connection they have to the continued development of your software.

  1. MAJOR version when you make incompatible API changes,
  2. MINOR version when you add functionality in a backwards-compatible manner, and
  3. PATCH version when you make backwards-compatible bug fixes.

Hello Java

Java has a lot of things which could qualify as members your public API. The most distinct feature in the language is the interface, a fully abstract class definition that forces you to describe all possible interactions that are allowed with a given implementation.

So let’s build an API using that.

package eu.toolchain.mylib;

/**
 * My Library.
 *
 * @since 1.0
 */
public interface MyLibrary {
    /**
     * Do something.
     */
    public void doSomething();
}

Consider @since, here it doesn’t contain the patch version. It could, but it wouldn’t make a difference. A patch must never modify API, that privilege is left to the major, and the minor version.

Maven plays an important role here as well. The Java ecosystem relies on it to distribute libraries and resolve dependencies. The way you would expose your library is by putting the above in an API artifact named eu.toolchain.mylib:mylib-api. You might also feel compelled to provide an implementation, this could be eu.toolchain.mylib:mylib-core.

The separation is not critical, but it helps in being explicit in what your public API is. Both for you and your users.

My intent is to have your users primarily interact with your library through interfaces, abstract classes, and value objects.

A Minor Change

Let us introduce a minor change to the library.

package eu.toolchain.mylib;

public interface MyLibrary {
    /* .. */

    /**
     * Do something else.
     *
     * @since 1.1
     */
    public void doSomethingElse();
}

In library terms, we are exposing another symbol. For Java, this is just another method with a given signature added to the already existing MyLibrary interface.

This only constitutes a minor change because consumers of the API which happen to use 1.0 will happily continue to operate in a runtime containing 1.1. Anything linked against 1.0 will be oblivious to the fact that there is added functionality in 1.1. This is due to indirection that is introduced by Java, method calls use a very flexible symbolic reference to indicate the target of the invocation.

Removing a method and not fixing all callers of it would eventually cause NoSuchMethodError. Eventually, because it would not be triggered until a caller attempts the invocation at runtime. Ouch.

What qualifies as a minor change

Identifying what qualifies as a minor change, and what does not, is one of the harder aspects we need to deal with. It requires a bit of knowledge in how binary compatibility works.

The Eclipse project has compiled an excellent page on this topic which touches a few more cases. For all the gritty details, you should consult Chapter 13 of the Java Language Specification.

I’ll touch on a few things that are compatible, and why.

Increasing visibility

Increasing the visibility of a method is a minor change.

Visibility goes with the following modifiers, from least to most visible:

  • private
  • package protected (no modifier)
  • protected
  • public

From the perspective of the user, a think is not part of your public API if it is not visible.

Adding a method

This works, because method invocations only consult the signature of the method being called, which is handled indirectly by the virtual machine who is responsible for looking up the method at runtime.

So this is good unless the client implements the given API.

package eu.toolchain.mylib;

/**
 * ... boring documentation ...
 *
 * <em>avoid using directly</em>, for compatibility extend one of the provided
 * base classes instead.
 *
 * @see AbstractMyCallback
 */
public interface MyCallback {
    /**
     * @since 1.0
     */
    public boolean checkSomething();

    /**
     * Oops, sorry client :(
     *
     * @since 1.1
     */
    public boolean checkSomethingElse();
}

If you are exposing an API that the client should implement, a very popular compromise is to provide an abstract class that the client must use as a base to maintain compatibility.

/**
 * A base implementation of {@link MyCallback} that will maintain compatibility
 * for you.
 */
public abstract AbstractMyCallback implements MyCallback {
    /**
     * Should be implemented by client, but if they are using a newer version
     * of the library this will maintain the behavior.
     */
    @Override
    public boolean checkSomethingElse() {
        return false;
    }
}

You as a library maintainer must maintain this class to make sure that between each minor release it does not force clients to have to implement methods they previously did were not required to.

To see this in action, check out SimpleTypeVisitor8 which is part of the interesting java.lang.model API.

Extending behaviour

This one is tricky, but probably the most important to understand.

If you have a documented behavior in your API, you are not allowed to remove or modify it.

In practice, it means that once your javadoc asserts something, that assertion must be versioned as well.

package eu.toolchain.mylib;

/**
 * @since 1.0
 */
public interface MyLibrary {
    /**
     * Create a new black hole that will slowly consume the current Galaxy.
     */
    public void createBlackHole();
}

You may extend it in a manner, which does not violate the existing assertions.

package eu.toolchain.mylib;

/**
 * @since 1.0
 */
public interface MyLibrary {
    /**
     * Create a new black hole that will slowly consume the current Galaxy.
     *
     * The initial mass of the black hole will be 10^31 kg.
     */
    public void createBlackHole();
}

You may not however, change the behavior from current Galaxy to Milky Way.

package eu.toolchain.mylib;

/**
 * @since 1.0
 */
public interface MyLibrary {
    /**
     * Create a new black hole that will slowly consume the Milky Way.
     */
    public void createBlackHole();
}

Your users will have operated under the assumption that the current galaxy will be consumed.

Imagine their surprise when they run the newly upgraded application in the Andromeda Galaxy and they inadvertently expedite their own extinction because they didn’t expect a breaking change in behavior for a minor version :/.

A Major Change

Ok, so it’s time to rethink your library’s existence. The world changed, you’ve grown and realized the errors of your way. It’s time to fix all the design errors you made in the previous version.

package eu.toolchain.mylib2;

/**
 * My Library, Reloaded.
 * @since 2.0
 */
public interface MyLibrary {
    /**
     * Do something, _correctly_ this time around.
     * @since 2.0
     */
    public void doSomething();
}

In order to introduce a new major version, it is important to consider the following:

  • Do I need to publish a new package?
  • Do I need to publish a new Maven artifact?
  • Should I introduce the changes using @Deprecated?

This sounds rough, but there are a few points to all this.

Publishing a new package

To maintain binary compatibility with the previous Major version.

There are no easy take-backs once an API has been published. You may communicate to your clients that something is deprecated, and it is time to upgrade. You cannot force an atomic upgrade.

If you introduce a Major change that cannot co-exist in a single classpath. Your users are in for a world of pain.

Publishing a new Maven artifact

To allow your users to co-depend on the various major versions of your library. Maven will only allow one version of a <groupId>:<artifactId> combination to exist within a given build solution.

For our example, we could go from eu.toolchain.mylib:mylib-api to eu.toolchain.mylib:mylib2-api.

If you don’t change the artifact, Maven will not allow a user to install all your major versions. More importantly, any transitive dependencies requiring another major version will find themselves lacking.

Using @Deprecated to your advantage

@Deprecated is a standard annotation discouraging the use of the element that is annotated.

This has wide support among IDEs, and will typically show up as a warning when used.

You can use this to your advantage when releasing a new Major version.

Assume that you are renaming a the following #badName() method.

package eu.toolchain.mylib;

/**
 * @since 1.0
 */
public interface MyLibrary {
    /**
     * A poorly named method.
     */
    public void badName();
}

Into #goodName().

package eu.toolchain.mylib2;

/**
 * @since 2.0
 */
public interface MyLibrary {
    /**
     * A well-named method.
     */
    public void goodName();
}

You can go back and release a new minor version of your 1.x branch containing the newly named method with a @Deprecated annotation.

package eu.toolchain.mylib;

/**
 * @since 1.0
 */
public interface MyLibrary {
    /**
     * A poorly named method.
     *
     * @deprecated Will be removed in 2.0 since the name is obviously inferior.
     *             Use {@link #goodName()} instead.
     */
    @Deprecated
    public void badName();

    /**
     * A well-named method.
     *
     * @since 1.1
     */
    public void goodName();
}

This is an excellent way of communicating what changes your users can expect, and can be applied to many situations.

Case studies

Project jigsaw

Project jigsaw is an initiative that could improve things in the near future by implementing a module system where dependencies and versions are more explicit.

The specification will not require implementations to support multiple versions of the same module, but it should be possible to hook into the module discovery process in a manner that supports it.

Final Words

Dependency hell is far from solved, but good practices can get us a long way.

Good luck, library maintainer. And may the releases be ever in your favor.