BT

Facilitating the Spread of Knowledge and Innovation in Professional Software Development

Write for InfoQ

Topics

Choose your language

InfoQ Homepage Articles Beyond API Compatibility: Understanding the Full Impact of Breaking Changes

Beyond API Compatibility: Understanding the Full Impact of Breaking Changes

Key Takeaways

  • The widespread use of SaaS APIs has exposed an inconsistent approach to handling major version updates and breaking changes.
  • API publishers primarily focus on API backward-compatibility when addressing potential disruptions.
  • Given that modern applications integrate various SaaS APIs, it is vital for API publishers to consider more than just basic API issues, including performance, dependency, wireformat compatibility, and more.
  • Failing to do so could lead to customers losing trust in versioning as a communication tool for changes, forcing API publishers to support older versions to support stragglers and further contribute to bad versioning.
  • The goal is not to achieve compatibility in every aspect, but to identify and clearly communicate the aspects that matter most to the customer segment you care about.

Introduction

Seasoned software engineers are intimately familiar with the concept of versioning software releases. Versions are the cornerstone of API evolution and change management. Semantic Versioning (SemVer) has emerged as the universal standard for communicating and managing API changes. While most parts of semantic versioning have held up to the test of time, there’s one aspect that’s truly been challenged - the concept of backward-incompatible, breaking changes. It is important now more than ever to build a shared understanding of the nuances of API breakages.

Why now?

The proliferation of SaaS platforms has made most real-world applications a melting pot of third-party APIs. The intricacies of modern build systems, the proliferation of libraries, multi-language software stacks, and the SaaS revolution have made it imperative for versioning to be consistently understood by both software publishers and consumers.

Especially for API breaking changes, inconsistent interpretations of semantic versioning result in a deadly cyclic problem for two reasons:

  • Because of the unpredictability of how breaking changes are modeled across APIs, consumers are wary of taking on even reasonable major revision upgrades.
  • In response, API developers hesitate to revise major revisions, since they risk version lags, maintenance burden and slow adoption, and sometimes slip breaking changes into minor or patch versions, further eroding trust.

Publishers of popular APIs often support major versions that are 3-4 years old to allow stragglers to catch up.

With the proliferation of SaaS APIs for Generative AI continuing, now is a good time for a retrospection on what constitutes a breaking change and how you can trade off backward compatibility and upgradability with modernization and iterability.

What this document is, and what it’s not

This article addresses the most contentious and misinterpreted parts of the SemVer standard, i.e. backward compatibility and breaking changes. Notably, the references to it in the following language are subject to interpretation. Our goal is to disambiguate it using real-world practical examples, pointing to open source where possible.

  • Patch version MUST be incremented if only backward compatible bug fixes are introduced.
  • Minor version MUST be incremented if new, backward compatible functionality is introduced to the public API.
  • Major version MUST be incremented if any backward incompatible changes are introduced to the public API.

Most API publishers really only think of one class of breaking changes - changes to the API signature (parameters, including their types, return types, etc.) that would result in a developer having to refactor their integration to an API.

This document serves as a reference to reason about which of these types of backward compatibility APIs/SDKs would want to pursue, and which of these are intentionally ignored. We’ll make recommendations, but our goal is not to prescribe, rather to provide a guide on how to think about API evolution using the SemVer standard. We’ll do this through examples that demonstrate non-obvious breaking changes.

Since most APIs today are accompanied by client libraries, our examples are framed in Java, but they are easy to extrapolate to other languages and non-SDKs. We intentionally don’t go into the details of how these can be solved using a certain design pattern or a certain trick to get around these problems in (say) Gradle or Maven. The goal here is to motivate different classes of breakages.

Let’s start with rudimentary API compatibility before we move onto more nuanced notions of backward compatibility.

API Compatibility

The most commonly recognized form of backward compatibility relates to direct changes in the API. These changes could be alterations in the API signature, such as modifications to parameters, their data types, or return types. Such changes may require developers to revise their existing integration with the API.

// Old version
public interface BookService {
    Book getBookById(int id);
    List<Book> searchBooks(String query);
}

// New version
public interface BookService {
    // Method was renamed
    Book findBookById(int id);

    // New non optional limit parameter would result in compile time error
    List<Book> searchBooks(String query, int limit);
}

ABI Compatibility

In addition to API compatibility, many programming languages also consider ABI (Application Binary Interface) compatibility. For example, in Java, a library might maintain API compatibility even if method signature changes occur, but this can result in a loss of ABI compatibility.

// Old version
public class Example {
    public void doSomething(int value) {
        // Implementation
    }
}

// New version
public class Example {
    public void doSomething(Integer value) {
        // Implementation
    }
}

In this example:

The API is compatible: Programs using the doSomething method of the Example class need not be modified when switching from version 1 to version 2 of the library (assuming they are passing an integer literal or an int variable, which is auto-boxed to Integer in version 2).

The ABI is not compatible: Programs compiled against version 1 of the library will not work with version 2 of the library without recompilation. This is because the method signature has changed: it now takes an Integer object instead of an int. At the JVM level, method signatures include the parameter types, so doSomething(int) and doSomething(Integer) are considered different methods at the binary level.

The concept of Application Binary Interface (ABI) compatibility might appear unfamiliar or less relevant for basic APIs. However, it becomes increasingly prevalent, especially when your implementations utilize Foreign Function Interfaces (FFIs) to communicate with underlying native platforms. FFIs allow programs written in one programming language to call functions and use services written in another language, often at a lower level. Ensuring ABI compatibility in such scenarios is crucial for maintaining the integrity and functionality of the software, as it relies on consistent data structures, function signatures, and calling conventions across different programming environments.

Partner API compatibility

It’s increasingly common for SaaS platforms to promote some kind of "better-together" interoperability, emphasizing enhanced functionality and seamless integration when their products are used in conjunction.

Consider a scenario involving a Payments API and an Analytics API. In this case, the client libraries of these APIs are designed to automagically work well together. When a transaction is processed through the Payments API, it automatically triggers the generation of client events:

// Old version
public interface AnalyticsClient {
    //Not a public API. For partner use only
    public void logPaymentSuccess();
}

public class PaymentsClient {
    public void processPayment(AnalyticsClient a) {
        a.logPaymentSuccess();
    }
}
AnalyticsClient analytics = new AnalyticsClientImpl()
PaymentsClient payments = new PaymentsClient()
payments.processPayment(analytics);
// New version
interface AnalyticsClient {
    // Not a public API. For partner use only
       // Introduce a backward incompatible change that was informed to the partner
    public void logPaymentSuccess(String source, String authToken);
}

AnalyticsClient analytics = new AnalyticsClientImpl()
PaymentsClient payments = new PaymentsClient()

// will not compile unless the payments library is also upgraded.
payments.processPayment(analytics); 

Even if the Analytics partner notified the Payment organization in advance about the breakage of the partner API, it still requires the end user of both of these libraries to concurrently upgrade both the Analytics API and the Payment API to prevent any disruptions in service. Modern build systems have advanced to better handle dependencies and to establish version-conflict resolution policies. However, it is crucial to meticulously analyze and understand these types of API changes and their implications. An upgrade experience that forces a customer to upgrade multiple dependencies together may still be treated as a breaking change.

Performance compatibility

Public APIs are ways to communicate contracts. In the real world, consumers of the API interpret contracts variably. Consider APIs that encourage a "fire and forget" invocation pattern (logging, counters, etc.). Typically, implementation-related changes are not seen as breaking in such cases. But anything that adds a noticeable latency to the invocation can lead to sizable behavioral changes and customer breakages.

private static final Logger simpleLogger = new Logger() {
        @Override
        public void log(String message) {
            System.out.println(message); // Just a simple console logger for this example.
        }
    };

private static final Logger simpleLogger = new Logger() {
        @Override
        public void log(String message) {
            
            System.out.println(message);
            
            //Expensive IO Operation 
            writeLogToFile(message);
        }
    };

Wireformat compatibility

When developing APIs, it's crucial to choose appropriate tools for schema evolution and data serialization. Not every API can be flexible with inter-process communication (IPC) and remote procedure call (RPC) formats and thus limit themselves to JSON. Scenarios like bidirectional streaming, chatty APIs, and handling large payloads demand more specific serialization approaches. This can lead to a class of subtle breakages.

Take, for example, a SaaS API for an on-premise logging system. Upgrading the on-premise API might not change its interface. However, if the upgrade alters the data format - like representing floats as strings - it could require a simultaneous update of all client applications. This is often challenging to coordinate and can lead to breaking changes.

Tools like Protobuf, Flatbuffers, Avro, and Parquet are beneficial for API developers. They allow for a better understanding of schema evolution and its integration with data transmission methods.

System-level compatibility

In modern development, SDKs are frequently deployed in varied and less predictable environments, like mobile operating systems. This situation often leads to the issue of API implementation details becoming exposed, which weren't intended to be part of the public interface.

A common scenario is with Android SDKs, where developers specify a minSDKVersion. This represents the lowest Android version the SDK is compatible with. If an SDK update incorporates a new system-level API that's only available in later Android versions, the minSDKVersion in the SDK's manifest must be increased. While this change is technically necessary, it's not always perceived as an API breakage by developers.

