Design for extensibility

2773 words | 15 minutes to read

Java Access Modifiers

In the previous chapter we talked about designing for extensibility by limiting extension of types with the final and sealed keywords that Java offers. In this chapter our focus shifts to the access modifiers we have within a type, more often referred to by the keywords public, protected, private, and package-private (denoted by the absence of any other access modifier). When it comes to API design, the Java access modifiers are crucial to a good API.

The best approach to API design in terms of access modifiers is to strive to use the least visibility required. Having said that, an approach that works well is to simplify the thinking, from an API design perspective, into public APIs (those designated public or protected), and internal APIs (those designated private or package-private. Crossing the threshold from internal to public API requires considerable consideration. Moving between public and protected requires some consideration, but moving between private and package-private essentially requires no consideration.

The one exception to the above statement is for the appropriate set of access modifiers for an instance field in a class. It is commonly accepted best practice that all instance fields should never be public or protected - they should always be private or package-private, and access to these fields should always go by way of their appropriate ‘getter’ method. Making instance fields public removes our ability to control the acceptable range of values that can be set on the field, meaning that users of our API miss out on our ability to validate their input and provide acceptable runtime feedback (through exceptions, logging, or otherwise).

The Protected Keyword

In my experience, the protected keyword in Java is often misunderstood, and perhaps, overused. In short, protected members can be called within the same class, by subclasses of the type, and by all classes in the same package. The primary purpose of a member marked as protected is to enable subclasses to use the API, without it being immediately accessible to users of the API.

There are certainly cases where the protected keyword can be a very useful tool in the API developers toolkit, but unless it is designed into a class from the start, it is often used incorrectly, leading to classes that appear to be extensible, but in practice fail to be so. In fact, sometimes the protected keyword can act as a form of virus, infecting the class as users of the API demand more and more of its private methods be made protected (or public!) to enable their requirements to be met.

In addition, API developers need to understand that protected methods form as much a part of their public API footprint as public API does. This is often misunderstood by beginner API developers, to their eventual detriment.

There is no clear answer to give when asked the question ‘should I make this API protected?’ The best approach I have found is to first consider whether it is an intentional part of the API being designed, or if it is being made protected because a better solution has not been found. It is always better to find a better solution rather than to make something unimportant to a class protected needlessly, because a protected member is us, as API designers, committing to leaking some implementation detail out into our public API.

Once we have committed to having the API be public, our next commitment must be to writing code that tests this API in real-world scenarios. We have to know that with the newly-available protected API we can achieve the goal that we have set out to enable. If, in writing this test code, we find that we cannot achieve our goal, it begs the question of what other API needs to be made available to enable this goal, and if we are going further away from the original intent of the class or if there is a better solution. On the other hand, we may find that our test code is able to achieve the required goal without this newly-available protected API, which is an ideal outcome as it indicates we do not need the API after all.

Final Classes

As an API developer, we must strike a balance between offering developers the functionality and flexibility that they need to perform their jobs, and the ability for ourselves to evolve our API over time. One way to ensure we, as API developers, retain some level of control is to make use of the final keyword. By making our classes or methods final, we are signaling to developers that, at this point in time, they cannot consider extending or overriding these particular classes and methods.

The reason why final is valuable to API developers is due to the fact that sometimes, our APIs are not flawless, and instead of getting in touch to help fix things, many developers want to try to workaround our flaws to patch their version, so that they can move on to the next problem. With this approach, they only create new problems for themselves, and ultimately, for us as API developers. Ideally, when a user of our API encounters a final class or method, they reach out to us to discuss their needs, which can lead to an even better API. The final keyword, after all, can always be removed in a subsequent release, but it is a wise idea to not make something final after it has already been released.

Sealed Classes

As mentioned earlier, sealed classes in Java is a new language feature that for now exists in JDK 15 as a preview feature. This means that it may continue to evolve over time until it becomes a final feature in a future JDK release. They are included in this document on Java API visibility issues because they help to solve one of the side-issues of interfaces: limiting who can implement the interface!

A sealed class or interface is one which can only be extended or implemented by classes and interfaces permitted to do so by the sealed class or interface. Sealing a class or interface is simple:

1) Apply the sealed modifier to the class or interface declaration, and 2) At the end of the class declaration, apply the permits keyword followed by a comma-separated list of classes or interfaces that can extend or implement the sealed class or interface.

