How to Implement SOLID Principles in Express.js and Next.js

How to Implement SOLID Principles in Express.js and Next.js
Implementing SOLID Principles in Express.js and Next.js: The Complete Guide

Introduction to SOLID Principles in Modern Web Development

In today's fast-paced web development landscape, building applications that are maintainable, scalable, and easy to modify is crucial. This is where SOLID principles come into play - a set of five design principles that help developers create robust software architectures.

Originally coined by Robert C. Martin (Uncle Bob), SOLID principles are particularly valuable when working with:

  • Express.js - For building backend APIs and services
  • Next.js - For developing React-based frontend applications

What Does SOLID Stand For?

  1. S - Single Responsibility Principle
  2. O - Open/Closed Principle
  3. L - Liskov Substitution Principle
  4. I - Interface Segregation Principle
  5. D - Dependency Inversion Principle

By applying these principles to your Express.js and Next.js projects, you'll achieve:

  • ✅ Cleaner, more organized code
  • ✅ Easier maintenance and updates
  • ✅ Better testability
  • ✅ Reduced coupling between components
  • ✅ More flexible architecture

1. Single Responsibility Principle (SRP)

A module should have only one reason to change.

Express.js Implementation

❌ Violating SRP (Mixed Concerns)

// routes/users.js - Bad example
router.post('/', (req, res) => {
    // Validation
    if (!req.body.email) {
        return res.status(400).json({ error: 'Email is required' });
    }
    
    // Business logic
    const user = createUser(req.body);
    
    // Database operation
    UserModel.save(user, (err) => {
        if (err) return res.status(500).json({ error: 'DB error' });
        res.status(201).json(user);
    });
});

Problems: Route handles validation, business logic, and database operations.

✅ Proper SRP Implementation

// controllers/userController.js
const userService = require('../services/userService');

