Do You Keep Things Backwards Compatible?

Code changes. It evolves over time. Few applications are written and never updated. But not all code changes at the same rate. You may need to make updates to one part of the system while other parts, which depend on the same code, lay dormant, with no need or reason to change. You may not even know all the dependencies of your code, especially if you’re maintaining a shared library!

If you just charge in and make changes without thinking about the dependencies, you run the risk of breaking parts of the system that have no need to change. However, with a little bit of planning, it’s possible to make changes and maintain backwards compatibility with existing code. There are several benefits to keeping things backward compatible:

  • Reduced scope
  • Reduced risk
  • Reduced testing

Making changes to software while maintaining backwards compatibility can seem like something of an art form, but there are techniques to make things a bit easier.

Let’s explore four ways to make changes to your code while remaining backwards compatible.

The General Approach

What does it mean to be backwards compatible anyway? In Java, and some other languages, there are two levels of backwards compatibility to consider:

  • Compile time. The ability to rebuild an application and expect things to work with the new code.
  • Run time. Also called binary compatibility. This is the ability to take an already compiled application, drop in a new version of the library, and things will continue to work.

Binary compatibility is harder to accomplish. If you have control over the code using your library, compile time compatibility might be enough, but sometimes you don’t have that luxury. Each of the techniques I’m about to describe uses the same methodology to migrate to the new code:

  1. Write the new code.
  2. Update the existing interface to call the new code
  3. Mark the existing interface as deprecated
  4. Eventually, in a future release, remove the deprecated code

For each scenario, make sure you have a suite of passing unit tests in place before you make any changes. If you don’t have a set of unit tests, write some. Otherwise you’re just be guessing if your changes are backward compatible.

Add a Method Parameter

One of the first and easiest things most developers learn to do is add an additional method parameter. Java, like many languages, has the concept of overloading methods. This can be used to our advantage when we want to add a new parameter, but there is existing code still calling the old method.

Here’s an example of how that might look. Suppose we have a method, sendMessage, that sends a message to another part of the system. Now imagine that we want the ability to, optionally, send the message asynchronously. The updated code might look something like the following:

1
2
3
4
5
6
7
8
9
10
/** This is original method */
public void sendMessage(String message) {
this.sendMessage(message, false);
}

/** The new method */
public void sendMessage(String message, boolean async) {
// send the message either synchronously or asynchronously,
// depending on the value of 'async'
}

In this example, we write a new version of sendMessage which takes two arguments: the message and a flag that indicates whether the message should be sent using a separate thread. The original version of sendMessage, which takes one argument, is preserved and adapted to call the new method with a default value for the async flag.

At this point, we can choose to leave the original method in place, or deprecate it for future removal. In this example, it could go either way. Having a single argument method with default behavior might be desirable. But if you want to force callers to make a decision each time they call sendMessage, then deprecate the original method.

Remove a Method Parameter

So what about the reverse scenario, where we want to remove a parameter from a method? The process is the same, except that we simply reverse the process.

Suppose, in our previous example, that we no longer like the async parameter and want to always send messages asynchronously. We could just update the existing method and stop paying attention to the async parameter, but that leaves dead code laying around. Let’s prune it out.

1
2
3
4
5
6
7
8
9
10
/** This is original method */
@Deprecated
public void sendMessage(String message, boolean async) {
this.sendMessage(message);
}

/** The new method */
public void sendMessage(String message) {
// send the message asynchronously
}

This should look suspiciously like the previous example. The only difference is that the delegation has been reversed. The old method has been marked as deprecated, so callers know they should stop using it, and it delegates to the new method that doesn’t care about an async flag. It probably isn’t a bad idea to document that the async flag is ignored in the deprecated version.

Change a Return Type

Up to now, my examples have addressed the inputs to a method. What about the outputs? How do we change the return type of a method?

If the method previously had a void return type, meaning it didn’t return anything, that’s pretty easy. Just update the method to start returning the new type. Existing code didn’t expect a return type, so it will happily ignore the new return value.

However, if you want true binary compatibility, this won’t work, as the method signature changes when you change the return type. Already compiled code won’t be able to find the method anymore if you simply change the return type. To keep things run-time compatible, keep reading.

Changing a method’s return type is a bit trickier since we can’t rely on method overloading–we can only vary the arguments, not the return type. We’ll follow a similar pattern as the previous two examples, but we’ll also change the name of the method.

Let’s use the same example, but this time the sendMessage method returns true or false, depending on whether the message was sent successfully.

1
2
3
4
5
6
7
8
public boolean sendMessage(String message) {
try {
doSendMessage(message);
return true;
} catch (Exception ex) {
return false;
}
}

Now, we want to make the call asynchronously and return a Future which contains the success or failure response. The updated code might look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/** @deprecated Use {@link #sendMessageAsync} instead */
@Deprecated
public boolean sendMessage(String message) {
return sendMessageAsync(message).get();
}

/** The new method */
public CompletableFuture<Boolean> sendMessageAsync(String message) {
CompletableFuture<Boolean> future = new CompletableFuture<>();
new Thread(() -> {
try {
doSendMessage(message);
future.complete(true);
} catch (Exception ex) {
future.complete(false);
}
}
}

The refactored code has a new method with a new name, sendMessageAsync, and a return type different from the original method. The old method, sendMessage, has been adapted to call the new method and transform the new return type back into the old, expected type.

Moving a Class

Sometimes you realize that an entire class is in the wrong package and you’d like to move it. How do you make the transition without breaking legacy code? You create a superclass.

Suppose you have the following class:

1
2
3
4
5
6
7
8
9
10
11
12
package com.djh.monkey;

public class Banana {
private final float ripeness;
public Banana(float ripeness) {
this.ripeness = ripeness;
}

public boolean isRipe() {
return this.ripeness > 0.3 && this.ripeness < 0.7;
}
}

This made sense originally when the system was first created, as it was centered around monkeys. But the system has evolved and now supports additional types of fruit, so we’d like to move the Banana class to the com.djh.fruit package.

We start by copying the class to the new package. Then we update the existing class to inherit everything from the new class. Most IDEs can perform both these steps at the same time with its “extract superclass” refactoring.

When we’re done, the original class looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
package com.djh.monkey;
/** @deprecated Use {@link com.djh.fruit.Banana} instead */
@Deprecated
public class Banana extends com.djh.fruit.Banana {
public Banana(float ripeness) {
super(ripeness);
}

public Banana(com.djh.fruit.Banana copy) {
super(copy.ripeness);
}
}

Everything else has moved into the superclass and the original class delegates all its behavior to the superclass. If legacy code hands back a com.djh.monkey.Banana instance, I can pass it to any new code I write that uses the com.djh.fruit.Banana class.

There’s one minor caveat with this technique. If your new code creates an instance of the new class (com.djh.fruit.Banana) and needs to pass that object to legacy code, you’ll need to convert the new class back into the old class. This is what the second constructor is for. This so-called copy constructor creates a com.djh.monkey.Banana class by copying the data from another instance. If this is something you find you have to do a lot, it might make sense to reverse the inheritance until you can officially drop the old one.

Conclusion

Writing code that is backwards compatible with legacy code takes a bit more work and attention, but it’s worth the effort. You’ll be able to make more isolated changes, reducing the cost of development and the risk involved.

I’ve shared four main ways to make changes in a backwards compatible manner. Use these techniques and look for others. I think you’ll find that making changes will seem less scary and actually allow the code to evolve the way you want it to.

Question: What are your favorite techniques for maintaining backwards compatibility?