Keeping it simple
September 7, 2011 Leave a comment
By writing client code first we can avoid the most embarrassing and bothersome API design mistakes. This approach also leads to simpler APIs. Nevertheless, APIs grow in size and complexity as the number of use cases grows. Many developers aren’t prepared to spend extra effort to keep APIs simple because they believe that API size and complexity are inextricably linked. But this cannot be entirely true: both the core Java API and the .Net API are quite large, yet we don’t find them particularly hard to use. While size and complexity are certainly related, they are not the same. There are techniques to make large APIs simple and easy to use.
We propose an easy-to-use measure of API complexity: count all named API constructs used in a scenario – all types, methods, enumerated values, constants, and exceptions – and subtract this number from 21. The higher the result, the simpler the scenario is. If the result turns negative, it is a sign to start thinking about simplifying the API. Let’s apply this method to assess a sample Java code written using our (internal) AuthenticationProvider interface, which prints a list of names and phone numbers from the corporate directory:
try { AuthenticationProvider/*20*/ provider = new LocalAuthenticationProvider/*19*/(); SearchCriteria/*18*/ criteria = new SearchCriteria/*17*/(EntityName/*16*/.USER/*15*/); criteria.addPropertyToFetch/*14*/(PropertyName/*13*/.COMMON_NAME/*12*/); criteria.addPropertyToFetch(PropertyName.PHONE/*11*/); criteria.addPropertyToMatch/*10*/(PropertyName.DEPARTMENT/*9*/, "R&D"); criteria.addPropertyToMatch(PropertyName.LOCATION/*8*/, "Waterloo"); criteria.setSortProperty/*7*/(PropertyName.COMMON_NAME); ProfileIterator/*6*/ iterator = provider.search/*5*/(criteria); while(iterator.hasNext()/*4*/){ Profile/*3*/ profile = iterator.next()/*2*/; Property/*1*/ commonName = profile.getProperty/*0*/(PropertyName.COMMON_NAME); Property phone = profile.getProperty(PropertyName.PHONE); System.out.println(commonName.getValue()/*-1*/, “ ”, phone.getValue()); } } catch(AuthenticationProviderException/*-2*/ e) { }
We count each concept only once and we do not count the standard language and library features. The result of -2 shows that the API could use some improvements.
Accidental complexity
Let’s see what we can do. If we replace our custom ProfileIterator
with the standard Java Iterator<Profile>
, the “simplicity score” increases from -2 to 1. If we use the standard NullPointerException
, InvalidArgumentException
, InvalidStateException
and RemoteException
instead of our own AuthenticationProviderException
, the “simplicity score” becomes 2. If we add a direct method like
public String Profile.getValue(PropertyName);
we eliminate one type (Property
) and one method call (getValue
), raising the “simplicity score” to 4. With only a few simple design changes we managed to reduce the complexity of the scenario to an acceptable level.
We can use this measure of complexity to explain certain API design rules and best practices. For example, asking callers to extend classes or implement interfaces is generally discouraged. Why? Because the caller may need to implement/override several methods for a single scenario, lowering the “simplicity score”. Similarly, if we measure the complexity of the design patterns from the “Gang of four” book, we get low numbers, which is one reason why these patterns aren’t recommended in APIs.
Providing alternate implementations for existing interfaces is an obvious, yet effective, technique for adding functionality to APIs without increasing their complexity. The Java Collection Framework is a good example: instead of providing different types for mutable, immutable, re-entrant and non re-entrant collections, it provides alternate implementations. We can turn a regular Set implementation into a re-entrant Set implementation by calling
public static<T> Set<T> Collections.synchronizedSet(Set<T> s)
Instead of an entire new type, this design only requires an additional method in the API.
When we apply design techniques like the ones above, we minimize accidental complexity. To put it simply, accidental complexity occurs when usage scenarios are more complex than necessary. This happens either because we make incorrect assumptions about what features we need or because we accept feature requests too easily.
Feature requests are often a combination of actual requirements and specific API design suggestions. While we must consider the requirements, we shouldn’t feel compelled to accept the design suggestions. API users are selfish; they ask for the simplest solution for themselves, but not necessarily have the interests of other users at heart. One user’s favorite feature becomes another user’s nightmare if we are not careful. The best way to handle such requests is to accept the requirements, express them as use cases, and then find a design which supports them without adding much complexity to unrelated scenarios. We should follow Joshua Bloch’s advice: “You can’t please everyone so aim to displease everyone equally”.
Essential complexity
Accidental complexity is the easy part of the problem. As we add more and more use cases, the complexity of APIs grows, and no design technique can completely prevent this. This is called essential complexity.
The easiest way to reduce essential complexity is by leaving functionality out. As Joshua Bloch says, “They [extreme programming proponents] do advocate leaving out the bells, whistles, and features you don’t need and add them later, if a real need is demonstrated. And that’s incredibly important, because you can always add a feature, but you can never take it out. Once a feature is there, you can’t say, sorry, we screwed up, we want to take it out because other code now depends on it. People will scream. So, when in doubt, leave it out.” When we designed the authentication service used in the example above, we decided that it will not provide related services like authorization, session management, or storage for user accounts. We received some complaints about this decision over the years, but it also enabled us to keep our API reasonably simple.
We must accept that giving up something valuable is the only way to make a use case simpler in the presence of essential complexity. In the previous example we gave up functionality in exchange for simplicity. When this is not possible, we may try giving up some flexibility by deliberately limiting the supported usage scenarios. If we know how the API will be used, we can work with sensible defaults instead of keeping every option open.
For example, an XML processing library has many options for formatting white space in XML output: whether to insert line feeds and where, whether to indent nested XML elements and by how much, whether to use spaces or tabs, and so on. These options exist because the designer of the XML library didn’t know exactly how the library will be used. But if we are using XML to store configuration information, we know that the files are small, there are no deeply nested XML elements, and the administrator views and edits the file using a simple text editor. Thus, we can choose the XML formatting options ourselves and avoid exposing them through the API.
Another possibility is to trade some control for simplicity. Coarse-grained APIs have more functionality per method call and are simpler to use, but offer less control to the caller. Finer levels of granularity give more control at the expense of many more method calls. When APIs are getting complex, we can give up some of this control and increase the granularity of APIs. For example, Data Transfer Object arguments let methods do more work because they carry a lot of information. On the other hand, Data Transfer Objects themselves are simple data structures, having no methods of their own.
Divide and conquer
If, despite our best efforts, uncomfortable levels of complexity remain in our APIs, we shouldn’t get entirely discouraged. People have been dealing with complex problems for a very long time and have devised practical methods of coping with them. All these methods are applications of the same old “divide and conquer” principle.
We can help our users cope with complexity by organizing our APIs into smaller, more manageable parts. For example, a complex multi-media asset management API can be divided into several functional areas, such as basic asset management, metadata management, search, video processing, and so on. With only 24 methods, the core AssetServices
interface handles all essential operations like asset checkout, retrieval, renaming and deletion. MetadataServices
needs only 7 methods for saving and retrieving all extensible descriptive asset metadata. The 16 methods of AssetSearchServices
interface handle all search functionality. These interfaces are in separate namespaces, each with a single entry point highlighted by the use of a consistent naming pattern. The functional areas are reasonably self-contained. Common scenarios can be realized without referencing more than two functional areas. It is also easy to understand how the various parts are tied together by the use of common asset identifiers.
Dividing APIs into functional areas is just one way of organizing them. We can also separate core features from the advanced functionality or higher level calls from lower-level, more detail-oriented ones. No matter how we do it, we are applying the principles of high cohesion and low coupling of modular software design. Refactoring APIs does not remove any functionality, just organizes it into smaller, more manageable units.
We can also remove complexity from common use cases by designing extension hooks into APIs. Common use cases are covered by default, built-in behavior, while fringe cases by plugging in custom logic. We keep the common use cases simple at the expense of making less common ones more complex, a reasonable tradeoff in many situations. For example, a batch processing system may define an Agent to handle compound jobs, and a Distributor to load balance jobs within a cluster of servers. The default Agent and Distributor implementations are designed to work for a well-defined set of common uses cases. For more advanced scenarios, callers can replace the default behavior by registering their own custom Agent or Distributor implementation. While writing custom Agents and Distributors is a complex task, it is rarely needed.
Conclusion
Keeping APIs simple requires effort:
- eliminate accidental complexity by choosing the best available design options
- limit essential complexity by tightly controlling scope, preventing feature creep, and giving up some flexibility or control
- make complexity manageable by organizing APIs into units of high cohesion and low coupling
- consider extension hooks to support advanced scenarios without impacting common ones
It is effort well spent, as simplicity is highly desirable in APIs.
This work is licensed under a Creative Commons Attribution-ShareAlike 2.5 Canada License.