exports.createUser = async (req, res) => {
    try {
        const user = await userService.createUser(req.body);
        res.status(201).json(user);
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
};

// services/userService.js
const UserModel = require('../models/User');

exports.createUser = async (userData) => {
    if (!userData.email) throw new Error('Email is required');
    return await UserModel.create(userData);
};

Benefits: Clear separation of concerns, easier testing, and maintenance.

Next.js Implementation

❌ Mixed API Route

// pages/api/users.js - Bad example
export default (req, res) => {
    if (req.method === 'POST') {
        // Validation
        if (!req.body.name) return res.status(400).json({ error: 'Name required' });
        
        // Business logic
        const newUser = { id: Date.now(), ...req.body };
        
        // DB operation
        db.users.push(newUser);
        res.status(201).json(newUser);
    }
};

✅ SRP-Compliant Next.js API

// lib/services/userService.js
export const createUser = (userData) => {
    if (!userData.name) throw new Error('Name required');
    return { id: Date.now(), ...userData };
};

// pages/api/users.js
import { createUser } from '../../lib/services/userService';

export default (req, res) => {
    if (req.method === 'POST') {
        try {
            const user = createUser(req.body);
            // DB operation would be separate
            res.status(201).json(user);
        } catch (error) {
            res.status(400).json({ error: error.message });
        }
    }
};

2. Open/Closed Principle (OCP)

Software should be open for extension but closed for modification.

Express.js Implementation

❌ Violating OCP

// Bad: Need to modify this function for new report types
function generateReport(type, data) {
    if (type === 'pdf') {
        // PDF generation logic
    } else if (type === 'csv') {
        // CSV generation logic
    }
    // Adding new report type requires modifying this function
}

✅ OCP-Compliant Solution

// strategies/reportStrategies.js
const reportStrategies = {
    pdf: (data) => { /* PDF generation */ },
    csv: (data) => { /* CSV generation */ },
    excel: (data) => { /* Excel generation - added without modifying existing code */ }
};

// services/reportService.js
exports.generateReport = (type, data) => {
    const strategy = reportStrategies[type];
    if (!strategy) throw new Error('Invalid report type');
    return strategy(data);
};

Next.js Implementation

❌ Hardcoded Component

function Button({ type }) {
    if (type === 'primary') {
        return <button className="bg-blue-500">Primary</button>;
    } else if (type === 'secondary') {
        return <button className="bg-gray-500">Secondary</button>;
    }
    // Adding new types requires modification
}

✅ Extensible Component

function Button({ variant = 'primary', children }) {
    const variants = {
        primary: 'bg-blue-500 text-white',
        secondary: 'bg-gray-500 text-black',
        danger: 'bg-red-500 text-white'
        // New variants can be added without modifying Button
    };
    
    return (
        <button className={`px-4 py-2 rounded ${variants[variant]}`}>
            {children}
        </button>
    );
}

3. Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types.

Express.js Implementation

❌ Violating LSP

class Database {
    query(sql) { /* Execute any query */ }
}

class ReadOnlyDatabase extends Database {
    query(sql) {
        if (sql.startsWith('INSERT')) throw new Error('Read-only!');
        super.query(sql);
    }
}

// This breaks existing code expecting full DB functionality
const db = new ReadOnlyDatabase();
db.query('INSERT INTO users...'); // Throws error

✅ LSP-Compliant Solution

interface Database {
    query(sql: string): Promise<any>;
}

class ReadWriteDatabase implements Database {
    async query(sql) { /* Execute any query */ }
}

class ReadOnlyDatabase implements Database {
    async query(sql) {
        if (sql.startsWith('INSERT')) {
            throw new Error('Read-only database');
        }
        return executeReadQuery(sql);
    }
}

4. Interface Segregation Principle (ISP)

Clients shouldn't depend on interfaces they don't use.

Express.js Implementation

❌ Fat Middleware

// Bad: Forces all routes to use features they might not need
app.use((req, res, next) => {
    logRequest(req); // Not all routes need logging
    authenticate(req); // Not all routes need auth
    rateLimit(req); // Not all routes need rate limiting
    next();
});

✅ Segregated Middleware

// Separate middleware modules
const logRequest = require('./middleware/logging');
const authenticate = require('./middleware/auth');
const rateLimit = require('./middleware/rateLimit');

// Apply only what's needed
app.get('/public', (req, res) => res.send('Hello'));
app.post('/login', logRequest, authenticate, (req, res) => { /* ... */ });
app.post('/api', logRequest, rateLimit, authenticate, (req, res) => { /* ... */ });

5. Dependency Inversion Principle (DIP)

Depend on abstractions, not concretions.

Express.js Implementation

❌ Tight Coupling

// Bad: Directly depends on MongoDB
const MongoClient = require('mongodb');
const client = new MongoClient(process.env.DB_URI);

app.get('/users', async (req, res) => {
    const users = await client.db().collection('users').find().toArray();
    res.json(users);
});

✅ DIP-Compliant Solution

// interfaces/UserRepository.js
class UserRepository {
    async findAll() {
        throw new Error('Method not implemented');
    }
}

// repositories/MongoUserRepository.js
class MongoUserRepository extends UserRepository {
    constructor(client) {
        super();
        this.client = client;
    }
    
    async findAll() {
        return this.client.db().collection('users').find().toArray();
    }
}

// controllers/userController.js
class UserController {
    constructor(repository) {
        this.repository = repository;
    }
    
    async getUsers(req, res) {
        const users = await this.repository.findAll();
        res.json(users);
    }
}

// app.js
const MongoClient = require('mongodb');
const client = new MongoClient(process.env.DB_URI);
const repository = new MongoUserRepository(client);
const userController = new UserController(repository);

app.get('/users', (req, res) => userController.getUsers(req, res));

Conclusion and Best Practices

Implementing SOLID principles in your Express.js and Next.js applications leads to:

  • 🚀 More maintainable codebases
  • 🧩 Better component reusability
  • 🔧 Easier refactoring and updates
  • 🧪 Improved testability
  • ⚡ More scalable architecture

Key Takeaways

  1. SRP: Keep modules focused on single responsibilities
  2. OCP: Design systems that can be extended without modification
  3. LSP: Ensure subtypes can replace their parents without issues
  4. ISP: Create small, focused interfaces
  5. DIP: Depend on abstractions rather than concrete implementations

By consistently applying these principles, you'll build web applications that stand the test of time and are a joy to work with for your entire team.