The SOLID principles tell us how to arrange our functions and data structures into classes, and how those classes should be interconnected.
The goal of the principles is the creation of mid-level software structure that:
- Tolerate change,
- Are easy to understand, and,
- Are the basis of components that can be used in many software systems.
The author is the one who made the SOLID principles!
A module should have one, and only one, reason to change.
->
A module should be responsible to one, and only one, user or stakeholder.
->
A module should be responsible to one, and only one, actor.
The opposition of this would be Shotgun surgery.
- Fig 7.1 - The Employee class
class Employee {
calculatePay() {}
reportHours() {}
save()
}
This violates the SRP because those methods are responsible to different actors.
calculatePay
is specified by the accounting department.reportHours
is specified by HR department.save
is specified by the database admins.
The module can be changed by a change of one of three departments. Three different dependencies.
merges will be common in source files that contain many different methods.
Multiple developers check out the class and make changes. The changes collide. Merges are risky.
I think code discoverability is also one.
Perhaps the most obvious way to solve the problem is to separate the data from the functions.
- Fig 7.3 - The three classes do not know about each other
It's interesting that this sounds like an opposition of "encapsulation".
The downside of this solution is that the developers now have three classes that they have to instantiate and track.
-> Solution is to use the Facade pattern.
- Fig 7.4 - The Facade pattern
Employee Facade is a thin module - it just calls the functions.
- Fig 7.5 - The most important method is kept in the original Employee class and use a Facade for the lesser functions
Now it's coming back to "encapsulation", but details are separated.
The principle is about functions and classes.
- At the level of components: The Common Closure Principle
- At the level of architecture: Axis of Change responsible for the creation of Architectural Boundaries
A software artifact should be open for extension but closed for modification.
Imaginary web system:
- Displays a financial summary
- The data is scrollable
- negative numbers are in red
Imaginary request:
- Print on a black-and-white printer.
- Properly paginated.
- Negative numbers should be surrounded by parentheses.
How much old code will have to change?
- Fig 8.1 - Applying the SRP
[Financial Data] -> (Financial Analyze) -> [Financial Report Data] -> (Web Reporter) (Print Reporter)
Organize the code with classes and components.
- Fig 8.2 - Participating the processes into classes and separating the classes into components
Decoupled by the following components;
-
Controller
-
Interactor
-
Database
-
Presenters
-
Views
-
Fig 8.3 - The component relationships are unidirectional
If component A should be protected from changes in component B, then component B should depend on component A.
The interactor is protected from changes in everything else - that best conforms to the OCP. Interactor contains the business rules -> The highest-level policies of the application.
Data will change, how we represent will change, but the business does not change. Let's sort the list above by the hierarchy.
- Interactor
- Database
- Controller (Same level as #2)
- Presenters
- Views
If you see the diagram again, you see the arrows are pointed to correct directions, with interfaces defined. -> "Dependency Inversion" that you learned in Part 2.
Use interface to hide implementation details.
Transitive dependencies are a violation of the general principle that software entities should not depend on things they don't directly use.
The OCP is one of the driving forces behind the architecture of systems.
It's accomplished by partitioning the system into components and arranging them into a dependency hierarchy.
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 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.
- Fig 9.1 - License, and its derivatives, conform to LSP
class Billing {
private license: License;
constructor(license: License) {
this.license = license;
}
getFee() {
return this.license.calcFee();
}
}
// Interface for a class
abstract class License {
abstract calcFee: () => number;
}
class PersonalLicense extends License {
calcFee(): number {
// Something
}
}
class BusinessLicense extends License {
users;
calcFee(): number {
// Something
}
}
Billing
: PLicense
: O2, type TPersonalLicense
: O1, type S (subtype of T)BusinessLicense
: O1, type S (subtype of T)
The behavior of Billing
does not depend on any of those subtypes.
Example of a violation of the LSP.
class User {
shape: Rectangle;
constructor(shape: Rectangle) {
this.shape = shape;
}
shapeArea(width, height) {
// EEK..
if (this.shape instanceof Square) {
// Square
this.shape.setSide(width);
} else {
// Rectangle
this.shape.setW(width);
this.shape.setH(height);
}
return this.shape.area();
}
}
class Rectangle {
width;
height;
area() {
return this.width * this.height;
}
setW(w) {
this.width = w;
}
setH(h) {
this.width = h;
}
}
class Square extends Rectangle {
setSide(s) {
this.width = s;
this.height = s;
}
}
It used to be for use of inheritance. Today, this is for use of interfaces and implementations.
The LSP from an architectural viewpoint. Special cases for acme.com
The LSP can, and should, be extended to the level of architecture. A simple violation of substitutability, can cause a system's architecture to be polluted with a significant amount of extra mechanisms.
- Fig 10.1 The Interface Segmentation Principle
Several users use the operations of OPS class. But the user1 only uses only OPS#op1
, same goes for user2 and user3. The change of op2
will force user1 to be recompiled and destroyed.
- Fig 10.2 Segregated Operations
Create the interface U1Opts that User1 depends on. The Interface describes part of OPS. Same goes for User2 and User3.
Statically typed languages forces programmers to create declarations that users must import
or use
, or otherwise include
. They are forming dependencies and changing dependencies means that it force recompilation and redeployment.
While in dynamically typed languages don't have such declarations because they're inferred at runtime.
This fact could lead you to conclude that the ISP is a language issue, rather than an architecture issue.
In general, it is harmful to depend on modules that contain more than you need.
- Fig 10.3 A problematic architecture
System S -> Framework F -> Database D
Now suppose that D contains features that F does not use and, therefore, that S does not care about. Changes to those features within D may well force the redeployment of F, and therefore, the redeployment of S.
The lesson here is that depending on something that carries baggage that you don't need can cause you troubles that you didn't expect.
We tolerate those concrete dependencies because we know we can rely on them not to change.
The implementation, then, is that stable software architectures are those that avoid depending on volatile concretions, and that favor the use of stable abstract interfaces. This implementation boils down to a set of very specific coding practices:
- Don't refer to volatile concrete classes. Refer to abstract interfaces instead.
- Don't derive from volatile concrete classes.
- Don't override concrete functions.
- Never mention the name of anything concrete and volatile.
To comply with these rules, the creation of volatile concrete objects requires special handling.
- Fig 11.1 Use of Abstract Factory pattern to manage the dependency
- The
Application
uses theConcreteImpl
through theService
interface. - The
Application
calls theServiceFactory#makeSvc
to create aConcreteImpl
instance. - The
ServiceFactory#makeSvc
is implemented by theServiceFactoryImpl
class, derives fromServiceFactory
.
The line in Fig 11.1 is an architectural boundary. It separates the abstract from the concrete. ...(the line) divides the system into two components: one abstract and the other concrete.
- The abstract component contains all the high-level business rules of the application.
- The concrete component contains all the implementation details that those business rules manipulate.
class App {
Run(SvcFactory: ServiceFactory) {
const service: Service = SvcFactory.makeSvc();
const serviceId: string = service.id();
}
}
abstract class ServiceFactory {
abstract makeSvc: () => Service;
}
abstract class Service {
abstract id: () => string;
}
// ~~~~~ architectural boundary ~~~~~
class ConcreteImpl implements Service {
constructor() {}
id() {
return "foo";
}
}
class ServiceFactoryImpl implements ServiceFactory {
public makeSvc(): Service {
return new ConcreteImpl(); // <- DIP violation
}
}
function main() {
// It instantiates `ServiceFactoryImpl`
App.Run(ServiceFactoryImpl);
}
main();
DIP violations cannot be entirely removed, but they can be gathered into a small number of concrete components and kept separate from the rest of the system.
DIP will be the most visible organizing principle. The curve line "architectural boundary" will become a new rule: Dependency Rule.