阿里云主机折上折
  • 微信号
Current Site:Index > Code organization and architecture design principles

Code organization and architecture design principles

Author:Chuan Chen 阅读数:41662人阅读 分类: Node.js

Code Organization and Architectural Design Principles

Express, as one of the most popular web frameworks for Node.js, offers flexibility and lightweight features that enable developers to quickly build applications. However, as project scale increases, proper code organization and architectural design become critical factors for maintainability and extensibility. A well-designed architecture can significantly reduce long-term maintenance costs and improve team collaboration efficiency.

Layered Architecture Principle

Express applications typically adopt a layered architecture pattern, separating code with different responsibilities into independent layers. A classic three-tier structure includes:

  1. Routing Layer: Handles HTTP requests and responses
  2. Service Layer: Contains business logic
  3. Data Access Layer: Responsible for database interactions
// Routing Layer Example
router.get('/users/:id', async (req, res) => {
  try {
    const user = await userService.getUserById(req.params.id);
    res.json(user);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Service Layer Example
class UserService {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  async getUserById(id) {
    return this.userRepository.findById(id);
  }
}

// Data Access Layer Example
class UserRepository {
  async findById(id) {
    return db.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}

This layered structure ensures single responsibility for each layer, facilitating unit testing and code reuse. Modifying data access implementations won't affect business logic, and adjusting business rules won't impact route handling.

Single Responsibility Principle

Each module, class, or function should have only one reason to change. In Express, this means:

  • Route handlers are only responsible for receiving requests and returning responses
  • Business logic is concentrated in service classes
  • Data operations are encapsulated in dedicated repository classes

A typical violation of single responsibility is the "fat controller":

// Bad Practice: Route handler with too many responsibilities
router.post('/products', async (req, res) => {
  // Input validation
  if (!req.body.name || !req.body.price) {
    return res.status(400).json({ error: 'Missing fields' });
  }
  
  // Business logic
  const discount = req.body.price > 100 ? 0.9 : 1;
  const finalPrice = req.body.price * discount;
  
  // Data operation
  const product = await db.query(
    'INSERT INTO products (name, price) VALUES (?, ?)',
    [req.body.name, finalPrice]
  );
  
  // Send notification
  await emailService.sendNewProductNotification(product);
  
  res.status(201).json(product);
});

Refactored code adhering to the Single Responsibility Principle:

// Routing Layer
router.post('/products', productController.createProduct);

// Controller
class ProductController {
  constructor(productService) {
    this.productService = productService;
  }

  async createProduct(req, res) {
    try {
      const product = await this.productService.createProduct(req.body);
      res.status(201).json(product);
    } catch (error) {
      res.status(400).json({ error: error.message });
    }
  }
}

// Service Layer
class ProductService {
  constructor(productRepository, notificationService) {
    this.productRepository = productRepository;
    this.notificationService = notificationService;
  }

  async createProduct(productData) {
    this.validateProductData(productData);
    const finalPrice = this.applyDiscount(productData.price);
    const product = await this.productRepository.create({
      ...productData,
      price: finalPrice
    });
    await this.notificationService.notifyNewProduct(product);
    return product;
  }
}

Dependency Injection Principle

Dependency Injection (DI) is a key technique for achieving loose coupling in architecture. In Express, dependencies can be injected via constructors:

// Dependency Injection Example
const express = require('express');
const { UserService } = require('./services/user.service');
const { UserRepository } = require('./repositories/user.repository');
const { UserController } = require('./controllers/user.controller');

const app = express();
const userRepository = new UserRepository(db);
const userService = new UserService(userRepository);
const userController = new UserController(userService);

app.get('/users/:id', (req, res) => userController.getUser(req, res));

This approach facilitates:

  • Easy injection of mock objects during unit testing
  • Implementation replacement without affecting other components
  • Clear visibility of component dependencies

Modular Organization

Express application file structure should reflect its architectural design. Common organizational patterns:

/src
  /config         # Configuration files
  /controllers    # Route controllers
  /services       # Business services
  /repositories   # Data access
  /models         # Data models
  /middlewares    # Middleware
  /routes         # Route definitions
  /utils          # Utility functions
  app.js          # Application entry point

Each module should:

  • Have a clear single responsibility
  • Expose public interfaces via index.js
  • Keep internal implementations private
// Module Example: /services/user.service.js
class UserService {
  // Implementation details
}

module.exports = { UserService };

// /services/index.js
const { UserService } = require('./user.service');
const { ProductService } = require('./product.service');

module.exports = {
  UserService,
  ProductService
};

Proper Middleware Usage

Express middleware is powerful but requires careful organization:

  1. Global Middleware: Applied to all routes (e.g., logging, body parsing)
  2. Route Middleware: Specific to route groups (e.g., authentication)
  3. Error Handling Middleware: Dedicated to error handling
// Global Middleware
app.use(express.json());
app.use(requestLogger);

// Route Group Middleware
const authRouter = express.Router();
authRouter.use(authenticate);
authRouter.get('/profile', profileController.getProfile);

// Error Handling Middleware
app.use((err, req, res, next) => {
  logger.error(err.stack);
  res.status(500).send('Something broke!');
});

Avoid embedding business logic in middleware. Middleware should only handle cross-cutting concerns such as:

  • Authentication and authorization
  • Request logging
  • Data validation
  • Response formatting

Configuration Management

Separating configuration from code is an important architectural principle. Express applications typically need to manage:

  • Database connection configurations
  • Third-party API keys
  • Environment-specific variables (development/test/production)

Recommend using dotenv for environment variable management:

// .env file
DB_HOST=localhost
DB_PORT=5432
DB_USER=myuser
DB_PASS=mypassword

// config/database.js
require('dotenv').config();

module.exports = {
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  user: process.env.DB_USER,
  password: process.env.DB_PASS
};

For more complex configurations, use configuration objects:

// config/index.js
const dev = {
  app: {
    port: 3000
  },
  db: {
    host: 'localhost',
    port: 27017,
    name: 'dev_db'
  }
};

const prod = {
  app: {
    port: 80
  },
  db: {
    host: 'cluster.mongodb.net',
    port: 27017,
    name: 'prod_db'
  }
};

const config = {
  dev,
  prod
};

module.exports = config[process.env.NODE_ENV || 'dev'];

Error Handling Strategy

A unified error handling mechanism is crucial for maintainability. Recommended practices in Express:

  1. Use custom error classes to distinguish error types
  2. Centralize error handling in middleware
  3. Standardize error response formats
// errors/app-error.js
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, 404);
  }
}

module.exports = {
  AppError,
  NotFoundError
};

// Usage in Controller
const { NotFoundError } = require('../errors/app-error');

async function getUser(req, res, next) {
  try {
    const user = await userService.getUser(req.params.id);
    if (!user) {
      throw new NotFoundError('User');
    }
    res.json(user);
  } catch (error) {
    next(error);
  }
}

// Error Handling Middleware
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  res.status(statusCode).json({
    error: {
      message: err.message,
      type: err.name,
      details: err.details
    }
  });
});