For example, a sealed, abstract class can be defined as such:

public abstract sealed class Mammal permits Dog, Cat, Cow, Sheep { ... }

This means that the classes Dog, Cat, Cow, and Sheep are the only allowed mammal’s in the application.

Similarly, an interface can be sealed in the following way:

public sealed interface Mammal permits Dog, Cat, Cow, Sheep { ... }

When a type is sealed in Java, all of the permitted types must meet the following constrains:

1) The sealed type and all permitted types must be in the same module. If the module is unnamed, the permitted types must be in the same package. 2) All permitted types must directly extend or implement the sealed type (there can’t be extra layers of inheritance). 3) All permitted types need to specify their own modifier to specify how sealing is to permeate: 1) The final modifier may be used to prevent further extension of the class. 2) The sealed modifier, along with permits may be used to limit sub-typing to a limited set of sub-types. 3) The non-sealed modifier may be used to enable the standard Java type system to be applied.

Using sealed types is a powerful tool in the Java API designers playbook, because it enables for selected sub-typing, rather than the current choice of final or non-final types in Java.

Options Classes

In API design we sometimes encounter what is commonly referred to as parameter telescoping. This is when we see the following in our IDE’s auto-complete popup as we are trying to call a foo method:

public class FooClient {
   public void foo()
   public void foo(int val1)
   public void foo(int val1, String val2)
   public void foo(int val1, String val2, Bar val3)
   public void foo(int val1, String val2, Bar val3, int val4)
   public void foo(int val1, String val2, Bar val3, int val4, Baz val5)
   public void foo(int val1, String val2, Bar val3, int val4, Baz val5, Object val6)
}

When the user wants to call foo, now they need to look through a list of possible choices to determine which set of parameters they can supply. In actual fact, the example above is the easy case for developers, because we can see that the order is retained and the parameter list just becomes longer and longer. It can be worse: our API might decide to provide some convenience by identifying that, for example, the val3 parameter above is optional:

public class FooClient {
   public void foo()
   public void foo(int val1)
   public void foo(int val1, String val2)
   public void foo(int val1, String val2, Bar val3)
   public void foo(int val1, String val2, Bar val3, int val4)
   public void foo(int val1, String val2, Bar val3, int val4, Baz val5)
   public void foo(int val1, String val2, Bar val3, int val4, Baz val5, Object val6)

   // new 'convenience' where we drop the val3 optional parameter
   public void foo(int val1, String val2, int val4)
   public void foo(int val1, String val2, int val4, Baz val5)
   public void foo(int val1, String val2, int val4, Baz val5, Object val6)
}

In reality, we’ve introduced convenience by introducing three more methods. The benefit of this is arguable at best, as we should always strive to have each API pay its way, and it could be debated whether having three more ‘convenience’ APIs is really the best way forward here.

Let’s go back to the simpler first set of overloads, and make things more complex by accepting different parameter lists, because the foo method can be called in different ways to obtain the same expected outcome. For example:

public class FooClient {
   public void foo()
   public void foo(int val1)
   public void foo(int val1, String val2)
   public void foo(int val1, String val2, Bar val3)
   public void foo(int val1, String val2, Bar val3, int val4)
   public void foo(int val1, String val2, Bar val3, int val4, Baz val5)
   public void foo(int val1, String val2, Bar val3, int val4, Baz val5, Object val6)

   // a new set of parameters
   public void foo(String val1)
   public void foo(String val1, long val2)
   public void foo(String val1, long val2, Bar val3)
   public void foo(String val1, long val2, Bar val3, float val4)
   public void foo(String val1, long val2, Bar val3, float val4, Baz val5)
   public void foo(String val1, long val2, Bar val3, float val4, Baz val5, Object val6)
}

The end result is a lot of foo, and probably very little clarity for the user of our API. What’s worse is that this is round one of our API - it is conceivable, and in my experience highly common, that future releases will want to telescope on more arguments until the number of overloads is truly unwieldy. Additionally, in some environments, particularly when an API is built to represent a web service, the number of arguments required of the user can be 20, 30, 40, or more for a valid request to be sent through to the service!

