Node.js
June 10, 20267 min read...
Node.jsJune 10, 20267 min read

Node.js Design Patterns: Repository, Clean Architecture, and Dependency Injection

Production Node.js applications need structure. This guide covers Repository Pattern for data access, Dependency Injection for testability, and Clean/Hexagonal Architecture for long-term maintainability.

Node.js Design Patterns: Repository, Clean Architecture, and Dependency Injection

As Node.js applications grow, lack of structure leads to spaghetti code, impossible testing, and fear of refactoring. Design patterns aren't just for Java — they're essential for any serious backend. This guide implements three complementary patterns that work beautifully in JavaScript/TypeScript.

Pattern #1: Repository Pattern (Data Access Layer)

Repository pattern abstracts database logic, making it swappable and mockable:

// domain/user.js - Pure business entity (no database code)
export class User {
  constructor(id, name, email) {
    this.id = id;
    this.name = name;
    this.email = email;
  }

canAccessResource(resource) { // Business logic only return this.isActive && resource.public; } }

// repositories/userRepository.js class UserRepository { async findById(id) { throw new Error(‘Not implemented’); } async save(user) { throw new Error(‘Not implemented’); } async delete(id) { throw new Error(‘Not implemented’); } }

// repositories/postgresUserRepository.js import db from ‘…/db.js’;

export class PostgresUserRepository extends UserRepository { async findById(id) { const result = await db.query(‘SELECT * FROM users WHERE id = $1’, [id]); if (!result.rows[0]) return null; return new User(result.rows[0].id, result.rows[0].name, result.rows[0].email); }

async save(user) { await db.query(‘INSERT INTO users (id, name, email) VALUES ($1, $2, $3) ON CONFLICT DO UPDATE’, [user.id, user.name, user.email]); } }

// repositories/inMemoryUserRepository.js (for testing) export class InMemoryUserRepository extends UserRepository { constructor() { super(); this.users = new Map(); }

async findById(id) { return this.users.get(id) || null; } async save(user) { this.users.set(user.id, user); } }

// Usage in service class UserService { constructor(userRepository) { this.userRepository = userRepository; }

async getUserProfile(userId) { const user = await this.userRepository.findById(userId); if (!user) throw new Error(‘User not found’); return user; } }

Pattern #2: Dependency Injection (Without Frameworks)

DI makes code testable and components replaceable:

// di/container.js (simple manual container)
class Container {
  constructor() {
    this.services = new Map();
  }

register(name, definition, dependencies = []) { this.services.set(name, { definition, dependencies, instance: null }); }

get(name) { const service = this.services.get(name); if (!service) throw new Error(Service ${name} not found);

if (!service.instance) {
  const dependencies = service.dependencies.map(dep => this.get(dep));
  service.instance = new service.definition(...dependencies);
}
return service.instance;

} }

// Setup const container = new Container(); container.register(‘userRepository’, PostgresUserRepository); container.register(‘emailService’, EmailService, [‘smtpClient’]); container.register(‘userService’, UserService, [‘userRepository’, ‘emailService’]);

// In route handler const userService = container.get(‘userService’); const user = await userService.getUserProfile(req.params.id);

// Testing with mock container.register(‘userRepository’, InMemoryUserRepository); const userService = container.get(‘userService’); // uses mock

For production, consider frameworks like awilix or typedi for more features.

Pattern #3: Clean Architecture (Hexagonal/Ports & Adapters)

Clean architecture separates business logic from external concerns (databases, APIs, UI).

Directory structure:

src/
├── domain/           # Enterprise business rules (no dependencies)
│   ├── entities/     # User, Order, Product
│   ├── value-objects/# Email, Address
│   └── repositories/ # Interfaces (ports)
├── application/      # Use cases
│   ├── use-cases/    # RegisterUser, CreateOrder
│   └── services/     # Domain services
├── infrastructure/   # Adapters
│   ├── database/     # PostgreSQL, MongoDB implementations
│   ├── web/          # Express/Fastify controllers, routes
│   ├── messaging/    # RabbitMQ, Kafka
│   └── cache/        # Redis implementation
└── interfaces/       # API definitions (GraphQL, REST schemas)

