top of page
  • Writer's pictureVaughn Geber

Building a Clean and Extensible 3-Tier Architecture with Node.js and Express

Updated: Mar 20, 2023

In modern web development, following best practices and established design principles is crucial for creating clean, maintainable, and extensible applications. One such principle is SOLID, which encompasses five essential concepts to create robust and modular applications. In this blog post, we will explore how to create a clean 3-tier architecture for your Node.js and Express applications, adhering to SOLID principles.


Overview of the 3-Tier Architecture:

The 3-tier architecture is a widely adopted pattern that divides applications into three distinct layers:


  1. Presentation Tier: Handles HTTP requests and responses (routes)

  2. Application Tier: Contains the business logic (services)

  3. Data Tier: Manages data access and storage (models, repositories)

By following this structure, we can separate concerns and responsibilities, making our application more maintainable and easier to understand.


Building a Node.js Express App with 3-Tier Architecture:

Let's dive into creating a clean and extensible 3-tier architecture for a Node.js Express app. We will be working on a simple REST API that follows the SOLID principles. Our sample file structure will look like this:

my-express-app/
│
├── src/
│   ├── controllers/
│   │   └── userController.js
│   ├── models/
│   │   └── user.js
│   ├── repositories/
│   │   └── userRepository.js
│   ├── routes/
│   │   └── userRoutes.js
│   ├── services/
│   │   └── userService.js
│   ├── app.js
│   └── index.js
│
├── .env
├── .gitignore
├── package.json
└── README.md

1. Presentation Tier: We create route files in the routes folder. Each route file should contain only the route definitions and delegate the actual handling to controllers. This separation ensures that our routing logic remains clean and focused.


Example: userRoutes.js

const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');

router.get('/', userController.getAllUsers);
router.post('/', userController.createUser);
router.get('/:id', userController.getUserById);
router.put('/:id', userController.updateUser);
router.delete('/:id', userController.deleteUser);

module.exports = router;

2. Application Tier: The services folder contains service files that hold the business logic of our application. By concentrating the business logic in one place, we can reuse and maintain it more efficiently.


Example: userService.js

const userRepository = require('../repositories/userRepository');

class UserService {
  async getAllUsers() {
    return await userRepository.findAll();
  }

  async createUser(userData) {
    // Add any validation or transformation logic here
    return await userRepository.create(userData);
  }

  // ... other CRUD methods
}

module.exports = new UserService();

3. Data Tier: Model and repository files are placed in the models and repositories folders, respectively. Models define the data structure, while repositories handle data access. This separation allows us to change the underlying data storage technology without affecting the rest of the application.


Example: user.js and userRepository.js


user.js:

const mongoose = require('mongoose');

const UserSchema = new mongoose.Schema({
  name: String,
  email: String,
  // ... other fields
});

module.exports = mongoose.model('User', UserSchema);

userRepository.js:

const User = require('../models/user');

class UserRepository {
  async findAll() {
    return await User.find({});
  }

  async create(userData) {
    return await User.create(userData);
  }

  // ... other CRUD methods
}

module.exports = new UserRepository();

4. Controllers: Controllers are created in the controllers folder. They handle requests, call the appropriate service methods, and send the response back. By delegating responsibilities, we make it easier to test and maintain our code.


Example: userController.js

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

exports.getAllUsers = async (req, res) => {
  try {
    const users = await userService.getAllUsers();
    res.status(200).json(users);
  } catch (error) {
    res.status(500).json({ message: error.message });
  }
};

// ... other CRUD methods

5. App Setup: Finally, we set up our app.js and index.js files. The app.js file configures the Express app, while the index.js file initializes the application and connects to the database.


app.js:

const express = require('express');
const userRoutes = require('./routes/userRoutes');

const app = express();

app.use(express.json());

// Register your routes
app.use('/api/users', userRoutes);

// Error handling middleware
app.use((err, req, res, next) => {
  res.status(err.status || 500).json({ message: err.message });
});

module.exports = app;

index.js:

const app = require('./app');
const mongoose = require('mongoose');
const dotenv = require('dotenv');

dotenv.config();

const PORT = process.env.PORT || 3000;
const MONGO_URI = process.env.MONGO_URI;

mongoose
  .connect(MONGO_URI, {
    useNewUrlParser: true,
    useUnifiedTopology: true,
  })
  .then(() => {
    console.log('Connected to MongoDB');
    app.listen(PORT, () => {
      console.log(`Server is running on port ${PORT}`);
    });
  })
  .catch((error) => {
    console.error('Error connecting to MongoDB:', error);
  });

Conclusion:

By following the 3-tier architecture and adhering to SOLID principles, we can create a clean, maintainable, and extensible Node.js Express application. This structure encourages separation of concerns, making it easier to develop, test, and maintain. As you add more features to your application, continue to follow the same structure for a consistently clean and organized codebase.




140 views0 comments

Comments


bottom of page