A pattern that has proven beneficial to me is what I refer to as the ‘Options’ pattern. In this approach, we accept defeat eagerly by acknowledging that there is no keeping this telescoping at bay. Instead, we introduce a custom type specifically designed to act as the container for all possible arguments that may go into the foo method above. In this way, we can reduce the number of foo overloads to the minimum, perhaps as low as the following:

public class FooClient {
   public void foo()
   public void foo(FooOptions options)
}

In doing this, the amount of noise that is in the users face is reduced drastically, but their ability to supply just the right amount of arguments is preserved. Indeed, as you’ll soon see, the complexity does not go away entirely, it just shifts to another, less visible place for the user to use. Here is how we may decide to define the FooOptions class for the first scenario above:

public class FooOptions {
   private int val1;
   private String val2;
   private Bar val3;
   private int val4;
   private Baz val5;
   private Object val6;

   public FooOptions() {
      // no-op for now, as all parameters are currently considered optional
   }

   public FooOptions setVal1(int val1) {
      this.val1 = val1;
      return this;
   }
   public int getVal1() {
      return val1;
   }

   public FooOptions setVal2(String val2) {
      this.val2 = val2;
      return this;
   }
   public String getVal2() {
      return val1;
   }

   // ... and so on for all arguments that can be set ...
}

Note that in this approach, the FooOptions type is ‘fluent’, which means that the setters can be chained together and called one after the other. We will talk more about fluent API design in a later chapter, but for now, here is how the FooOptions type would be used:

foo(new FooOptions().setVal1(5).setVal2("Hello World!").setVal3(Bar.OK));

In many regards, this fluidity gives Java APIs something that the Java language itself lacks: named parameters. Named parameters is when you can call a method by stating the parameter name before the argument value, for example, in C# you can do the following: PrintOrderDetails(productName: "Red Mug", sellerName: "Gift Shop", orderNum: 31).

It could be argued that we aren’t making anything better, we are just shuffling where the complexity is, and yes, that is right. The argument for options types is that they are at a lower level than our top-level API, and therefore the user does not find themselves overwhelmed by considerable API at that top level, and can choose to familiarise themselves with the available options for a particular API only when it is relevant to their interests.

To make options types slightly more useful and flexible, there are a few slight tweaks that can be made to further hold our users hand and grant them some convenience.

Required Parameters

As we will discuss later on in relation to the builder design pattern, the options pattern allows for required parameters at two different levels:

  1. Our options type should have a constructor for each valid set of required parameters, and no no-args constructor.
  2. Our foo method in the FooClient class that accepts the options type could instead receive the required parameters at that level, with n overloads, one for each required parameters permutation.

There are pros and cons to each approach, with the right choice coming down to a number of factors:

  1. How often is this method called? If it is called frequently and with just the required parameters, users may start to dislike having to instantiate an options type every time they need to call the method.
  2. How burdensome is it (in terms of additional overloads) to properly support all required parameter permutations at the top level?
  3. What convention has been set already in the API? It is always better to be consistent whenever possible, rather than sometimes have required parameters at the options constructor level and other times at the top-level FooClient level.

Convenience APIs for Options

Having just spoken about required arguments, and how they can be homed in two locations, a similar argument can be made for non-optional parameters too. For example, it might be argued that a particular optional parameter is almost always going to be set, but it isn’t required. We might find ourselves thinking of our users and considering introducing overloads such as the following:

public class BazClient {
   public void baz()
   public void baz(String args1)
   public void baz(BazOptions bazOptions)
   public void baz(String args1, BazOptions bazOptions)
}

public class BazOptions {
   private String args1;
   private String args2;
   private String args2;

   public BazOptions() {
      // no-op
   }

   // setters and getters as before for args1, args2, and args3
}

The problem we’ve introduced here is with the last baz overload: now if the user supplies args1 in the method call as an argument as well as via the FooOptions, we are seeing the possibility of duplication of arguments and the introduction of inconsistencies (imagine if the user tries to be smart and caches the FooOptions)! In providing convenience, we’ve actually introduced a serious API problem, and what’s worse - our users will be extremely confused as they try to reconcile what will happen in terms of validation, order of precedence, and so on.