How to Correctly Apply the Single Responsibility Principle (SRP) for Better Code

Last updated on May 17th, 2024 at 06:34 am

Welcome back to our blog series on the SOLID principles, where we previously explored how these principles help create applications that are more agile and ready to adapt to changes. Today, we’re focusing on the Single Responsibility Principle (SRP) and demonstrating how you can practically implement this principle in your code.

When developing, one often faces the dilemma of deciding how much to refactor and what constitutes the “right size” for a piece of code. Let’s consider a typical Express application that handles CRUD operations for a user entity:

// Sample Express code snippet handling CRUD for user entity
const express = require('express');
const { Pool } = require('pg');
const app = express();
app.use(express.json()); // Middleware to parse JSON bodies

// PostgreSQL pool connection setup
const pool = new Pool({
    user: 'your_username',
    host: 'localhost',
    database: 'your_database',
    password: 'your_password',
    port: 5432,
});

// Create a new user
app.post('/users', async (req, res) => {
    const { name, email } = req.body;
    const query = 'INSERT INTO users(name, email) VALUES($1, $2) RETURNING *;';
    try {
        const result = await pool.query(query, [name, email]);
        res.status(201).json(result.rows[0]);
    } catch (err) {
        console.error(err.stack);
        res.status(500).send('Server error');
    }
});

// Get a user by ID
app.get('/users/:id', async (req, res) => {
    const { id } = req.params;
    const query = 'SELECT * FROM users WHERE id = $1;';
    try {
        const result = await pool.query(query, [id]);
        res.json(result.rows[0]);
    } catch (err) {
        console.error(err.stack);
        res.status(500).send('Server error');
    }
});

// Update a user
app.put('/users/:id', async (req, res) => {
    const { id } = req.params;
    const { name, email } = req.body;
    const query = 'UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING *;';
    try {
        const result = await pool.query(query, [name, email, id]);
        res.json(result.rows[0]);
    } catch (err) {
        console.error(err.stack);
        res.status(500).send('Server error');
    }
});

// Delete a user
app.delete('/users/:id', async (req, res) => {
    const { id } = req.params;
    const query = 'DELETE FROM users WHERE id = $1;';
    try {
        await pool.query(query, [id]);
        res.send('User deleted successfully');
    } catch (err) {
        console.error(err.stack);
        res.status(500).send('Server error');
    }
});

// Start the server
const PORT = 3000;
app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
});

At first glance, this file looks simple and might not seem to need any refactoring. However, in real applications, functionality often extends beyond managing users. For example, you might need to add roles for users, prompting further API additions. Adding more features to the same file can introduce new bugs and complicate maintenance. It’s generally better to build upon what works rather than making changes to stable components.

In the next sections, we will explore how to refactor our example into a more maintainable structure by applying SRP, thus preparing our application for easy scalability and extension.

Deep Dive into the Single Responsibility Principle: Practical Refactoring for Scalability

How to Refactor Existing Code:

Applying SRP is often confusing, What code should we separate? How do we do it? The refactoring of any code to apply SRP mostly involved following steps.

  1. Identify Responsibilities:
    Before refactoring, it’s crucial to identify what different actions the class or module is performing. This involves a thorough analysis to distinguish between various functionalities.
  2. Create Focused Classes:
    For each responsibility identified, create a new class. This helps in keeping the code modular and easier to manage.
  3. Delegate Responsibilities:
    Adjust the original classes to delegate specific responsibilities to the newly created classes. This not only cleans up the code but also adheres to SRP.

Step-by-Step Refactoring Example:

In our example, the code is performing the following actions:

  • Configuring Database Connection
  • Accessing Data from User Table
  • Implementing CRUD Operations (Business Logic)
  • Exposing CRUD Operations as REST API

Let’s refactor by first separating the database connection and data access layer from the business logic and API handling.

a. Configuring Database Connection

Extracting the database configuration into its own module simplifies the management of database connections and enhances modularity. We can achieve this by simply creating a new database file. This helps us in maintaining the configuration outside the main code. Following is the newly created file.

database.js:

const { Pool } = require('pg');

const pool = new Pool({
    user: 'your_username',
    host: 'localhost',
    database: 'your_database',
    password: 'your_password',
    port: 5432,
});

module.exports = pool;

b. Data Access Object for Users

Now, let us create a dedicated class for handling database operations related to users encapsulates all data access logic in one place. The clear advantage of separating the data access layer from code is if we ever want to integrate a new database into our program, we only need to update this data access layer. Here is the updated code for data access file.

userDataAccess.js:

const pool = require('./database');

class UserDataAccess {
    async createUser(userData) {
        const query = 'INSERT INTO users(name, email) VALUES($1, $2) RETURNING *;';
        const { rows } = await pool.query(query, [userData.name, userData.email]);
        return rows[0];
    }

    async getUserById(userId) {
        const query = 'SELECT * FROM users WHERE id = $1;';
        const { rows } = await pool.query(query, [userId]);
        return rows[0];
    }

    async updateUser(userId, userData) {
        const query = 'UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING *;';
        const { rows } = await pool.query(query, [userData.name, userData.email, userId]);
        return rows[0];
    }

