Solidifying SOLID

Dimitris Pliakos
Dimitris Pliakos
Hexagons resembling how software modules  work together

Solidifying SOLID

S.O.L.I.D. is an acronym referring to a set of principles that work together with the goal of making a system extensible, easily maintainable and as a result more testable. SOLID principles are the foundation for most application design patterns (e.g. hexagonal, clean etc), so I believe it is worth spending some time to wrap your head around these concepts.

Namely, the principles are:

  1. Single responsibility principle
  2. Open/close principle
  3. Liskov substitution principle
  4. Interface segregation
  5. Dependency inversion

These principles, in reality, are working as a set. One can argue that individual principles can be applied, but more often than not, a code base is structured based on SOLID or not. Essentially, these principles are a selection of best practices and patterns passed to us by the previous generation of developers. As far as I can tell, the original written material that grouped them together was the "Clean Architecture" book by Robert Martin (later the acronyms were rearranged to SOLID).

In this article, I'll stick to the practical aspect of the application of the SOLID principles, but I highly encourage you to follow through the SOLID Principles of Object Oriented Design and Architecture by Vasily Zukanov for an extensive presentation of the background.

I should note that SOLID principles are mostly relevant for application code bases. If you're working with scripts and slimmer code modules, you might find these ideas an overkill. Follow your judgment on whether they're usable for your use case. A codebase that you can comfortably understand and reason through is more valuable than anything I can teach you.

Definitions

Developers who work with frameworks like Spring, Angular, and NestJS are probably already familiar with some of the techniques, since the frameworks' dependency injection systems account for these principles.

Even if you never worked with a code base that officially is designed to follow these principles, I would bet that anyone tasked with maintaining code is already familiar with the challenges we're trying to address and the direction of the solution we'll discover. The SOLID principles and, by extension, this article aim at setting the practical definitions of what works best and providing a handbook to support your decisions while deciding on modules' boundaries.

SOLID compatibility

