SOLID Foundations for Scalable and Maintainable Software
Part 2: Cloud computing and dynamic orchestration
In Part 1, we explored the Single Responsibility Principle and its application outside the original object-oriented programming scope. Don’t forget to check it out if you haven’t yet:
This issue will explore the rest of the SOLID principles and how they are interlinked with components and services running in dynamically orchestrated environments that promote maintainability, scalability, and robustness.
Open/Closed Principle in Containerized Applications
The Open/Closed Principle (OCP) states:
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification
By adhering to the OCP, containerized applications can be designed so that new functionalities can be added as new services or modules without altering the existing system.
One inherent benefit of OCP is its application in updates or upgrades in containers themselves, where containers can be updated, added, or replaced independently without disrupting the overall system. This complies with dynamic orchestration's needs for rapid scaling and updates.
Let’s take an example of a service written in Javascript and bundled within a Docker container with installed NodeJS v20. The service runs within a Kubernetes cluster with four instances to manage load and prevent downtime.
Now, scoping the OCP to the service itself as a software entity, upgrading NodeJS in containers to version 21 means creating a new image with NodeJS v21, making necessary changes in other parts of the application and its dependencies, and performing a rolling update of the running replicas of the service.
As the service is represented to the outside world via its API, we can draw a parallel between creating a new bundle with upgraded dependencies and implementing an abstract interface defined by the API, as referred to in the OOP version of the OCP.
So, the service represented by its API and implemented by the containerized application is open to an extension, an upgrade of the framework via a new version of a container, but closed to modification, which, for an illustrative example, could be an upgrade of the system inside of an already existing and running container.
Liskov Substitution Principle for API Management
The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of its subclasses without affecting the application. This means that a subclass should not change the expected behavior of the superclass.
Formally:
Let φ(x) be a property provable about objects x of type T. Then φ(y) should be true for objects y of type S where S is a subtype of T.
Let’s explore how LSP improves API management, particularly maintaining backward compatibility, enabling the continuous development of microservices, and managing breaking changes.
Ensuring Backward Compatibility
When an API follows the LSP, it ensures its new versions can directly replace older ones. This means a new version (subtype) can add new functionality but should not remove or fundamentally change existing functionality in the current version (supertype).
Examples of allowed changes:
adding new endpoints
adding new optional fields in responses
adding new optional fields in error responses
adding new optional parameters to existing requests
For example, a new version of a microservice must be able to handle all requests that the previous version could handle. If the LSP were not followed, all clients relying on the previous API version would break.
Breaking Changes Management
When introducing changes that might break the LSP, such as changing the endpoint behavior, removing an existing field, or changing its type, versioning the API allows the old and new versions to coexist.
API versioning allows clients using the API to opt into the new behavior at their own pace and under the control of their owners, thus keeping adherence to the LPS. For some time, both old and new API versions coexist, allowing clients to migrate. After all clients are migrated, the old API can be deprecated.
Client-Server Interaction
The goal is to ensure API's responses remain predictable and consistent with previous versions, and if a breaking change is introduced, clients are entirely in control of the transition.
We can use contract testing to verify the new version of an API adheres to the LSP and is compatible with the previous one. A contact testing suite consists of a set of real requests that are issued against the API during a test run. Requests test that the agreed-upon contact is kept both from requests and responses point of view.
Improved Access Control via Interface Segregation
The Interface Segregation Principle (ISP) is not only an integral part of SOLID but also a core concept in IDEALS, a set of principles aimed directly at Microservices architecture.
In SOLID, the ISP is defined as:
Clients should not be forced to depend upon interfaces that they do not use.
In IDEALS, it can be stated as:
Each type of client sees the service contract that best suits its needs.
So far, so good. Each microservice exposes only the interfaces necessary for communication with other services or clients. This reduces dependencies and simplifies updating or replacing services without affecting the entire system. Popular approaches for microservices to implement the ISP are API Gateway or Backend-for-Frontend (BfF).
One often-overlooked benefit of the ISP is improved security and access control within the system.
By applying ISP, each microservice exposes only the necessary interfaces for its specific set of clients. This means that any given client has access only to the functionalities it genuinely requires. This approach reduces the potential attack surface since clients cannot see parts of the system that are irrelevant to them. If a particular interface or service is compromised, the attacker's access remains limited to the service’s scope.
Segregated interfaces make monitoring access and usage patterns for each service easier. Each service can have targeted monitoring for quicker detection and response to potential security threats.
Anomalous behavior, such as an unexpected increase in requests to a particular interface, can be identified quickly and investigated more easily because it's clearer which activity patterns are expected for each segregated interface and which are not.
Dependency Inversion and Abstracting Details
The Dependency Inversion Principle (DIP) aims at creating loosely coupled software modules/components:
High-level modules should not import anything from low-level modules. Both should depend on abstractions.
Abstractions should not depend on details. Details should depend on abstractions.
Loose coupling
By applying the DIP, services communicate with each other through interfaces or contracts rather than direct references to concrete implementations. This abstraction layer means that a service does not need to know the details of another service's implementation, data storage, or business logic.
For instance, a subscription processing service might interact with a notification service through a simple "send notification" interface without knowing whether notifications are sent via email, SMS, or another method.
At the same time, failures in one service are less likely to impact others directly. For example, if the notification service fails, the subscription service can still operate and cache the unsent notifications in a queue for replaying later on when the notification service is available again.
Configuration management
By applying the DIP, we can separate configuration details from business logic by treating configurations as dependencies injected into services. The same service code can run in different environments without modification. This approach aligns with the principles of the Twelve-Factor App, enhancing portability and scalability.
In dynamic orchestration environments like Kubernetes, services must adjust to changing conditions, such as varying loads, different network topologies, or storage resources. Services can adapt dynamically by abstracting and injecting these environmental factors as dependencies.
With the abstraction provided by the DIP, configurations can also be managed centrally, for example, in a configuration server. Services can then retrieve their configurations at runtime, reducing the need to manage environment-specific configurations in the service codebase itself. This simplifies deployment and improves security by keeping sensitive information, like database credentials or API keys, out of the service's code or CD pipelines.
What I am listening to
How to Think Like a Roman Emperor by Donald J. Robertson
While the connection between ancient philosophy and modern software engineering might not be apparent at first glance, the parallels in decision-making, problem-solving, and leadership are profound.
The book delves into concepts like understanding what is within our control and what is not—a principle that can be transformative when applied to system design or managing project timelines. It encourages a reflective approach to decision-making, urging us to consider our responses to failure, stress, and uncertainty.
The goal is to cultivate resilience, improve our problem-solving skills, and lead with greater clarity and effectiveness while navigating the complex, often unpredictable world of software engineering.