    async deleteUser(userId) {
        const query = 'DELETE FROM users WHERE id = $1;';
        await pool.query(query, [userId]);
    }
}

module.exports = UserDataAccess;

3. Implementing Service Layer for Business Logic

The service layer handles business logic, interfacing with the data access object to manipulate data. This is probably the most important layer of your application. This is where real data is being handled and forms the core of your application logic. Right now our business logic is simple and only consists of CRUD operations.

userService.js:

const UserDataAccess = require('./userDataAccess');
const userDao = new UserDataAccess();

class UserService {
    async addUser(userData) {
        // Add business logic here if necessary
        return await userDao.createUser(userData);
    }

    async findUser(userId) {
        return await userDao.getUserById(userId);
    }

    async updateUser(userId, userData) {
        // Additional logic before updating
        return await userDao.updateUser(userId, userData);
    }

    async deleteUser(userId) {
        // Additional checks before deletion
        return await userDao.deleteUser(userId);
    }
}

module.exports = UserService;

4. Refactoring the REST API Layer

Finally, you should use the service layer to handle requests separately from the REST API layer. Normally, the API layer includes a controller layer that manages API responses.

userController.js:

const UserService = require('./userService');

class UserController {
    constructor() {
        this.userService = new UserService();
    }

    async createUser(req, res) {
        try {
            const user = await this.userService.addUser(req.body);
            res.status(201).json(user);
        } catch (err) {
            console.error(err);
            res.status(500).send('Server error');
        }
    }

    async getUser(req, res) {
        try {
            const user = await this.userService.findUser(req.params.id);
            res.json(user);
        } catch (err) {
            console.error(err);
            res.status(500).send('Server error');
        }
    }

    async updateUser(req, res) {
        try {
            const user = await this.userService.updateUser(req.params.id, req.body);
            res.json(user);
        } catch (err) {
            console.error(err);
            res.status(500).send('Server error');
        }
    }

    async deleteUser(req, res) {
        try {
            await this.userService.deleteUser(req.params.id);
            res.send('User deleted successfully');
        } catch (err) {
            console.error(err);
            res.status(500).send('Server error');
        }
    }
}

module.exports = UserController;

Now, we’ll adjust the app.js to utilize the UserController, which cleanly separates the handling of requests from service logic.

app.js:

const express = require('express');
const UserController = require('./userController');
const userController = new UserController();
const app = express();
app.use(express.json());

app.post('/users', (req, res) => userController.createUser(req, res));
app.get('/users/:id', (req, res) => userController.getUser(req, res));
app.put('/users/:id', (req, res) => userController.updateUser(req, res));
app.delete('/users/:id', (req, res) => userController.deleteUser(req, res));

const PORT = 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

Conclusion

With the restructuring steps outlined in this blog, our Express application has successfully been reorganized to follow the Single Responsibility Principle. Each component now has a clear, dedicated purpose: handling database connections, managing data interactions, implementing business logic, and processing HTTP requests and responses. This setup enhances the maintainability and scalability of the application, preparing it for easier adaptation and growth.

Despite these improvements, the program still faces limitations in terms of modularity. For example, if new features or types of users need to be added to the application, the current structure may require modifying existing classes, potentially introducing new bugs and violating the principle that software entities should be open for extension but closed for modification.

In our upcoming blog, we will address these challenges by applying the Open/Closed Principle (OCP). This principle will guide us in making our application more flexible and robust, allowing it to be open for extension but closed for modification. By integrating OCP, we aim to enhance our software architecture further, building an application that is truly resilient and adaptable without needing to alter existing code. Stay tuned as we delve deeper into enhancing our software design to meet these advanced criteria.

References:

Here are some valuable resources that provide in-depth knowledge about the Single Responsibility Principle and other SOLID principles:

  1. “Clean Architecture: A Craftsman’s Guide to Software Structure and Design” by Robert C. Martin – This book gives detailed insights into SOLID principles and their practical applications.
    Buy on Amazon
  2. Refactoring.Guru – This website is excellent for learning about design patterns and principles, including SRP, with clear explanations and examples.
    Visit Refactoring.Guru on SRP
  3. “Clean Code: A Handbook of Agile Software Craftsmanship” by Robert C. Martin – This book is crucial for developers looking to improve their coding practices and embrace SRP.
    Buy on Amazon
  4. Martin Fowler’s Blog – A resource rich with articles on software architecture, including discussions on the responsibilities of software components.
    Explore Martin Fowler’s Blog

These resources will enhance your understanding and application of SRP in your software development projects.


Discover more from kumarvinay.com

Subscribe to get the latest posts to your email.

2 thoughts on “How to Correctly Apply the Single Responsibility Principle (SRP) for Better Code”

  1. Pingback: How to apply Open/Closed Principle (OCP) correctly | kumarvinay.com

  2. Pingback: SOLID Principles for Scalable Solutions | kumarvinay.com

Leave a Reply

Discover more from kumarvinay.com

Subscribe now to keep reading and get access to the full archive.

Continue reading