SOLID Principles
SOLID is a great way to design and engineer software. It is a set of 5 principles that assist in building maintainable code. Strictly following them is a great theory, but we all know reality kicks theory in the south side of a northbound mule at times. The larger the project, the better off you are to follow SOLID architecture.
The five principles are:
Single Responsibility Principle:
Single Responsibility Principle (SRP) simply states the obvious:
Keep it simple (and stupid)
Well not quite the stupid part. A class, function, rule, and so forth shall perform exactly one thing and perform it well. In the real world, developers say that is impossible since our apps must do multiple things. Lets break it down:
You get the point. And the point is exactly correct. Real world apps are complex operations of multiple requirements and use cases. An application must do multiple things... right. Yes but:
For example we have an e-commerce site where we are selling goods we purchase from suppliers at a discounted rate and we sell them for a markup. Sounds simple; Yes but no.
The process can manage:
This is a complex solution and if an single app was written to manage all these operations, it will probably never get finished. Why. Its too complex. But we have a good start. We have already taken the first step to SRP.
In the domain aspect, we now have determined how the business needs to operate into areas that manage a single responsibility of the business.
To develop the applications (it is plural) we need to evaluate the domains and architect the interactions between them and determine the use cases for the inter-domain operations. This is not the domain operations themselves, but how the domains interact with each other. Orchestrators can be set to manage these inter-domain operations where each operator is responsible for a certain process flow and only that process flow.
The domains need to build their use cases and requirements for the domain to operate, and how to be able to communicate with the downstream operations and manage any of the upstream operations communications with their domain. These rules and use cases can be broken down into discrete steps as to which the development begins.
Notice there has not been a single line of code written to this point. Once the ruled are determined, use cases are set, behaviors are determined (happy and sad paths) and then broken from the epic to the feature and then the story (along with test cases at each level) then the coding starts.
Back to SRP. If the process above is done well, we should have an epic that manages a process flow, features required to execute the process flow, and discrete stories to manage one step of the flow.
Why? We have taken a lot of time to manage a complex process and created a series of steps that perform a single task, and the orchestration that is done to manage the steps flow in the proper order.
If you look at an orchestra. There are several domains in an orchestra:
The string domain, for example, has the following types of instruments:
Each of these perform a task unique to their operation: They play music that is in a certain range and crossover of the ranges between instruments is minimal. OK its not truly isolated, but lets go with the flow. The bass player(s) don't play the high notes, because they are responsible for the low notes. You should understand the point now. Strings are there to perform a soft sound (and an occasional pluck sound.) Brass and woodwinds have their own sounds as well as the percussion and vocals.
The conductor of the orchestrator does not make a single sound during the performance. His job is to ensure the different domains interact with each other at the right time and with the right operation. (OK he may speak occasionally, but not during a song). In an orchestra, section someone has been given the role as the leader of the section and ensures each member works together.
In the coding world, by breaking the monolith down into discrete domains, and then into distinct processes, and finally into discrete operations to be ran as a process flow, we are in the right direction to SRP.
Open/Closed Principle:
The most violated principle I have seen in my career is OCP. I am guilty as charged. Its too easy when requirements change to go into existing code, rip out old stuff, add new stuff, and hope it does not break things. This really shows up when there are differences in the class that should have been designed with OOP in mind.
You have seen the code where there are nested if statements, while statements, and deep. In a monolith program I had to support, one method managed all the business rules around the item's operations. The 50K function had nests as deep as 15 deep and looked like a mountain range on its side. My first thought (since it was not my code) is where is OOP? I supported this monolith for many years, and it was nearly impossible to make small changes to without a large effort.
In another class referred to as common, was every function that was used by other objects all placed in a single class. This common operations class was over 500K lines long. It was the inner operations of every module in the monolith. Many of these methods were copy, paste and modify. I never knew what the original code was before it started to expand.
My point is, once you have a class completed, and requirements change, don't modify the class, extend it. Options available to extend a class are
Never modify the class, unless, there is no other way to manage the change. That brings us to...
Liskov's Substitution Principle:
Liskov's principle is critical. If you are going to use an interface for objects that meet certain contracts, then any and all objects that are substituted into the contract must meet the contract.
I have seen so much code implementing an interface and has a bunch more public functions not defined in any interface. These overblown implementations fail because you now have to define the specific object in your orchestrator or unbox the abstraction to the specific concretion to get the rest of the criteria. Now you have started to create code that cannot be managed.
Recommended by LinkedIn
The developers created the code because the interface they used simply failed to meet expectations. New rules were added and new use cases were developed to manage these rules, and suddenly you are creating objects that cannot support the business. What happens is the concretion is used instead of the abstraction in order to manage the new use cases.
The fix to that is to break up the responsibilities, add composition to the orchestrator's objects and perform the new requirements using a contract reflecting the new requirements. The composition added is merely the contract (abstraction).
OK that is as confusing as Liskov's principle's statement (almost). Let's say you have new business rules to execute, and it applies to certain sectors but not all, then you can create a new contract (B) (abstraction), The contract can have as a component, the original contract (A). In essence, your new object manages the new business rules and has a component that performs the original business rules. This brings us to:
Interface Segregation Principle:
So we create additional methods to the contract to fix the LSP violations. When some of the objects require the additional rules and others do not, these old objects must implement a dummy method for the new rules, even though they are not required to do so.
ISP says to break down contracts into smaller contracts that perform a specific set of tasks for similar use cases and not managing unrelated or not required use cases.
A good example is a repository with CRUD operations. We have a repository that performs the basic CRUD operations below:
This interface seems like a good start to a repository. It performs the major operations of a repo. What if...
This is a prime example of a cluttered interface and a violation of ISP. Why? It manages READ operations, WRITE operations and DELETE operations. This is fine if we need all these operations. What if we only require read access. Then the implementation must have dummy implementations of unused requirements. This can cause the program to fail if the wrong condition is met. Lets fix this issue:
Take the first step and create a read-only repository:
We have a read-only repository used when we are only accessing data. Any object implementing this interface can read data, but cannot perform any write or delete requests to the repository.
Next we can manage the read-write section of the repository. The assumption here is any repo that writes to the repo should be able to retrieve data before its updated. Therefore the Write repository shall also inherit the read repository. This repo now meets the requirements for full access to the repo. In the use cases for this repo and for read only access, this contract will work all the use cases required. The enhancements add the ability to write.
A consumer of the Read-Only orchestration can use this version without any modifications. The orchestrators that require a read-write operation can use this abstraction (and concretions can be injected) but the read-only concretions cannot be injected.
Now we need to manage sessions and transactions: Since there are instances where a transaction is required to ensure the write operations all perform correctly before accepting the results, a transactional interface is required. It seems like a moot point, but if you are working with a single table and single write operations, a transaction, and its overhead is overkill. But if multiple tables require write operations, and these need to be fully completed before any are accepted, the transaction is a requirement to ensure the operation completes, or we rollback the transaction to set the data to its previous state.
We now have an insurance where all operations are completed, or the transaction is rolled back. (EF manages rollback if an exception is thrown before the finally statement is called.)
We have built three use cases for managing data in a repository:
At some time in the future new requirements are needed for direct access via native queries or stored procedures have been created for complex data management. Now a new contract is needed:
These new contract definitions are now available for managing the new use cases. This interface does not inherit from previous interfaces. It is in the form of composition (has-a) and a new class can be created that manages these steps of the repository. The class can be implemented independently, and, where needed, the concretion shall be added as another tool in the operation. And to make all the connections possible..
Dependency Inversion Principle:
Remember the orchestra's conductor. He did no work. He just led the operations and let the performers play, when they were needed, and to not play otherwise. An orchestrator is simple a process flow manager. It performs no work. It does not care how the work gets done. It just wants to see the work done in the proper sequence.
You have a document in Office that you want to print. You push the print button. You get a printout. Did you really care how it printed. No. Did the office tool care how its printed. No. Did the print button care how its printed. No.
The print button is a simple example of an orchestrator. Its responsibility is to ensure:
The spooler has the responsibility (as an orchestrator)
Neither of these ever cared about how the document was printed. That was the responsibility of the printer. (That could be considered a rabbit hole we don't care to continue with.)
What does this have to do with DIP. Each of the operations had nothing in their process to perform work. The type of work was done by determining which printer to use, which spooler the job gets inserted into, which printer in the pool to queue up to, etc. All these operators were injected into the orchestrator, based upon the preconditions made at the time the operation was executed.
Conclusion:
I know there is a lot of missing information here. I am looking for feedback from outside as to what examples people want, other languages to show examples with. (I am skilled in C# and to a lesser degree Java.)
This can be considered a draft still
I would also add onion and/or clean architecture and design patterns to the list. With these powers combined....:) will result in your app being very robust!!!
Great article Tom. This will surely help a lot of folks out there that are learning the SOLID principles and trying to implement them. 👏