The Liskov Substitution Principle at an Architectural Level
Introduction
This is the second article of a series about the SOLID Principles applied at an architectural level. You can read the first one: The Open-Closed Principle at an Architectural Level.
The Liskov Substitution Principle (LSP), as Barbara Liskov wrote it, talks about substitutability of types and defines what a subtype is. In OOP it is closely related to classes and inheritance, but the concept behind it can be extended to other programming paradigms, like Functional Programming (there is an excellent post by Jessica Kerr explaining this). What I'm trying to do here is to show that this concept can also be applied at an architectural level and that you can have the full benefits of it regardless of the programming paradigm you may be using.
Definition
The definition Barbara Liskov wrote was:
What is wanted here is something like the following substitution property: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.
Uncle Bob wrote a great article explaining this principle and translated it in a more intelligible sentence:
Functions that use pointers or references to base classes must be able to use ojbects of delivered classes without knowngn it.
Moreover, I particularly like a summary from Jessica Kerr:
Don't surprise people.
A useful generalization of the LSP could be:
If a system is made of a composition of entities, you need to program these entities in a way that if entity A depends upon entity B (A uses B), A can use an equivalent of B without knowing it. Changes in the interface of B will never surprise the users of this entity.
I'm replacing the concept of subtype by an equivalent entity, one that can be substituted by another one, much more general than a derived class.
Design by Contract
Bertrand Meyer defined Design by Contract, a concept at the heart of LSP, that relies on three requirements: preconditions, postconditions, and invariants.
In the LSP these conditions are defined in terms of a type system, and assert the following:
- Preconditions cannot be strengthened in a subtype.
- Postconditions cannot be weakened in a subtype.
- Invariants of the supertype must be preserved in a subtype.
Using the LSP generalization defined before, these conditions become:
- Preconditions cannot be strengthened in an equivalent entity.
- Postconditions cannot be weakened in an equivalent entity.
- Invariants of the supertype must be preserved in an equivalent entity.
LSP in a Microservices Environment
I'm going to give you an example where the entities are microservices, and the interface they provide is a REST API. Microservice A will reference B through an API Gateway, and we'll try to comply with LSP.
I'm using the Edge API Gateway pattern, but regarding the discussion about LSP, we can also use the two-tier gateway pattern, micro-gateway pattern or a service mesh, the important thing is that microservices communicate through a proxy (the API Gateway). We'll see why in the next post.
The critical question now is, what an equivalent entity is in this context?
Microservice Versioning
The reason why you usually have to replace a microservice is that there is a new version of it. So, if my entity is a microservice, the equivalent entity is the new version of it.
I can think of several reasons to build a new version:
- Adding new functionality.
- Bugfixing.
- Increasing performance.
- Refactoring (enhancing the code structure).
- Reprogramming in a new language.
- Anything you can think of.
The reason to follow LSP in OOP is to enable your code using type T1 to use type T2 instead, as T2 is a subtype of T1. In other words, you don't want to break existing code but alter the behavior. So, applying LSP to microservices, you don't want to break existing clients of the service, but replace it with a better or enhanced one.
We need to find a way to replace microservice A version 1.0 (MSAv1.0) with version 1.1 (MSAv1.1), not only without breaking existing clients but having them utterly unaware of the change (in our example, microservice B, MSB). To our fortune, REST and HTTP are ready to do the first part of the job for us.
In REST, everything is a resource, and resources have a unique URI identifying them. Imagine MSAv1.0 is exposing ResourceA under the URI
http://mydomain/msa/v1.1/resource-a
This does not comply with REST; we have the same resource with different URIs. And worst, if we want MSB to be able to reference ResourceA regardless of the version of MSA, we can't do it with this kind of URI. What we need as URI is something like:
http://mydomain/msa/resource-a
This versioning can't be done in the URI. What can we do, if we don't store the version information in the URI to indicate the resource version? Fortunately, HTTP already has a standard way of doing this, and it is called Content Negotiation. Using standard HTTP headers, a client can specify the format and version of the resource he understands.
In our previous example, MSB can request version 1.0 of ResourceA as follows:
GET http://mydomain/msa/resource-a
Accept: application/json; version=1.0, application/xml; version=1.0
MSB is saying to MSA: I want the resource ResourceA, and I understand the formats: application/json (JSON) version 1.0, application/xml (XML) version 1.0.
MSA then can answer:
HTTP/1.1 200 OK
Content-Type: application/json; version=1.0
{ResourceA representation v1.0}
MSA has been given a choice to respond either in XML or in JSON and has chosen JSON, but in both cases, the version number is 1.0.
Once we have a new version of MSB that understands version 1.1 of ResourceA, it can ask specifically for it (in this case just in JSON):
GET http://mydomain/msa/resource-a
Accept: application/json; version=1.1
and have the correct response:
HTTP/1.1 200 OK
Content-Type: application/json; version=1.0
{ResourceA representation v1.1}
You may argue that if MSBv1.0 understands version 1.0 of ResourceA and accesses it with URI https://mydomain/msa/v1.0/resource-a, it will never try to access URI https://mydomain/msa/v1.1/resource-a. As new resource versions are unknown to old microservices, there is no benefit of doing content negotiation. OK, if we forget for a moment that resources with a version number don't comply with REST, let's remember the different reasons to build a new microservice version. For adding new functionality, this way of constructing the URI is ok, as old clients know nothing about this functionality.
Imagine for a moment that we have MSAv1.0 programmed in Java and that for building the representation of ResourceA we need to issue several database queries, whihc is a resource expensive and time-consuming task. We want to improve our system performance and are delighted with a new language called Scala, so we want to give it a try. As Scala supports Functional Programming, we will try this, too. We also want to get the data from a brand new NoSQL in-memory database and expect the process to be much faster. So, we build MSAv1.1 in a new language and make it more performant.
If we codify the resource version in the URI, MSBv1.0 won't be able to access the resources of the new MSAv1.1 microservice and therefore will not benefit from the enhanced performance of this new service (well, this is not entirely true, you can do some tricks in the API Gateway, but this will increase the system complexity, and this is never a good idea). On the other hand, if we use content negotiation MSBv1.0 is oblivious of the real version of MSA, it just asks for a resource and any version of MSA will return the desired one, taking advantage of any enhancements made to the microservice.
With microservice versioning and Content Negotiation, we have been able to substitute one version of a service for another without breaking existing clients, one of the benefits of LSP. Also, in the process, we've improved our system's performance and even changed the programming paradigm and language!
We have achieved many things, but, are we done? Do you remember Design by Contract? We know how to make clients oblivious of new versions, but still have to deal with microservices semantics. So, we have to do the second part of the job.
Microservice Composability
One of the advantages of a microservice architecture is to have several small, specialized components that can be composed together. You can do it in several and often unexpected ways, and, at the end, the result is bigger than the sum of the parts. Imagine the following simple composition:
We have chained microservices, A, B, and C. A has to be aware of B's semantics and the same for B with C's semantics. This means that every microservice knows the contract (preconditions, postconditions, and invariants) that the one it is calling offers. If, for example, B breaks its contract, A will probably malfunction. This is why it is so crucial for a substitute of B to honor B's contract; otherwise, we'll be facing errors, and the overall composability of the system is jeopardized.
Let's have a look at a specific example with postconditions.
Postconditions and Quality of Service (QoS)
In the previous composition, A has to wait for B's response but, as it can't remain forever, it will probably set up a timeout. If the timeout expires without receiving the response, A will think that B is failing and will try some error handling strategy (retry n times, return an error, etc.). The same happens for B, C, and any microservice that calls another.
Chaining microservices calls, timeouts, and retries can result in a mess quickly. There is an excellent talk from Matthew Squire and Jan Machacek, Reactive Systems Architecture, where they explain this subject, but unfortunately won't be available to the general public for some months.
Instead, there's a better solution introducing the concept of quality of service (QoS): every microservice will make the promise (contract) of returning a response in a limited amount of time (the timeout), regardless of if it is the desired response, an error message, or a message indicating a timeout. If the time to do the job expires, the service has to return a response indicating this fact, but it has to respond within the fixed time. In other words, the maximum response time is established and known by the clients: it is a postcondition.
As we saw earlier, "postconditions cannot be weakened in an equivalent entity," which, in our case, means that a new version of the microservice cannot have a higher maximum response time. If microservice A expects B to respond in 1000ms, returning in less time is right, but doing it in more can lead A to malfunction (retrying a successful operation, returning an error, etc.). Hence the importance of the new versions of the microservice to fulfill the contract and fully comply with the LSP.
Summary
We've seen a brief definition of the Liskov Substitution Principle and a possible generalization of it to work with entities, in our case with microservices. Then we introduced Design by Contract, also generalizing it to deal with microservices.
Next, we realized that the concept of a derived class, in the context of microservices, is a new version of the service. So then, we found a way of satisfying the LSP with microservice versioning, content negotiation, complying with the REST principles and fulfilling a contract, with its preconditions, postconditions, and invariants.
Lastly, we saw a specific example of a postcondition oriented to satisfy Quality of Service and showing that complying with LSP is necessary to enable composability.