Design Principles in Software Development Part 2

2021/2/17·8 min read

This blog is talking about design principles in software development. It'll focus on the SOLID design principle and provide a few example codes. 

SOLID Design Principle

SOLID design principle is a acronym for following five design principles, these design principle will help building maintainable, testable, flexible, understandable software applications. 

Single Responsibility Principle

"There should never be more than one reason for a class to change. In other words, every class should have only one responsibility."

SRP, single responsibility principle is basically trying to state that one class should only have one purpose or responsibility, this could be similar to the SoC, which is separation of concern with little different. Both of them If we keed the principle in mind will be very helpful for software design at any area, let's look at following sample code.

Assume, you start to use express.js to write a blog system, back-end is MongoDB and you're using node.js mongoose as ORM to access to the MongoDB database, following is express.js route, will take a Id from the page, then perform query to the MongoDB database and return the result to the UI. The UI code is not here but will take the post data then display to the page.

This is not a lots of code, but it has fixed area of code here. You see the code to define the MongoDB schema, also setup the index, also there's a code to query the Posts collection by Id, then there's express.js code to call the data for the UI. Base on the single responsibility, we should devide all these different area of code to separate file, one of the reason is manintainbility, I know following code is few line, but in reality one page's code could be houndreds of thousands of code, it probably call many different services, validation, filter to get the data. So, that's why when you first write the code, it'll be better to keep this principle in mind and break the code to different files.

Let's see how we change this code.

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const PostsSchema = new Schema({
   slug: String,
   title: String,
   state: String,
   content: String,
   createdDate: Date,
   tags: Array
});

PostsSchema.index({
   title: 'text',
   content: 'text'
});

async findById(id: any) {
    return this.PostsSchema.findById(id);  
}

exports.index = async function(req, res) {
   
    const model = await findById(req.params.id);
    
    res.render('my-page-by-id', model);
    
}

posts.schema.ts

First, I think we can move the Posts MongoDB schema to a separate file, this will be a file whenever you update your collection's mongoDB schema, add some default index.

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

export const PostsSchema = new Schema({
   slug: String,
   title: String,
   state: String,
   content: String,
   createdDate: Date,
   tags: Array
});

PostsSchema.index({
   title: 'text',
   content: 'text'
});

Posts.dataService.ts

So, for the MongoDB database access part, create another data repository file as PostsDataService, so this file will responsible for access to the MongoDB and return the result. 

const {PostsSchema} from './Posts.schema.ts';
export class PostsDataService {

   async findById(id: any) {
        return this.PostsSchema.findById(id);  
   }
}

index.ts

Now, the last file is the express.js route, the controller page to handler page request get data and return to the view. Ideally, it should has another business logic layer in between, but after remove MongoDB schema and data access, this file become much more less responsibility now, which validate the request and return the data to the UI. 

const {PostsDataService} from './Posts.DataService.ts';

exports.index = async function(req, res) {
   
   if (!req.params.id) {
       // go to 404 page
      return GoTo404Page();
   }

    const model = await new PostsDataService().findById(req.params.id);
    
    res.render('my-page-by-id', model);
    
}

Open/Closed Principle

"Software entities .. should be open for extension, but closed for modification."

Open and Closed Principle is, when you create a class you should hide or close from outside for its implemntation, but should open to other to extension the functions. Let's take look of previous Posts DataService class, this time I add few more functions. 

Also, you noticed, you'll need to create comment, users, image MongoDB collection, and they all have similar functions as follow. You want they all to use the same implementation to access MongoDB database, but also want to want to extend the function to each collection. 

const {PostsSchema} from './Posts.schema.ts';
export class PostsDataService {

   async findById(id: any) {
        return this.PostsSchema.findById(id);  
   }

    async countAllQuery(condition:any): Promise<number> {
        return await this.PostsSchema.count(condition);
    }

    async deleteOne(model: any): Promise<any> {
        return await this.PostsSchema.deleteOne(model);
    }

    async update(filter: any, doc: any) {
        return await this.PostsSchema.updateOne(filter, doc);
    }}

