What are solid principles? - Explained using real examples. How to implement S.O.L.I.D principles in simple language using TypeScript.
A class should have one and only one reason to change.
UserService
class as an example for understanding the above principle. As per the naming and the principle
if interpreted correctly the following class should handle only one type of tasks.
export class UserService {
private databaseService: DatabaseService;
public constructor(databaseService: DatabaseService) {
this.databaseService = new DatabaseService();
}
public get(id: string): Promise<User> {
return this.databaseService.users.getById(id);
}
public update(id: string, data: Partial<User>): Promise<User> {
const user = this.get(id);
if(!user) {
throw new Error('The requested user does not exists');
}
return this.databaseService.users.updateById(id, data);
}
public postComment(data: Partial<Comment>): Promise<Comment> {
return this.databaseService.comments.create(data);
}
}
get
and update
but it also doing something extra - postComment
. The postComment method does not
have anything to do with the user entity and shouldn't be present in this class. Now let's fix this.
export class UserService {
private databaseService: DatabaseService;
public constructor(databaseService: DatabaseService) {
this.databaseService = new DatabaseService();
}
public get(id: string): Promise<User> {
return this.databaseService.users.getById(id);
}
public update(id: string, data: Partial<User>): Promise<User> {
const user = this.get(id);
if(!user) {
throw new Error('The requested user does not exists');
}
return this.databaseService.users.updateById(id, data);
}
}
get
method here as of now are not doing much but simple db call.
export class UserService {
private databaseService: DatabaseService;
public constructor(databaseService: DatabaseService) {
this.databaseService = new DatabaseService();
}
public get(id: string): Promise<User> {
return this.databaseService.users.getById(id);
}
public update(id: string, data: Partial<User>): Promise<User> {
return this.databaseService.users.updateById(id, data);
}
private validateUser(id: string) {
const user = this.get(id);
if(!user) {
throw new Error('The requested user does not exists');
}
}
}
UserUpdateService
: Handles only and only updates to the user entityUserCreateService
: Handles only and only creation of the user entityUserGetService
: Handles only and only fetching of the user entityObjects or entities should be open for extension but closed for modification.
export class Printer {
public print(content: string, type: string) {
if(type == 'image') {
// logic to print image
}
if(type == 'pdf') {
// logic to print pdf
}
if(type == 'doc') {
// logic to print doc
}
}
}
new Printer().print('acme.png', 'img');
new Printer().print('acme.pdf', 'pdf');
new Printer().print('acme.doc', 'doc');
interface IPrintable {
print: (content: string) => void
}
export class ImagePrinter implements IPrintable {
public print(content: string) {
// logic to print image
}
}
export class PdfPrinter implements IPrintable {
public print(content: string) {
// logic to print image
}
}
export class DocPrinter implements IPrintable {
public print(content: string) {
// logic to print image
}
}
new ImagePrinter().print('acme.png');
new PdfPrinter().print('acme.pdf');
new DocPrinter().print('acme.doc');
export class VideoPrinter implements IPrintable {
public print(content: string) {
// logic to print video
}
}
new VideoPrinter().print('acme.mp4');
Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.
Invoice
and two classes extending it ServiceInvoice
and ProductInvoice
despite the two classes have different methods and members both can be substituted
in the printTotal
function as they're extending from the same parent class.
export class Invoice {
private itemAmounts: number[];
constructor(itemAmounts: number[]) {
this.itemAmounts = itemAmounts;
}
public getTotal() {
return this.itemAmounts.reduce((acc, curr) => acc + curr, 0);
}
}
export class ServiceInvoice extends Invoice {
private worker: string;
constructor(itemAmounts: number[], worker: string) {
super(itemAmounts);
this.worker = worker;
}
public getWorkerName() {
return this.worker;
}
}
export class ProductInvoice extends Invoice {
private category: string;
constructor(itemAmounts: number[], category: string) {
super(itemAmounts);
this.category = category;
}
public getCategoryName() {
return this.category;
}
}
function printTotal(invoice: Invoice) {
console.log(invoice.getTotal());
}
printTotal(new ServiceInvoice([10,20], 'Jaremy'));
printTotal(new ProductInvoice([10,20], 'Books'));
A client should never be forced to implement an interface that it doesn’t use, or clients shouldn’t be forced to depend on methods they do not use.
IDevice
having two methods getCost
and getDimensions
. At the start our classes WashingMachine and Fridge implement both of these methods so we're
already satisfying the principle.
export interface IDevice {
getCost: () => number;
getDimensions: () => number[];
}
export class WashingMachine implements IDevice {
getCost = () => 140;
getDimensions = () => [10, 15, 10, 15];
}
export class Fridge implements IDevice {
getCost = () => 145;
getDimensions = () => [10, 20, 10, 20];
}
getScreenResolution
and getCameraResolution
methods in Fridge and WashingMachine. So how do we break this?
export interface IDevice {
getCost: () => number;
getDimensions: () => number[];
// additional methods
getScreenResolution: () => number[];
getCameraResolution: () => number;
}
export class Mobile implements IDevice {
getCost = () => 145;
getDimensions = () => [10, 20, 10, 20];
getScreenResolution = () => [1900, 1200];
getCameraResolution = () => 0.5;
}
IDevice
interface and adding new methods which all of its clients does not need we can create a new interface extending it. This way we're not only using existing interface methods but also extending existing code.
export interface IDevice {
getCost: () => number;
getDimensions: () => number[];
}
export interface MobileDevice extends IDevice {
getScreenResolution: () => number[];
getCameraResolution: () => number;
}
Entities must depend on abstractions, not on concretions.
UserService
class which needs to make some changes to database on call of its methods for example - create
. Looks fine, However what if instead of using mongodb we've decided to use PostgreSQL database?
We'll have to go the service and change the whole service.
// user-service.ts
import mongo from 'mongo';
export class UserService {
private mongoDbInstance;
constructor() {
this.mongoDbInstance = mongo.connect();
}
public create(data: unknown) {
this.mongoDbInstance.users.create(data);
}
}
// user-service.ts
import DatabaseProvider from 'database-provider';
export class UserService {
private databaseProvider;
constructor() {
this.databaseProvider = new DatabaseProvider();
}
public create(data: unknown) {
this.databaseProvider.create(data);
}
}
// database-provider.ts
import pg from 'pg';
export class DatabaseProvider {
public create(data: object) {
pg.create(data);
}
}