When I develop code, usually the first implementation of a feature is not a fully developed one (unless I'm sure that every aspect is clear). I'm working with products, and usually they're developed gradually. So a system that is constantly being developed has what I call SOLID-compatible and non-SOLID-compatible classes.

A SOLID-compatible class is a class that I'm confident about its level of abstraction. Sometimes this comes early (utilities and commodity code ), and other times this comes later. The latter, I usually avoid the implementation of any sophisticated design patterns, so I can keep my options open as long as possible before the benefits of a strict design outweigh the negatives.

Single Responsibility Principle (SRP)

This principle states that each module should be responsible for achieving one and only one task without side effects.

The definition of responsibility is an open question with no concrete answer. Most of the time, the responsibility comes instinctively from experience or the system specifications. In case of collision, a good rule is to decide under what circumstances this class may change. This will preferably be derived from past data and experience when possible.

Another way to reason while judging if a module passes the SRP test is to assess to what extent the application will be altered when this class is updated, or how extensive the alteration will be. Ideally, each class should be able to change without bleeding implementation details into its environment.

// SRP example // Bad class SteeringWheel { // basic wheel movements turnLeft() {} turnRight() {} // On-wheel controls for the infotainment system volumeUp() {} volumeDown() {} } // Good // Changes only when the volume controls change (e.g. different volume steps) class InfotainmentVolumeControls { volumeUp() {} volumeDown() {} } // changes only when the steering controls change (e.g. power steering) class SteeringControl { turnLeft() {} turnRight() {} } // changes only when the steering wheel changes as an interface // (e.g. add more infotainment system buttons) // the underlying classes can get updated without their implementation bleeding // to the `SteeringWheel` class class SteeringWheel { steeringControl: SteeringControl; infotainmentControl: InfotainmentVolumeControls; turnLeft() { this.steeringControl.turnLeft(); } turnRigh() { this.steeringControl.turnRight(); } volumeUp() { this.infotainmentControl.volumeUp(); } volumeDown() { this.infotainmentControl.volumeDown(); } }

Open/Close principle (OCP)

The official summary of OCP states that a module is "open for extension, but closed for modification," and a summary of the interpretations on it is that a module (class) should allow the developer to change its behaviour (open), but its interface shouldn't be subject to change (closed).

My own understanding from practical use is that the author invites us to decide on an interface and commit to it. The real challenge here is to decide whether an interface is mature enough for committing to it.

I'll admit that in reality, I push this commitment whenever it is possible. This is not always possible, especially when the code is getting reused extensively or published. As a rule of thumb, we can borrow the maturity assessment trick that Martin Fowler states in his book "Refactoring" (sometimes referred to as "The rule of three"). "Here’s a guideline Don Roberts gave me: The first time you do something, you just do it. The second time you do something similar, you wince at the duplication, but you do the duplicate thing anyway. The third time you do something similar, you refactor".

As a backup plan, you can use versioning to overcome a bad decision, but please keep that to a minimum. Version numbers don't carry any qualitative information, and an interface works best when it describes well the implementation it hides.

Earlier, I stated the idea of "SOLID-compatible" classes. This principle is the most impactful exercise when I decide whether a class is SOLID-compatible.

// Bad // Each time new functionality is added, this class has to change // If this class is being used as a library, but the dependent system breaks class InfotainmentControls { volumeUp() {} volumeDown() {} changeSource() {} next() {} previous() {} } // Good // Handles only the volume. Safe to be used as a library class InfotainmentVolumeControls { volumeUp() {} volumeDown() {} } // Handles only the selection controls. Safe to be used as a library class InfotainmentSelectionControls { next() {} previous() {} } // Handles only the steering properties. Safe to be used as a library class SteeringControl { turnLeft() {} turnRight() {} } // Consumes libraries with safe interfaces. When we need to change this // (e.g. for phone capabilities), a new property can be added. The rest // of the classes are not getting affected class SteeringWheel { steeringControl: SteeringControl; infotainmentControl: InfotainmentVolumeControls; infotainmentSelectionControls: InfotainmentSelectionControls; turnLeft() { this.steeringControl.turnLeft(); } turnRigh() { this.steeringControl.turnRight(); } volumeUp() { this.infotainmentControl.volumeUp(); } volumeDown() { this.infotainmentControl.volumeDown(); } next() { this.infotainmentSelectionControls.next(); } previous() { this.infotainmentSelectionControls.previous(); } }

Liskov substitution Principle (LSP)

The Liskov substitution principle is provided to us by Barbara Liskov and states that a module should be designed in a way that allows its substitution by other modules that implement the same interface and behaviour.

The complexity in this rule comes from the fact that not all classes that can implement an abstraction can actually be used as a substitute for the abstraction. In order for a class to be a valid candidate for a substitution, it has to abide by (a) the signature rule and (b) the method pre- and post-conditions.

The signature rule essentially states that all the methods must have identical parameters, and the Exceptions thrown by each subclass must be within the same set of Exceptions expected by the prototype.

The pre-condition rule states that a subclass must not require a different set of conditions prior to its calling. For example, if a property may be null in a superclass's method, but not in the subclass's definition of the same method, the per-condition rule is violated. Similarly, the post-condition guarantees that the output of a method behaves in the same way in both the parent and subclass.

I believe this principle can be marked as the core principle of the SOLID family. The rest of the principles can emerge as dependencies for this principle to be technically possible.

In practice, this principle assumes that you've already decided on an interface (and thus that this interface is mature enough). The need for this principle comes when your goal is for the application to change behaviour based on factors that should not (or can not) be decided during the development.

A popular design pattern that benefits from the Liskov substitution principle is the adapter pattern. You can usually see it in action when you want to change services based on the application runtime environment (e.g. local, staging test) and for services that require external communication (e.g. email, sms) or the humble logging. Of course, adapters can be used for all sorts of implementations.

The source material for this principle is the paper Behavioural Subtyping Using Invariants and Constraints by Barbara Liskov and Jeannette M. Wing

// LSP // Bad class SteeringWheel { // basic wheel movements turnLeft() {} turnRight() {} // On-wheel controls for the infotainment system volumeUp() {} volumeDown() {} } // Good // changes only when the steering controls change (e.g. power steering) abstract class SteeringControl { abstract turnLeft() {} abstract turnRight() {} } class ManualSteeringControl extends SteeringControl { turnLeft() {} turnRight() {} } class HydraulicSteeringControl extends SteeringControl { turnLeft() {} turnRight() {} } class PowerSteering extends SteeringControl { turnLeft() {} turnRight() {} } // Requires the SteeringControl, but not a specific implementation class SteeringWheel { private readonly _steeringControl: SteeringControl; constructor( steeringControl: SteeringControl; ) { this._steeringControl = steeringControl; } }

Interface Segregation Principle (ISP)

This rule requires that each module depend only on the interfaces it is using.

This principle seems a bit similar to the Single Responsibility Principle, but they differ in scope. The SRP tries to answer what is the minimal set of behaviours that a class should implement, while the ISP describes what the essential properties an interface should implement.

The essence of this principle is to help us be more mindful about what interfaces we're using. The author invites us to avoid adding properties to a class that we're not going to utilise. Nothing revolutionary, but as we said at the start, this article aims to set the definitions of what constitutes a well-defined module.

In practice, I found that this principle is useful when the interface is set by a third party and we're tasked to decide on what properties we should include in our own implementation.

I believe we can cover 3 different broad categories:

  1. First-party definition with full ownership
  2. First-party definition with no ownership
  3. Third-party definition

The easiest scenario is when we own the schema and we have full control of the definition. An example is when we borrow definitions from standards (e.g. schema.org). The solution here is obvious, and we can omit the redundant properties.

The other scenario is when the definition is an in-house schema, but you are not the owner (e.g. in-house services and events). In this scenario, it is tempting to request changes to the original schema, but I'd advise against that. Even when we are the only consumers of the data, I'd suggest just limiting the definition on your part. This way you avoid making architectural decisions when there is not required. The only exception here is performance. Design decisions that aim at performance optimisation are beyond the goal of the SOLID design and out of the scope of this article.

For the third-party definitions (e.g. external services or libraries), I prefer to limit my interfaces to the properties I use. I've found that adding link references to online documentation is really helpful.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that both high-level and low-level modules should utilise abstractions, not implementations, when they're declaring dependencies. This helps a codebase be loosely coupled and maintainable.

In practical terms, this principle encourages developers to use abstractions as the middle-level entity where there is some dependency. The upper class depends on the abstraction, and the lower class implements that abstraction. The actual injection happens at run time. So, instead of direct code access, the class that we implement requires an interface that implements an expected functionality. This enables us to make the module composition dynamic.

A prominent implementation of this principle is the dependency injection technique, which enables an application's modules to depend on abstractions and the modules to realise these abstractions with classes. This behaviour becomes visible to the developer when they use injection keys to select a class for the dependency.

This principle can be utilised for business logic module selection or for technical reasons to select dynamically code to be selected for different types of user data (e.g. user group or region) or technical requirement (e.g. while processing different types of files or while selecting or changing a database).

In all honesty, I don't always follow this practice, since it requires a level of abstraction and complexity that is not always justified by the problem being solved. A good test to decide whether you should use an abstraction is to argue whether the adapter pattern is relevant. In case of existing code that doesn't follow the same interface, wrapping them with an abstraction usually helps to achieve homogeneity in the interface (and thus an abstraction can be defined).

Putting it all together

Now that we have the full picture of what the SOLID principles are about, let's wrap up the idea of SOLID. First of all, let's rearrange the principles in a way that is less marketable but more useful for comprehension.

As the core need, I would add the (1) Dependency Inversion Principle. This is the need to split the application into modules in a way that is loosely coupled, yet handy for composition. In order to achieve that, we need each module to have a (2) Single Responsibility and the (3) Segregation of Interfaces supports us in achieving this goal. In practice, to avoid code duplication and in order to share code effectively, the (4) Open/Close principle guides us to decide on what will be public and what private. Finally, the (5) Liskov Substitution Principle helps us determine what constitutes a valid module implementation for a given interface.

So in reality, the order is more like DSIOL than SOLID, but I wouldn't remember the DSIOL acronym.

Full code example

You can find a full code example at github.com/dpliakos/solid-principles-full-example

// Assuming node >= 18 import { readFile } from 'node:fs/promises'; enum SupportedTextFileTypes { TEXT_CSV = 'text/csv', } enum SupportedImageFileTypes { IMAGE_PNG = 'image/png', IMAGE_JPEG = 'image/jpeg', } enum SupportedAudioFileTypes { AUDIO_MPEG = 'audio/mpeg', } type SupportedFileTypes = SupportedTextFileTypes | SupportedImageFileTypes | SupportedAudioFileTypes; interface IFileSource { path: string; type: SupportedFileTypes; } /* Note 1: We have to resist spliting the interfaces too much, thinking that potentially it would be helpful. Indeed, the following type configuration would result in better type hints. I'd advise though to avoid the complexity for now and use a simpler type system since the system does not require that separation. This is a form of the Interface Segregation principle in practice. interface IFileSourceDescription { path: string; } interface ITextFileSourceDescription extends IFileSourceDescription { type: SupportedTextFileTypes; } interface IImageFileSourceDescription extends IFileSourceDescription { type: SupportedImageFileTypes; } interface IAudioFileSourceDescription extends IFileSourceDescription { type: SupportedAudioFileTypes; } type ApplicationFileSourceDescription = ITextFileSourceDescription | IImageFileSourceDescription | IAudioFileSourceDescription; */ /** * * The FileAccessError class encapsulates all the errors caused * by the file. * */ class FileAccessError extends Error { constructor(path: string, options?: ErrorOptions) { super(`Could not access file ${path}`, options); this.name = FileAccessError.name; } } /** * * The ExtractionError class encapsulates all the errors caused by the processing * of a file. * */ class ExtractionError extends Error { constructor(path: string, options?: ErrorOptions) { super(`Could not extract text from ${path}`, options); this.name = ExtractionError.name; } } abstract class FileResolver { private readonly _name: string; constructor() { this._name = (this as object).constructor.name; } protected abstract _resolve(source: IFileSource): Promise<Buffer>; async resolve(source: IFileSource): Promise<Buffer> { try { console.debug(`[${this._name}] Resolving file ${source.path}`); return await this._resolve(source); } catch (err) { if (err instanceof FileAccessError) { throw err; } else { throw new FileAccessError(source.path, { cause: err, }); } } } } class LocalFileResolver extends FileResolver { protected override async _resolve(source: IFileSource): Promise<Buffer> { return await readFile(source.path); // let the super.resolve handle the error } } class HTTPFileResolver extends FileResolver { protected override async _resolve(source: IFileSource): Promise<Buffer> { const result = await fetch(source.path); if (!result.ok) { throw new FileAccessError(source.path, { cause: new Error(`HTTP ${result.status} ${result.statusText}`), }); } const arrayBuffer = await result.arrayBuffer(); return Buffer.from(arrayBuffer); } } interface ITextExtractionResult { text: string[]; metadata?: Record<string, unknown>; } /** * * This abstract class sets the module interface for the TextExtraction classes that * will contain the actual implementation. * * In this example this class is being used to extract the contents of a file to text. * Intuitively one could say that it should only process the contents of the file and the file * management should be handled externally. In order for the Single responsibility principle to be * satisfied. This could be an interpetation indeed, but there is no such a limitation. * * For practical reasons the binding code will always have to be somewhere. We can include it in the module * that actual needs it without violating the single responsibility principle. To do such thing we can * use the Inversion of Dependencies Principle and make the current module depend on an interface and make * it use that interface safely. * * The Single Responsibility Principle is still satisfied since there is only a single reason to update * the current piece of code. * * Also, please notice that we define explicitely: * - The Interface for the methods to be implmented * - The Parameters that should be defined * - The Error types that are allowed to be thrown * * This is the Liskov Substitution Principle in action! * * Furthermore, the class and the errors can be published in a module. Any class that inherits * the TextExtractionService will comply with the Closed interface, yet it will be Open for utilizing any * custom implementation the authors require. * * This is the Open/Close principle! * * @throws {FileAccessError} The file could not be accessed * @throws {ExtractionError} The contents of the file could not be extracted * */ abstract class TextExtractionService { private readonly _name: string; constructor( private readonly _fileResolver: FileResolver, ) { this._name = (this as object).constructor.name; } protected abstract _extract(buffer: Buffer): Promise<ITextExtractionResult>; async extract( source: IFileSource, ): Promise<ITextExtractionResult> { try { const buffer = await this._fileResolver.resolve(source); console.debug(`[${this._name}] Extracting text from ${source.path}`); return await this._extract(buffer); } catch (err) { if (err instanceof FileAccessError) { throw err; } else { throw new ExtractionError(source.path, { cause: err, }); } } } } class CSVTextExtraction extends TextExtractionService { private async _textFromCSV(buffer: Buffer): Promise<string[]> { throw new Error('Not implemented'); } protected override async _extract(buffer: Buffer): Promise<ITextExtractionResult> { return { text: await this._textFromCSV(buffer), } } } class ImageTextExtraction extends TextExtractionService { private async _textFromImage(buffer: Buffer): Promise<string[]> { throw new Error('Not implemented'); } protected override async _extract(buffer: Buffer): Promise<ITextExtractionResult> { // call an OCR tool return { text: await this._textFromImage(buffer), } } } class VoiceTextExtraction extends TextExtractionService { private async _textFromAudio(buffer: Buffer): Promise<string[]> { throw new Error('Not implemented'); } protected override async _extract(buffer: Buffer): Promise<ITextExtractionResult> { // call a audio to text extraction service return { text: await this._textFromAudio(buffer), } } } class TextExtractionRegistry { private readonly _container: Map<SupportedFileTypes, TextExtractionService>; constructor() { this._container = new Map<SupportedFileTypes, TextExtractionService>(); } private getClassName(instance: object) { return instance.constructor.name; } register(types: SupportedFileTypes[], service: TextExtractionService) { for (const type of types) { this._container.set(type, service); } console.debug(`[${TextExtractionRegistry.name}] Registered ${this.getClassName(service)} for types ${types}`); } getForType(fileType: SupportedFileTypes): TextExtractionService { const service = this._container.get(fileType); if (service) { return service; } throw new Error(`No extractor for type ${fileType}`); } } const main = async () => { // 1. Let's initialize the low level classes. At this example both local and http resolvers will be // created and assigned for this example without any reasoning for the selection to each class // as a demonstration. // In a proper application, a dependency injection system would help with the ergonomics // of such cases. const localFileResolver = new LocalFileResolver(); const httpFileResolver = new HTTPFileResolver(); // 2. Let's initialize our support data structure // This is the registry that will store references to the modules. // That way the code will depend on the existance and functionality of the registry // which can have a variable number and supported types of file extractor const textExtractionRegistry = new TextExtractionRegistry(); // 3. Let's initialize the registry with actual module instances // Again, this is Inversion of Dependencies. A reminder that it works both ways. // The initialization module depends on the implementation of the registry regardless of // how it will be utilized later in the execution flow. // In a real application the text extracting modules could support extra dependencies and the initialization // could be a bit more complex. This of course, depends on the situation. // Please, keep in mind that the registry declaration and modules registration could span // between modules allowing various modules to declare text extractors in runtime textExtractionRegistry.register([SupportedTextFileTypes.TEXT_CSV], new CSVTextExtraction(localFileResolver)); textExtractionRegistry.register([SupportedAudioFileTypes.AUDIO_MPEG], new VoiceTextExtraction(httpFileResolver)); textExtractionRegistry.register([ SupportedImageFileTypes.IMAGE_PNG, SupportedImageFileTypes.IMAGE_JPEG ], new ImageTextExtraction(localFileResolver)); // 4. Let's declare all files in the same list. // SOLID helps us build an application that doesn't require unecessary changes // We can take it a step further and make it a adapt on in the runtime const files: IFileSource[] = [ { type: SupportedTextFileTypes.TEXT_CSV, path: '/tmp/path/to/file.csv', }, { type: SupportedAudioFileTypes.AUDIO_MPEG, path: 'https://placehold.co/600x400/EEE/31343C' }, { type: SupportedImageFileTypes.IMAGE_JPEG, path: '/tmp/path/to/file.jpg', }, ]; // 5. Let's execute a simple processing // Then we can proceed processing heterogenous files in the same code block. // This block of code really depends on an TextExtractionService without the real implementation // being known beforehand // This is Inversion of Dependencies in practice! // while avoiding code complexity. Our SOLID code adapts in a declarative fashion for (const file of files) { const resolver = textExtractionRegistry.getForType(file.type); // on error the code throws. Of course, in production we have to handle such cases. const { text } = await resolver.extract(file); console.log('Contents of', file.path); console.log(text); console.log('---'); } } main().catch(console.error);

Conclusion

Given the definition of the principles above, we can see that they can be separated into three categories: (a) slim definitions, (b) replaceable parts, and (c) contracts over implementation.

The way I'm organising the relationship between these principles while coding is that the goal is to have an extensible and maintainable system, and for that (b) replaceable parts are needed. In order to have replaceable parts, they should be (a) slim and thus flexible, and finally, we need the (c) contracts over implementation rule and techniques as a way to bind the slim and flexible modules.

SOLID principles are useful in large code bases and frameworks, but they are not limited to them. I believe it is a useful exercise to think about the modules of your code base through the lens of SOLID, even if you're not using DI in your code currently. Software engineering is all about defining behaviour given a set of technical constraints, and mental exercises like this allow us to understand our system better.

Thank you for reading this article. I hope it helped you make SOLID ...solid!