DEV Community

Cover image for Stop Writing Spaghetti Code: A Developer's Guide to Clean Architecture in Node.js
Teguh Coding
Teguh Coding

Posted on

Stop Writing Spaghetti Code: A Developer's Guide to Clean Architecture in Node.js

Stop Writing Spaghetti Code: A Developer's Guide to Clean Architecture in Node.js

I've been there. We all have.

You open a file, expecting to make a quick fix, and suddenly you're staring at a 600-line function that does everything — database queries, business logic, input validation, email sending, and somehow also formats a PDF. You close the laptop. You question your career choices.

This is spaghetti code. And it's killing your productivity, your team's morale, and your app's maintainability.

Today, we're fixing that — with a practical, real-world guide to Clean Architecture in Node.js.


Why Architecture Actually Matters

Let me paint two pictures:

Scenario A: Your team needs to swap from MongoDB to PostgreSQL. You spend 3 weeks untangling database calls from business logic scattered across 40 files.

Scenario B: Same requirement. You update one adapter layer. Done in a day. Tests still pass.

The difference? Architecture.

Clean Architecture (popularized by Uncle Bob's Clean Code) is built on one core idea: your business logic should not care about your database, your framework, or your HTTP library.

Let's build something real.


The Project: A User Management API

We'll build a simple user registration system. Nothing fancy, but enough to show the architecture in action.

The Layer Structure

src/
├── domain/          # Business rules (pure JS, no dependencies)
│   ├── entities/
│   └── repositories/  # Interfaces only
├── use-cases/       # Application logic
├── infrastructure/  # DB, external services, frameworks
│   ├── database/
│   └── http/
└── interfaces/      # Controllers, presenters
Enter fullscreen mode Exit fullscreen mode

Each layer has one job. And crucially, dependencies only point inward — infrastructure knows about use-cases, but use-cases don't know about infrastructure.


Layer 1: The Domain Entity

This is the heart of your app. Pure business rules. Zero external dependencies.

// src/domain/entities/User.js
class User {
  constructor({ id, name, email, createdAt }) {
    this.id = id;
    this.name = name;
    this.email = email;
    this.createdAt = createdAt || new Date();
  }

  validate() {
    if (!this.name || this.name.trim().length < 2) {
      throw new Error('Name must be at least 2 characters');
    }
    if (!this.email || !this.email.includes('@')) {
      throw new Error('Invalid email address');
    }
    return true;
  }

  toJSON() {
    return {
      id: this.id,
      name: this.name,
      email: this.email,
      createdAt: this.createdAt,
    };
  }
}

module.exports = { User };
Enter fullscreen mode Exit fullscreen mode

Notice: no require('mongoose'), no require('express'). This class could run in a browser, a CLI, or a serverless function — it doesn't care.


Layer 2: The Repository Interface

We define what we need from storage, not how it works.

// src/domain/repositories/IUserRepository.js
class IUserRepository {
  async findByEmail(email) {
    throw new Error('findByEmail() must be implemented');
  }

  async save(user) {
    throw new Error('save() must be implemented');
  }

  async findById(id) {
    throw new Error('findById() must be implemented');
  }
}

module.exports = { IUserRepository };
Enter fullscreen mode Exit fullscreen mode

This is our contract. The use-case depends on this interface — not on MongoDB, not on PostgreSQL.


Layer 3: The Use Case

This is where your application logic lives. One use case, one job.

// src/use-cases/RegisterUser.js
const { User } = require('../domain/entities/User');
const { v4: uuidv4 } = require('uuid');

class RegisterUser {
  constructor(userRepository, emailService) {
    this.userRepository = userRepository;
    this.emailService = emailService;
  }

  async execute({ name, email }) {
    // Check if user already exists
    const existing = await this.userRepository.findByEmail(email);
    if (existing) {
      throw new Error('User with this email already exists');
    }

    // Create and validate user entity
    const user = new User({ id: uuidv4(), name, email });
    user.validate();

    // Persist
    await this.userRepository.save(user);

    // Send welcome email (without caring HOW)
    await this.emailService.sendWelcome(user);

    return user;
  }
}

module.exports = { RegisterUser };
Enter fullscreen mode Exit fullscreen mode

The use case receives its dependencies via constructor — this is Dependency Injection, and it's what makes testing so clean.


Layer 4: Infrastructure (The Real Database)

Now we implement the actual storage. This is the only place MongoDB (or Postgres, or SQLite) appears.

// src/infrastructure/database/MongoUserRepository.js
const { IUserRepository } = require('../../domain/repositories/IUserRepository');
const { User } = require('../../domain/entities/User');
const UserModel = require('./models/UserModel'); // Mongoose model

class MongoUserRepository extends IUserRepository {
  async findByEmail(email) {
    const doc = await UserModel.findOne({ email });
    if (!doc) return null;
    return new User({ id: doc._id, name: doc.name, email: doc.email, createdAt: doc.createdAt });
  }

  async save(user) {
    await UserModel.create({
      _id: user.id,
      name: user.name,
      email: user.email,
      createdAt: user.createdAt,
    });
    return user;
  }

  async findById(id) {
    const doc = await UserModel.findById(id);
    if (!doc) return null;
    return new User({ id: doc._id, name: doc.name, email: doc.email, createdAt: doc.createdAt });
  }
}

module.exports = { MongoUserRepository };
Enter fullscreen mode Exit fullscreen mode

Layer 5: The HTTP Controller

// src/interfaces/http/UserController.js
const { RegisterUser } = require('../../use-cases/RegisterUser');

class UserController {
  constructor(userRepository, emailService) {
    this.registerUser = new RegisterUser(userRepository, emailService);
  }

  async register(req, res) {
    try {
      const { name, email } = req.body;
      const user = await this.registerUser.execute({ name, email });
      return res.status(201).json({ success: true, user: user.toJSON() });
    } catch (error) {
      if (error.message.includes('already exists')) {
        return res.status(409).json({ success: false, error: error.message });
      }
      if (error.message.includes('Invalid') || error.message.includes('must be')) {
        return res.status(400).json({ success: false, error: error.message });
      }
      return res.status(500).json({ success: false, error: 'Internal server error' });
    }
  }
}

module.exports = { UserController };
Enter fullscreen mode Exit fullscreen mode

The controller knows about HTTP. That's it. It doesn't touch the database. It doesn't write business rules.


The Magic: Testing Becomes Trivial

Here's the payoff. Testing your use case without spinning up a database:

// tests/use-cases/RegisterUser.test.js
const { RegisterUser } = require('../../src/use-cases/RegisterUser');

// Mock repository — no database needed!
const mockUserRepo = {
  findByEmail: jest.fn(),
  save: jest.fn(),
};

const mockEmailService = {
  sendWelcome: jest.fn(),
};

describe('RegisterUser', () => {
  let registerUser;

  beforeEach(() => {
    jest.clearAllMocks();
    registerUser = new RegisterUser(mockUserRepo, mockEmailService);
  });

  it('should register a new user successfully', async () => {
    mockUserRepo.findByEmail.mockResolvedValue(null);
    mockUserRepo.save.mockResolvedValue(true);
    mockEmailService.sendWelcome.mockResolvedValue(true);

    const user = await registerUser.execute({ name: 'Teguh', email: 'teguh@example.com' });

    expect(user.name).toBe('Teguh');
    expect(user.email).toBe('teguh@example.com');
    expect(mockUserRepo.save).toHaveBeenCalledTimes(1);
    expect(mockEmailService.sendWelcome).toHaveBeenCalledTimes(1);
  });

  it('should throw if email already exists', async () => {
    mockUserRepo.findByEmail.mockResolvedValue({ email: 'teguh@example.com' });

    await expect(
      registerUser.execute({ name: 'Teguh', email: 'teguh@example.com' })
    ).rejects.toThrow('already exists');
  });

  it('should validate email format', async () => {
    mockUserRepo.findByEmail.mockResolvedValue(null);

    await expect(
      registerUser.execute({ name: 'Teguh', email: 'not-an-email' })
    ).rejects.toThrow('Invalid email');
  });
});
Enter fullscreen mode Exit fullscreen mode

Fast. Isolated. No database spinning up. No HTTP server needed. This is the beauty of clean architecture.


Swapping Databases? Easy.

Need to migrate from MongoDB to PostgreSQL? Create a new repository:

// src/infrastructure/database/PostgresUserRepository.js
class PostgresUserRepository extends IUserRepository {
  async findByEmail(email) {
    const { rows } = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
    if (!rows.length) return null;
    return new User(rows[0]);
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Swap it in your dependency injection setup. Your use cases don't change. Your tests don't change. Your entities don't change.


Key Takeaways

  1. Domain entities are pure business logic — no framework imports
  2. Use cases orchestrate — they depend on interfaces, not implementations
  3. Infrastructure implements the interfaces — this is where your DB, ORM, and external APIs live
  4. Controllers are thin — they translate HTTP to use-case language
  5. Dependency Injection makes everything swappable and testable

Clean Architecture isn't about being clever. It's about being kind — to your future self, to your teammates, and to whoever inherits your codebase at 2am trying to ship a hotfix.

Write code that future-you will thank you for. 🙌


Did this help? Drop a ❤️ or share with a teammate who's drowning in spaghetti code. Let's build better software together.

Top comments (0)