However, this can lead to conflicts for API consumers targeting an older version, which encounter the notorious "minSdkVersion x cannot be smaller than version x+n declared in library". Consequently, API consumers are forced to increase their minSDKVersion, leading to losing a segment of their user base still on older Android versions.

Downgradability

If consumers upgrading to a newer API version cannot go back to the previous version, that is likely a breaking change. As an example, if the upgraded version renames a database column that cannot be understood by older implementations, it makes it impossible to rollback or downgrade the API. Here’s a real world example related to the popular Google Firebase SDK for Android.

The inability to downgrade the SDK may mean that it becomes impossible to rollback the entire application, drastically increasing the risk incurred by the developer when adopting the upgrade. Such changes may need to be treated as breakages.

    // v1 
    public void init() {
        // perform necessary database migrations
    }

    // v2
    // executing this version of init will make it impossible to downgrade to older versions that expect the old column name
    public void init() {
        // perform necessary database migrations
        execute("ALTER TABLE table_name RENAME COLUMN user TO username;");
    }

Data collection/ storage/ retention compatibility

If your new APIs have changed the data that they implicitly collect, store, or process, it may be wise to communicate this as a breaking change. Inadvertently collecting data has real-world legal implications for consumers, and is likely to affect software distribution. Most SaaS providers should publicly communicate how their data collection policies transitively affect the app’s privacy guarantees in relation to the App Store, PlayStore and regulatory bodies around the world.

Dependency compatibility

Breakages introduced by dependencies of your SDK. Unless you "shade" your dependencies and bundle them into your distribution (not always ideal or possible!), the symbols in your SDK's dependencies are also part of the app's namespace. This can be particularly problematic if the app uses an incompatible version of the same library used by an APIs, resulting in symbol conflicts that are tricky to resolve. It is essential to depend only on highly stable libraries, where breaking changes are rare and thoughtfully made.

A key learning is to never have your public API expose the API of a dependency. This can be disastrous, resulting in you having to evolve your APIs alongside that of your dependency. Consider the following scenario:

import org.joda.time.DateTime;

public interface DateProcessorInterface {
    void processDate(DateTime jodaDate);
}

Everytime the date library introduces a breaking change, you’ll likely need to treat that as breaking, for customers that themselves depend directly on the date library.

So, select your dependencies carefully, consider shading or re-namespacing them and keep them up to date.

Implicit contract compatibility

While your APIs indicate a specific contract, customers interpret this contract from their viewpoint. Breaking even such implicit contracts leads to an unpleasant experience. Consider the following example:

auth.getSignedInUser(email, password)
        .addOnCompleteListener(this, new OnCompleteListener<AuthResult>() {
            @Override
            public void onComplete(@NonNull Task<AuthResult> task) {
                // Callback fires immediately if the session is already in memory
                if (task.isSuccessful()) {
                    
                } else {
                   
                }
            }
        });

While you might document your API as a long-running asynchronous operation, there may be customers that notice that your API returns immediately most of the time and design their UI to, say, not include a spinner. As a SaaS provider, you're burdened with not introducing implementation changes that may cause the callback to not be fired immediately just so that it does not break such unintended contracts.

While you may find it justifiable to modify these implicit agreements, be aware that customers might view these changes as disruptions. Proper documentation and build tools like lint checks can help identify such implicit contracts and help API consumers avoid painful migrations.

Conclusion

The landscape of SaaS APIs presents complex and ever-evolving challenges. The key takeaways from this discussion highlight the importance of a nuanced understanding of versioning and the impact of breaking changes.

Firstly, any inconsistency in handling major version updates and breaking changes across SaaS APIs can create significant disruptions. API publishers often focus narrowly on API compatibility, overlooking broader implications. The integration of multiple SaaS APIs in modern applications necessitates a broader view, encompassing various aspects of API functionality and behavior.

Secondly, there's a growing realization that breaking changes involve more than just alterations in API signatures. It encompasses a range of factors, from ABI and wireformat compatibility to system-level changes and implicit contract expectations. These changes, if not managed properly, can erode customer trust in versioning as a reliable tool for communicating changes, forcing publishers to support outdated versions and perpetuating poor versioning practices.

The examples shown in this article show that achieving complete backward compatibility in all aspects is not always feasible or desirable. The real objective is to identify and clearly communicate changes that are most significant to your customer base. This involves a careful balancing act between maintaining backward compatibility, encouraging upgradability, and embracing modernization and iteration.

The examples provided illustrate the complex nature of API evolution. These are not merely technical challenges but also involve a deep understanding of customer needs and expectations. By understanding the various dimensions of compatibility and breaking changes, API publishers can make informed decisions that not only enhance their products but also foster trust and reliability among their user base.

About the Author

Rate this Article

Adoption
Style

BT