Design Principles in Software Development Part 2
Table of Contents
- SOLID Design Principle
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
- Conclusion
Introduction
This blog talks about design principles in software development. It will focus on the SOLID design principles and provide a few example codes. This is Part 2 of the Design Principles series, covering the five SOLID principles that help developers build maintainable and scalable software.
SOLID Design Principle
SOLID design principle is an acronym for the following five design principles. These design principles will help you build maintainable, testable, flexible, and 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, the Single Responsibility Principle, basically states that one class should only have one purpose or responsibility. This is similar to SoC (Separation of Concerns), with a slight difference. If we keep this principle in mind, it will be very helpful for software design in any area. Let's look at the following sample code.
Assume you start to use Express.js to write a blog system. The back-end is MongoDB and you're using Node.js Mongoose as an ORM to access the MongoDB database. The following is an Express.js route that takes an ID from the page, then performs a query to the MongoDB database and returns the result to the UI. The UI code is not shown here but will take the post data and display it on the page.
This is not a lot of code, but it has several areas of concern. You see the code to define the MongoDB schema, the setup for the index, a query to the Posts collection by ID, and Express.js code to call the data for the UI. Based on the Single Responsibility Principle, we should divide all these different areas of code into separate files. One of the reasons is maintainability. I know the following code is just a few lines, but in reality one page's code could be hundreds or thousands of lines. It probably calls many different services, validation, and filters to get the data. That's why when you first write the code, it is better to keep this principle in mind and break the code into 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 the file you update whenever you change your collection's MongoDB schema or 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
For the MongoDB database access part, create another data repository file as PostsDataService. This file will be responsible for accessing MongoDB and returning 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 that handles page requests, gets data, and returns it to the view. Ideally, there should be another business logic layer in between, but after removing the MongoDB schema and data access, this file now has much less responsibility. It simply validates the request and returns 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."
The Open/Closed Principle states that when you create a class, you should hide or close its implementation from the outside, but it should be open for others to extend its functions. Let's take a look at the previous Posts DataService class, this time with a few more functions added.
You'll also notice that you need to create comment, users, and image MongoDB collections, and they all have similar functions as follows. You want them all to use the same implementation to access the MongoDB database, but you also want to extend the functions for 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 {
return await this.PostsSchema.count(condition);
}
async deleteOne(model: any): Promise {
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 an interface which has all the functions you think all the other collections can use.
export interface IDataServiceBase {
create(model: Dto): Promise;
findAll(sort?: object, filter?: object, limit?: number, skip?: number): Promise;
findAllByModel(model: Collection): Promise;
findOneById(id: any): Promise;
findOne(model: Collection): Promise;
countAll(): Promise;
count(model: Collection): Promise;
update(filter: Collection, doc: Collection);
deleteOne(model: Collection): Promise;
}
DataServiceBase.ts
Next, we'll create a base data class to implement the interface. All the detailed implementation goes inside this DataServiceBase class.
import { Document } from 'mongoose';
export abstract class DataServiceBase
implements InterfaceServiceBase {
create(model: TModel): Promise {
throw new Error("Method not implemented.");
}
findAll(sort?: object, filter?: object, limit?: number, skip?: number): Promise {
throw new Error("Method not implemented.");
}
findAllByModel(model: TDocument): Promise {
throw new Error("Method not implemented.");
}
findOneById(id: any): Promise {
throw new Error("Method not implemented.");
}
findOne(model: TDocument): Promise {
throw new Error("Method not implemented.");
}
countAll(): Promise {
throw new Error("Method not implemented.");
}
count(model: TDocument): Promise {
throw new Error("Method not implemented.");
}
update(filter: TDocument, doc: TDocument) {
throw new Error("Method not implemented.");
}
deleteOne(model: TDocument): Promise {
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."
The Liskov Substitution Principle (LSP) means that objects of a subclass should be able to replace objects of the parent class without breaking the application. In other words, if class B is a subclass of class A, you should be able to pass an instance of B anywhere an instance of A is expected, and everything should still work correctly. This encourages designing class hierarchies where derived classes truly extend the behavior of the base class rather than altering it in unexpected ways. Violating LSP often leads to fragile code that relies on type-checking or conditional logic to handle different subclasses.
Interface Segregation Principle
"Many client-specific interfaces are better than one general-purpose interface."
ISP, the Interface Segregation Principle, is about making interfaces as small as possible instead of having one big interface doing everything. If you use a single large interface, you might end up with classes that implement methods they don't actually need, leaving some declared but doing nothing.
Let's take a look at the following example. It is a data repository interface which contains basic CRUD operations. Now, let's say you want to implement some operations that only involve writing or reading. Would you create a class that implements the full IDataServiceBase?
export interface IDataServiceBase {
create(model: Dto): Promise;
findAll(sort?: object, filter?: object, limit?: number, skip?: number): Promise;
findAllByModel(model: Collection): Promise;
findOneById(id: any): Promise;
findOne(model: Collection): Promise;
countAll(): Promise;
count(model: Collection): Promise;
update(filter: Collection, doc: Collection);
deleteOne(model: Collection): Promise;
}
For that scenario, you should create interfaces only for what is needed. So I separated IDataServiceBase into the following two interfaces. You can use each interface to implement specific details in a separate class file.
export interface IWriteDataServiceBase {
create(model: Dto): Promise;
update(filter: Collection, doc: Collection);
deleteOne(model: Collection): Promise;
}
export interface IReadDataServiceBase {
findAll(sort?: object, filter?: object, limit?: number, skip?: number): Promise;
findAllByModel(model: Collection): Promise;
findOneById(id: any): Promise;
findOne(model: Collection): Promise;
countAll(): Promise;
count(model: Collection): Promise;
}
You can also combine the two interfaces together if needed.
export interface IDataServiceBase
extends IReadDataServiceBase, IReadDataServiceBase {
}
Dependency Inversion Principle
"Depend upon abstractions, not concretions."
The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules. Both should depend on abstractions. Additionally, abstractions should not depend on details; details should depend on abstractions. In practice, this means that instead of a class directly creating or referencing a concrete implementation, it should depend on an interface or abstract class. This makes it easy to swap out implementations without modifying the high-level code. For example, in the code we wrote earlier, the Express.js controller depends on the PostsDataService class directly. With DIP, you would have the controller depend on an IDataServiceBase interface instead, allowing you to inject any implementation at runtime.
Conclusion
In this post, we covered the five SOLID design principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. Each principle helps you write cleaner, more maintainable, and more flexible code. By applying SRP you keep classes focused, OCP lets you extend behavior without modifying existing code, LSP ensures subclasses are truly interchangeable, ISP keeps interfaces lean, and DIP decouples high-level logic from low-level details. Keeping these principles in mind from the start will save you significant effort as your codebase grows.