Domain layer (no external dependencies):

// domain/entities/User.js
export class User {
  constructor(id, email, passwordHash) {
    this.id = id;
    this.email = email;
    this.passwordHash = passwordHash;
  }

verifyPassword(password, bcryptService) { // dependency inversion return bcryptService.compare(password, this.passwordHash); } }

// domain/repositories/UserRepository.js (port) export class UserRepository { async findByEmail(email) { throw new Error(‘Not implemented’); } async save(user) { throw new Error(‘Not implemented’); } }

Application layer (use cases):

// application/use-cases/RegisterUser.js
export class RegisterUser {
  constructor(userRepository, bcryptService, emailService) {
    this.userRepository = userRepository;
    this.bcryptService = bcryptService;
    this.emailService = emailService;
  }

async execute({ email, password }) { const existing = await this.userRepository.findByEmail(email); if (existing) throw new Error(‘User already exists’);

const passwordHash = await this.bcryptService.hash(password, 10);
const user = new User(uuid(), email, passwordHash);
await this.userRepository.save(user);
await this.emailService.sendWelcome(email);

return { userId: user.id };

} }

Infrastructure layer (adapters):

// infrastructure/database/PostgresUserRepository.js
import db from './connection.js';
import { UserRepository } from '../../domain/repositories/UserRepository.js';

export class PostgresUserRepository extends UserRepository { async findByEmail(email) { const result = await db.query(‘SELECT * FROM users WHERE email = $1’, [email]); if (!result.rows[0]) return null; return new User(result.rows[0].id, result.rows[0].email, result.rows[0].password_hash); }

async save(user) { await db.query(‘INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)’, [user.id, user.email, user.passwordHash]); } }

// infrastructure/web/ExpressController.js import { RegisterUser } from ‘…/…/application/use-cases/RegisterUser.js’;

export function registerUserController(req, res, next) { const registerUser = new RegisterUser( container.get(‘userRepository’), container.get(‘bcryptService’), container.get(‘emailService’) );

registerUser.execute(req.body) .then(result => res.status(201).json(result)) .catch(next); }

Benefits of Clean Architecture in Node.js

  • Testability: Domain and application layers can be unit-tested without databases or network.
  • Frameworks are details: Swap Express for Fastify, PostgreSQL for MongoDB, without touching business logic.
  • Maintainability: New team members understand the separation of concerns immediately.

Testing Example (No Database Required)

import { RegisterUser } from './RegisterUser.js';
import { InMemoryUserRepository } from '../../../test/doubles/InMemoryUserRepository.js';

test(‘should not register duplicate email’, async () => { const userRepo = new InMemoryUserRepository(); await userRepo.save(new User(‘1’, ‘test@example.com’, ‘hash’));

const registerUser = new RegisterUser( userRepo, mockBcryptService, mockEmailService );

await expect(registerUser.execute({ email: ‘test@example.com’, password: ‘123’ })) .rejects.toThrow(‘User already exists’); });

Common Mistakes

  • Over-engineering for small apps: Clean architecture adds overhead. Use for apps expected to live >2 years or with >5 developers.
  • Leaking infrastructure into domain: No imports from express, fs, axios in domain layer.
  • Too many layers: Keep it simple. Domain → Application → Infrastructure is enough.

Conclusion

Start with Repository Pattern for data access — it's the lowest friction. Add Dependency Injection as your app grows to improve testability. When you hit complexity with business rules, adopt Clean Architecture to separate concerns. These patterns transform Node.js from a quick script language into a platform for serious, long-lived systems. The investment in structure pays back in debugging time and confidence to refactor.

Comments

Join the conversation — sign in to leave a comment.