Test-Friendly Design

Good architecture should facilitate testing. In Express, this means:

  1. Avoid direct module requiring; use dependency injection
  2. Separate side-effect code (e.g., database access)
  3. Keep functions pure (same input always produces same output)
// Testable Service Example
class OrderService {
  constructor(inventoryService, paymentGateway) {
    this.inventoryService = inventoryService;
    this.paymentGateway = paymentGateway;
  }

  async placeOrder(order) {
    await this.inventoryService.reserveItems(order.items);
    const receipt = await this.paymentGateway.charge(order.total);
    return { ...order, receipt };
  }
}

// Test Example
describe('OrderService', () => {
  it('should place order successfully', async () => {
    const mockInventory = { reserveItems: jest.fn() };
    const mockGateway = { charge: jest.fn().mockResolvedValue('receipt123') };
    const service = new OrderService(mockInventory, mockGateway);
    
    const order = { items: ['item1'], total: 100 };
    const result = await service.placeOrder(order);
    
    expect(mockInventory.reserveItems).toHaveBeenCalledWith(['item1']);
    expect(mockGateway.charge).toHaveBeenCalledWith(100);
    expect(result.receipt).toBe('receipt123');
  });
});

Performance Considerations

Architectural design should account for performance factors:

  1. Middleware Order: Place frequently used middleware first to filter invalid requests
  2. Route Organization: Position high-frequency routes earlier
  3. Caching Strategy: Implement appropriate caching layers
// Optimized Middleware Order
app.use(helmet()); // Security-related first
app.use(compression()); // Response compression
app.use(rateLimiter); // Rate limiting
app.use(authMiddleware); // Authentication
app.use('/api', apiRouter); // Business routes

For data-intensive operations, consider adding a caching layer:

