Applying SOLID principles to services
A question came in over the wire today about how one might apply the SOLID principles to services when they were originally intended to be about class design.
This actually a very interesting topic. I have noticed that as microservices have taken hold, many engineers are finding that object-oriented languages and design practices have become less useful (assuming their services are reasonably small and self-contained), and are shifting to languages like Go, Rust and others which they feel are less burdensome and easier to work with. The organizing principles around good design of large OO systems don’t seem as important at that scale.
But those principles are actually still very important; they just have relocated to the connections between services. Essentially what has happened is that the composition of large systems is shifting from large object-oriented monoliths to a large network of cooperating services.
Now instead of asking “how big should my class be?” people are asking “how big should my service be?” Instead of asking “how can I safely substitute one implementation of a class with another?” we ask “how do I refactor and evolve my services without breaking my clients?” It is the same problems and questions with a different implementation model.
With this in mind, let’s see if we can map the SOLID principles from classes to services…
For those of you not in the know, SOLID is an acronym for five particularly useful principles:
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
I’m not going to go into the details of these principles, you can read up on them from the link I provided above as well as your own research. Here I’ll just focus on how those might be applied to services.
Single Responsibility Principle
The Single Responsibility Principle is key for services. If you find a service taking on multiple responsibilities, that is likely an indicator it is time to split them up. I find using Domain-Driven Design a powerful tool for coming up with well-defined areas of responsibility. A principle I like to apply is a service should be no smaller than an aggregate, and no larger than a bounded context.
But in general, you can use your spidey-sense to determine if your service is getting too big. Some questions you might ask:
- Do I have multiple teams with different sets of deliverables all trying to make changes to this service’s code base?
- Are my APIs falling into natural groups based on separate concepts? For example, are there separate REST resources for orders, products, and inventory?
- Am I serving multiple types of users? For example, do I have APIs to support sales managers as well as customer service?
- Do I find that there are clear divisions of the code where there is tight collaboration, with rather loose connections to other parts of the code?
These all might be signs that you have multiple responsibilities living within the same service and it might be beneficial to break them up. This is particularly true if there are a lot of teams piling into the same code base.
This principle states that a class should be open for extension but closed for modification. I like to call this a “plugin” model — you can extend a class by “plugging in” different implementations. Think of the power of the browser plugin ecosystem.
So how does this apply to services? I find I use this all the time. It is a very effective way to gain flexibility and decouple components of your system.
There are multiple ways this principle can manifest in service design, but the two I see most frequently are the proxy or dispatcher pattern and the event-driven architecture.
A very popular implementation of the dispatcher pattern is GraphQL. A GraphQL service like Apollo delegates to “plugins” called resolvers. The core GraphQL engine is closed to modification, but open to extension through resolvers.
In event-driven architectures, a service is open to extension by other services or components that listen to events being emitted by the service. In OO systems you would see this implemented as the Observer pattern.
Liskov Substitution Principle
From this great article, Saajan Nagendra succintly defines the Liskov Substitution Principle:
At a high level, the LSP states that in an object-oriented program, if we substitute a superclass object reference with an object of any of its subclasses, the program should not break.
Why is this important? This lets you have the flexibility to change implementations without breaking your consumers. This is just as important in microservice architectures as it was in larger OO system design.
Surprisingly, I have met many engineers who have not thought about this principle as they evolve their system, or do not think it is important to maintain. That may work in a small startup where everyone can collaborate and talk with each other, but you learn pretty quickly this approach does not scale.
The need to honor the LSP in a microservices system rears its head in two important ways.
First of all, every time you deploy a new version of a service, you are essentially changing the implementation of the API contract you have published. Most of us who have worked with larger microservices systems has experienced a consumer breaking when a new service is deployed, and has learned how much work needs to be put into insuring compatibility.
OO languages like Java did a lot of heavy lifting around verifying LSP (although not all, but let’s not go there) through its compiler. In microservice systems, you essentially have to write your own LSP verifier through contract tests and API schema verification.
The second way I see the need to honor LSP is in system evolution. For example, maybe you realize you need to split a service in two, or maybe implement it in a different language, or change its internal logic. To do this safely, you might build a new implementation of the service’s API which delegates to two new services or does dual writes to the old and new service. As you do this, you need to guarantee that this new implementation is fully substitutable for the old one.
Interface Segregation Principle
You can read this article on it but I summarize it as “don’t write big-ass interfaces.” Why is this important? Because you need loose coupling in a system architecture that you want to be able to evolve and adapt easily. Putting a large number of methods together in a single API means that they have to change and evolve together, creating tighter coupling.
This applies perfectly well to service API design. Perhaps your service works with two different types of consumers, administrators that want to configure and administer your service and users that want to make use the features supported by your service. Each of these should be presented as completely separate APIs; you probably even want to provide different levels of permission to them.
Dependency Inversion Principle
The Dependency Inversion Principle in particular seems very much tied to a hierarchical object-oriented design. The way I think of this is that you want to avoid hard-coded dependencies on specific implementations of a class, and that your caller gets to decide which implementation you use. So instead of you depending directly on objects you create, you depend on your caller to give them to you. The dependency is inverted from down to up.
This is very powerful in OO design, as it makes your system much more flexible. Callers can provide the appropriate implementations given the context in which they are calling you, and they can evolve their implementations over time without breaking your code. In a way it’s a specific realization of the Open/Closed Principle.
A pattern that you might squint your eyes and say resembles the OCP in a microservice architecture is the Reactive pattern like you see in RxJs or Project Reactor. Here you send a request, and register to a method or stream processor to receive streamed events from the service.
But this really isn’t the same as physically injecting alternate implementations of a dependency to a service you are calling. It’s not even clear this is a recommended way of doing things in a distributed environment. In a single process space, you can generally trust your caller to hand you safe dependencies. That is definitely not the case for someone calling you over HTTP or some other network protocol.
As long as we have complex systems, we are going to use functional decomposition to break them apart into multiple collaborating components, and we are going to want them to be loosely coupled so we have a system that can change and evolve over time. We may have moved from large OO monoliths to microservices, but the same principles and patterns still apply.