Let's check how we can achieve this. 

IDataServiceBase.ts

First, create interface which has all the function you think all the other collection can use. 

export interface IDataServiceBase<Dto, Collection> {
    
    create(model: Dto): Promise<Collection>;
    findAll(sort?: object, filter?: object, limit?: number, skip?: number): Promise<Collection[]>;
    findAllByModel(model: Collection): Promise<Collection[]>;
    findOneById(id: any): Promise<Collection>;
    findOne(model: Collection): Promise<Collection>;
    countAll(): Promise<Number>;
    count(model: Collection): Promise<Number>;
    update(filter: Collection, doc: Collection);
    deleteOne(model: Collection): Promise<any>;
}

DataServiceBase.ts

Next, we'll create base data class to implement the interface, so all the detail implementation are inside this DataServiceBase class. 

import { Document } from 'mongoose';
export abstract class DataServiceBase<TDocument extends Document, TModel>
implements InterfaceServiceBase<TModel, TDocument> {
    create(model: TModel): Promise<TDocument> {
        throw new Error("Method not implemented.");
    }
    findAll(sort?: object, filter?: object, limit?: number, skip?: number): Promise<TDocument[]> {
        throw new Error("Method not implemented.");
    }
    findAllByModel(model: TDocument): Promise<TDocument[]> {
        throw new Error("Method not implemented.");
    }
    findOneById(id: any): Promise<TDocument> {
        throw new Error("Method not implemented.");
    }
    findOne(model: TDocument): Promise<TDocument> {
        throw new Error("Method not implemented.");
    }
    countAll(): Promise<Number> {
        throw new Error("Method not implemented.");
    }
    count(model: TDocument): Promise<Number> {
        throw new Error("Method not implemented.");
    }
    update(filter: TDocument, doc: TDocument) {
        throw new Error("Method not implemented.");
    }
    deleteOne(model: TDocument): Promise<any> {
        throw new Error("Method not implemented.");
    }
}

Liskov Substitution Principle

"Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it"

Interface Segregation Principle

"Many client-specific interfaces are better than one general-purpose interface"

ISP, Interface Segregation Principle, it is for making the interface smaller as possible instead of one big interface doing everything, if you do that you might end-up has some interface has declared but doing nothing.

Let's take a look of following example, is is data repository interface which contains basic CRUD operation. Now, let's say you want to implement some operation only contains write or read, would you create a class implement IDataServiceBase?

export interface IDataServiceBase<Dto, Collection> {
    
    create(model: Dto): Promise<Collection>;
    findAll(sort?: object, filter?: object, limit?: number, skip?: number): Promise<Collection[]>;
    findAllByModel(model: Collection): Promise<Collection[]>;
    findOneById(id: any): Promise<Collection>;
    findOne(model: Collection): Promise<Collection>;
    countAll(): Promise<Number>;
    count(model: Collection): Promise<Number>;
    update(filter: Collection, doc: Collection);
    deleteOne(model: Collection): Promise<any>;
}

So for that senario, it should be create the interface only for the need, so I separate IDataServiceBase into following two interfaces. So you can use each interface to implement some detail on separate class file.

export interface IWriteDataServiceBase<Dto, Collection> {
    
    create(model: Dto): Promise<Collection>;
    update(filter: Collection, doc: Collection);
    deleteOne(model: Collection): Promise<any>;

}

export interface IReadDataServiceBase<Dto, Collection> {
    
    findAll(sort?: object, filter?: object, limit?: number, skip?: number): Promise<Collection[]>;
    findAllByModel(model: Collection): Promise<Collection[]>;
    findOneById(id: any): Promise<Collection>;
    findOne(model: Collection): Promise<Collection>;
    countAll(): Promise<Number>;
    count(model: Collection): Promise<Number>;
}

You can also combine two interface tegother if need. 

export interface IDataServiceBase<Dto, Collection> 
extends IReadDataServiceBase<Dto, Collection>, IReadDataServiceBase<Dto, Collection> {

}

Dependency Inversion Principle

"Depend upon abstractions, not concretions"