// Cache Decorator Example
function cache(ttl) {
  return function(target, name, descriptor) {
    const original = descriptor.value;
    descriptor.value = async function(...args) {
      const cacheKey = `${name}-${JSON.stringify(args)}`;
      const cached = await cacheClient.get(cacheKey);
      if (cached) return JSON.parse(cached);
      
      const result = await original.apply(this, args);
      await cacheClient.set(cacheKey, JSON.stringify(result), 'EX', ttl);
      return result;
    };
    return descriptor;
  };
}

// Using Cache Decorator
class ProductService {
  @cache(60) // Cache for 60 seconds
  async getPopularProducts() {
    return this.productRepository.findPopular();
  }
}

Scalability Design

As business grows, architecture should support horizontal scaling:

  1. Stateless Design: Avoid storing session state in memory
  2. Queue Integration: Process time-consuming operations asynchronously
  3. Microservice Preparation: Prepare for future splits via module boundaries
// Using Redis for Session Storage
const session = require('express-session');
const RedisStore = require('connect-redis')(session);

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: 'your-secret',
  resave: false,
  saveUninitialized: false
}));

// Asynchronous Task Processing Example
router.post('/reports', async (req, res) => {
  const reportId = uuidv4();
  await queue.add('generate-report', {
    reportId,
    userId: req.user.id,
    params: req.body
  });
  res.json({ reportId, status: 'queued' });
});

// Worker Processing Task
queue.process('generate-report', async (job) => {
  const { reportId, userId, params } = job.data;
  const report = await reportService.generateReport(userId, params);
  await storage.save(reportId, report);
  await emailService.sendReportReady(userId, reportId);
});

Documentation and Consistency

Good code organization requires supporting documentation and standards:

  1. API Documentation: Use Swagger or OpenAPI
  2. Architecture Decision Records: Document major design decisions
  3. Code Style Guidelines: Maintain code consistency
// Swagger Documentation Example
/**
 * @swagger
 * /users/{id}:
 *   get:
 *     summary: Get user by ID
 *     tags: [Users]
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: string
 *     responses:
 *       200:
 *         description: The user
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/User'
 */
router.get('/users/:id', userController.getUser);

Architecture Decision Record Example (in docs/adr directory):

001-use-express-framework.md
002-layered-architecture.md
003-postgresql-as-primary-db.md

Modernization Directions

As technology evolves, consider:

  1. TypeScript Integration: Enhance type safety
  2. GraphQL Hybrid: Adopt GraphQL for some APIs
  3. Serverless Adaptation: Deploy to function computing
// TypeScript Controller Example
import { Request, Response } from 'express';
import { UserService } from '../services/user.service';

export class UserController {
  constructor(private userService: UserService) {}

  async getUser(req: Request, res: Response): Promise<void> {
    try {
      const user = await this.userService.getUser(req.params.id);
      if (!user) {
        res.status(404).json({ error: 'User not found' });
        return;
      }
      res.json(user);
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
  }
}

Continuous Evolution and Refactoring

Architecture isn't static; it requires regular evaluation and adjustment:

  1. Monitor Pain Points: Identify frequently modified modules
  2. Track Technical Debt: Record areas needing improvement
  3. Incremental Refactoring: Small-step iterations rather than large-scale rewrites

Refactoring Example: Extracting inline middleware into independent modules

// Before Refactoring
app.use((req, res, next) => {
  if (!req.headers['x-api-key']) {
    return res.status(401).send('API key required');
  }
  if (req.headers['x-api-key'] !== process.env.API_KEY) {
    return res.status(403).send('Invalid API key');
  }
  next();
});

// After Refactoring: /middlewares/api-auth.js
function apiAuth(req, res, next) {
  if (!req.headers['x-api-key']) {
    return res.status(401).send('API key required');
  }
  if (req.headers['x-api-key'] !== process.env.API_KEY) {
    return res.status(403).send('Invalid API key');
  }
  next();
}

module.exports = apiAuth;

// Using Refactored Middleware
const apiAuth = require('./middlewares/api-auth');
app.use(apiAuth);

本站部分内容来自互联网,一切版权均归源网站或源作者所有。

如果侵犯了你的权益请来信告知我们删除。邮箱:cc@cccx.cn

Front End Chuan

Front End Chuan, Chen Chuan's Code Teahouse 🍵, specializing in exorcising all kinds of stubborn bugs 💻. Daily serving baldness-warning-level development insights 🛠️, with a bonus of one-liners that'll make you laugh for ten years 🐟. Occasionally drops pixel-perfect romance brewed in a coffee cup ☕.