How to properly develop APIs with backward compatibility support. Yandex Workshop

    Hello! My name is Sergey Konstantinov, in Yandex I lead the development of the Maps API. Recently, I shared the experience of supporting backward compatibility with my colleagues. My report consisted of two unequal parts. The first, a large one, is devoted to how to properly develop an API so that it will not be excruciatingly painful. The second one is about what to do if you need to refactor something and not break backward compatibility along the way.



    If you look at Wikipedia, then about backward compatibility it will be written there that this is the preservation of the system interface when new versions are released. In fact, for end users, backward compatibility means that code written for a previous version of the system works in the same way in the next version.

    For the developer, backward compatibility primarily implies that once a commitment to provide any functionality cannot be canceled, fixed or ceased to be supported.

    Why do you need to make commitments? First, you save time and money for your users. It is naive to think that supporting backward compatibility is cheaper. In fact, you just spread the cost of customer support. One fakap in production can cost much more than the entire development of the entire product.

    Secondly, you support your karma. Conscious scrapping of backward compatibility upsets users much more than bugs. People really dislike being shown indifference to their problems.

    Third, backward compatibility is a competitive advantage. It implies the ability to switch to more recent versions without development costs, a guarantee that the service will not break production.

    Backward Compatibility: The Right Architecture


    What can be done at the design stage so that then it will not be excruciatingly painful? There are three preliminary points to make. First, backward compatibility is not free. Building the right architecture entails overhead. You will have to think more, introduce more entities, write redundant code.

    Secondly, before embarking on development, it is necessary to identify the area of ​​responsibility, clearly clarify what exactly will be supported. Minimize situations where some publicly available API is not described in the documentation. Never give for reading (and even more so for writing) entities whose format is not described.

    Thirdly, it is assumed that your API is designed correctly and structured by levels of abstraction.

    Suppose that we understood and realized all this. It’s time to move on to the rules that we have learned from our nearly five-year experience.

    Rule number 1: more interfaces


    In the limit, in your public documentation there should not be a single signature accepting specific types, not interfaces. An exception can be made for base global classes and explicitly subordinate components.

    interface IGeoObject :
            IChildOnMap, ICustomizable,
            IDomEventEmitter, IParentOnMap {
        attribute IEventManager   events;
        attribute IGeometry       geometry;
        attribute IOptionManager options;
        attribute IDataManager    properties;
        attribute IDataManager    state;
    }
    Map getMap();
    IOverlay getOverlay();
    IParentOnMap getParent();
    IGeoObject setParent(IParentOnMap parent)
    

    Why does this help to avoid losing backward compatibility? If an interface is declared in the signature, you will not have problems when you have a second (third, fourth) implementation of the interface. The responsibility of facilities is being atomized. The interface does not impose conditions on what the transferred object should be: it can be either an heir to a standard object or an independent implementation.

    Why is this useful when designing an API? Allocation of interfaces is first of all necessary for the developer to restore order in the head. If your method accepts an object with 20 fields and 30 methods as a parameter, it is highly recommended to think about what exactly is needed from these fields and methods.

    As a result of applying this rule, you should get many fractional interfaces. Your signatures should not require more than 5 ± 2 properties or methods from the input parameter. You will get an idea of ​​which properties of your objects are important in the context of the overall system architecture and which are not. As a result, interface redundancy will decrease.

    Rule number 2: hierarchy


    Your objects should be arranged in a hierarchy: who interacts with whom. When the interfaces that you presented to your objects overlap this hierarchy, you will get a certain hierarchy of interfaces. Now the most important thing: an object has the right to know only about objects of neighboring levels.

    Why does this help to avoid losing backward compatibility? The overall connectivity of the architecture is reduced, fewer links - less side effects. And when changing an object, you can only touch its tree neighbors.

    Obtaining this in obvious ways is not always possible. The necessary methods and properties need to be thrown along the chain through the intermediate links (taking into account the level of abstraction, of course!). Thus, you will automatically receive a set of extension points, which can then come in handy.

    Rule 3: Contexts


    Consider any intermediate level of the hierarchy as an information context for the underlying level.

    Example:
    Map= cartographic context (observed area of ​​the map + scale).
    IPane= positioning context in client coordinates.
    ITileContainer= positioning context in tile coordinates.



    Your object tree can be seen as a hierarchy of contexts. Each level of the hierarchy must correspond to some level of abstraction.

    Why does this help to avoid losing backward compatibility? A properly constructed context tree will almost never change during refactoring: information flows can appear, but are very unlikely to disappear. The context rule allows you to effectively isolate hierarchy levels from each other.

    This is useful when designing an API, since keeping in mind the information scheme of a project is significantly simpler than a full tree. And the description of objects in terms of the contexts they provide allows us to correctly identify levels of abstraction.

    Rule No. 4: consistency


    In this case, I use the term consistency in the ACID paradigm for databases. This means that between transactions, the state of objects must always be valid. Any object must provide a full description of its state at any time and a complete set of events that allows you to track all changes in its state.

    Similar patterns violate consistency:

    obj.name = 'что-то';
    // do something
    obj.setOptions('что-то');
    // do something
    obj.update();
    

    In particular, the rule follows from here: avoid the update, build, apply methods.

    This helps to avoid loss of backward compatibility, as an external observer can always fully restore the state and history of an object using its public interface. In addition, such an object can always be replaced or cloned, without knowledge of its internal structure.

    When you have organized such an interaction that there is a state of an object and an event about its change, the nomenclature of the methods and events of your objects becomes less diverse and more consistent. It will become easier for you to select interfaces and keep all this in mind.

    Rule number 5: events


    Organize the interaction between objects with the help of events, and in both directions.

    Let's look at two examples of how you can organize the interaction between the button and the layout:

    button.onStateChange = function () {
        layout.setCaption(state.caption); }
     layout.onClick = function () {
        button.select(); }
    

    vs

    button.onStateChange = function () {
        this.fire('statechange'); }
    layout.onClick = function () {
        this.fire('click') }
    

    The second interaction scheme is obtained natively while observing the consistency requirement:

    • each of the objects knows when the state of the other object has changed;
    • each of the objects can fully determine the state of the other.


    In the first case, the button and layout know the details about each other’s internal structure, in the second - no.

    This helps to avoid loss of backward compatibility, as events are optional for both objects: you can easily support implementations of both objects that respond only to some events and display only part of the state of the second object. If you have a third object that needs to respond to the same action, you will have no problems.

    If you correctly followed the previous four steps, you get a standard pattern: you have, state, events about its change, the underlying object that listens to this event and reacts to it somehow. Your organization of interaction between objects is significantly unified. The interaction between objects in this way is based on general methods and events, rather than private ones, i.e. will contain much less specifics of specific objects

    Rule number 6: delegation


    The sixth rule logically follows from the first five. You have built the whole system, you have interfaces and events, levels of abstraction. Now you need as much as possible to transfer all the logic to the lower level of abstraction. Since the implementation and functionality of the lower level of abstraction (layout, interaction protocols, etc) most often changes, the interface to the lower level of abstraction should be as general as possible.

    With this approach, the connections between objects become as abstract as possible. You can safely rewrite objects of the lower level of abstraction as a whole if necessary

    Rule number 7: tests


    Write tests for the interface.

    Rule number 8: external sources


    In the vast majority of cases, the biggest problems with maintaining backward compatibility arise due to non-preservation of backward compatibility by other services. If you do not control an adjacent service (data source), get a versioned wrapper on it on your side.

    Backward compatibility: refactoring


    Before embarking


    Clarify the situation:

    • If the declared functionality has never worked, you are free to make any decision: repair, change, throw;
    • If something looks like a bug - this is not a reason to rush to repair it;
    • Check the tests on the interface of the object you are going to refactor and related objects;
    • If there are no tests, write them;
    • Never start any refactoring without tests;
    • Testing should include checking the consistency of the behavior of the old and new versions of the API;


    One and a half reception of refactoring:

    • If you did everything right and the interaction of the objects was done according to the “state – state change event” scheme, then often you can rewrite the implementation, leaving the old fields and methods for backward compatibility;
    • Use optional fields, methods, and fallbacks in interfaces — correctly selected defaults will allow you to increase functionality.


    From release to release


    Get yourself a notebook of peace of mind:

    • If you incorrectly named an entity - it will be incorrectly named until the next major release
    • If you made an architectural mistake - it will exist until the next major release
    • Write yourself a problem in a notebook and try not to think about it until the next major release

    Also popular now: