📜 ⬆️ ⬇️

How to develop an API with backward compatibility. Yandex Workshop

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



If you look at Wikipedia, then backward compatibility 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 the code written for the previous version of the system works functionally in the next version as well.
')
For a developer, backward compatibility primarily implies that the once accepted obligation to provide any functionality cannot be canceled, corrected or ceased to be supported.

Why do I have to commit? First, you save time and money for your users. It is naive to think that it is cheaper to maintain backward compatibility. In fact, you simply smear the cost of customer support. One production pack can cost much more than the entire development of an entire product.

Secondly, you support your karma. Conscious scrapping of backward compatibility frustrates users much more than bugs. People do not like it when they are clearly shown indifference to their problems.

Third, backward compatibility is a competitive advantage. It implies the ability to upgrade to newer 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 later it would not be painfully 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, enter more entities, write redundant code.

Secondly, before embarking on development, it is necessary to designate the area of ​​responsibility, clearly clarifying what will be supported. Minimize situations where some publicly available API is not described in the documentation. Never give read (and, especially, write) entities whose format is not described.

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

Suppose we understood and understood all this. It's time to move on to the rules that we 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 that accepts 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 avoid the loss of backward compatibility? If an interface is declared in the signature, you will have no problems when you have a second (third, fourth) interface implementation. Atomized responsibility of objects. The interface does not impose conditions on what the transmitted object should be: it can be either a descendant of a standard object or an independent implementation.

Why is this useful when designing an API? The allocation of interfaces is first necessary for the developer to restore order in his 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 this rule, you should get a lot of fractional interfaces at the output. 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 present to your objects overlap this hierarchy, you will get a certain hierarchy of interfaces. Now the most important thing: the object has the right to know only about the objects of the next levels.

Why does this help avoid the loss of backward compatibility? The overall connectedness of the architecture is reduced, fewer connections - fewer side effects. And if you change an object, you can only touch its neighbors in the tree.

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

Rule number 3: contexts


Consider any intermediate level hierarchy as an informational context for the underlying stage.

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



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

Why does this help avoid the loss of backward compatibility? A properly constructed context tree will almost never change when refactoring: information flows may appear, but they 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, as it is much easier to keep in mind the information scheme of a project than a full tree. A description of objects in terms of the contexts they provide allows you to correctly distinguish levels of abstraction.

Rule number 4: consistency


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

Similar patterns violate consistency:

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

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

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

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

Rule number 5: events


Organize interaction between objects using events, and in both directions.

Consider 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 if the consistency requirement is met:



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

This helps to avoid loss of backward compatibility, since events are not necessary for execution for both objects: you can easily maintain such implementations of both objects that react only to a part of events and display only a part of the state of the second object. If you have a third object that needs to respond to the same action - you will not have problems.

If you correctly completed 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 in some way. Your organization of interaction between objects is significantly unified. The interaction between objects is thus based on common methods and events, rather than private ones, i.e. will contain much less specific objects

Rule # 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 far 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, connections between objects become as abstract as possible. You can safely rewrite the objects of the lower level of abstraction as a whole if necessary.

Rule number 7: tests


Write tests on the interface.

Rule number 8: external sources


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

Backward compatibility: refactoring


Before embarking


Clarify the situation:



One and a half methods of refactoring:



From release to release


Get yourself a notebook of peace of mind:

Source: https://habr.com/ru/post/237459/


All Articles