Clean Architecture in Node.js: Escaping ‘Spaghetti Code’ as Your Project Grows

Development tutorial - IT technology blog
Development tutorial - IT technology blog

The “Spaghetti Code” Nightmare

Have you ever opened a Controller file only to find 1,000 lines of chaotic code? In the beginning, stuffing everything into a Controller helps ship features quickly. However, once the project hits the 50,000-line mark, the nightmare begins. A minor database logic change could end up breaking your email service or invoice calculations.

I once lost an entire week refactoring a system because the business logic was too tightly coupled with the Express framework. The hard-earned lesson here is: Isolate what is prone to change. Your database might switch from MongoDB to PostgreSQL, or your framework from Express to Fastify. However, your VIP discount rules should remain protected and independent.

Clean Architecture: It’s Not Just Theory

Many people shy away from Clean Architecture because it seems complex. In reality, Uncle Bob’s golden rule is simple: Dependencies must only point inwards. Core layers shouldn’t know anything about the outside world, like databases or the UI.

In real-world Node.js projects, I usually divide the system into four protective layers:

  • Domain (Entities): Where the most fundamental business rules reside. This layer remains “immutable” regardless of technology changes.
  • Use Cases: Contains specific application scenarios. For example: A registration process involving email validation, password hashing, and storage.
  • Interface Adapters: Data conversion bridges. Here, Controllers receive user requests and transform them into a format the Use Case understands.
  • Infrastructure: The layer for external tools. This is where Express, TypeORM, Redis, or the AWS SDK live.

A Folder Structure to Find Files in 3 Seconds

Instead of organizing by file type (controllers, models), organize by feature and layer. This structure allows you to locate logic instantly without constant Ctrl+F:

src/
 ├── domain/           # Contains Entities and core logic (e.g., User, Order)
 ├── use-cases/        # Specific business scenarios (e.g., RegisterUser, Checkout)
 ├── interfaces/       # Controllers, DTOs, and Repository definitions
 ├── infrastructure/   # Database implementation, mail service, logger
 └── main.ts           # Entry point to wire everything together

Hands-on: Building a User Registration Feature

Let’s see how we break down an API to achieve maximum flexibility.

1. Domain Layer: The Heart of the System

This entity is completely pure. It doesn’t depend on any ORM libraries like Mongoose or Sequelize, allowing you to easily switch when choosing Drizzle ORM over Prisma.

// src/domain/entities/User.ts
export class User {
  constructor(
    public readonly id: string,
    public readonly email: string,
    public readonly password: string
  ) {}

  // Email validation logic without external libraries
  public static isValidEmail(email: string): boolean {
    return /\S+@\S+\.\S+/.test(email);
  }
}

2. Use Case Layer: Orchestrating Business Logic

In this layer, we define the Repository Interface and handle business validation, where Zod can serve as an ultimate runtime error safeguard. The Use Case only calls the interface; it doesn’t care whether you’re saving data to MySQL or an Excel file.

// src/use-cases/register-user.use-case.ts
import { User } from "../domain/entities/User";
import { IUserRepository } from "./user-repository.interface";

export class RegisterUserUseCase {
  constructor(private userRepository: IUserRepository) {}

  async execute(data: any) {
    if (!User.isValidEmail(data.email)) throw new Error("Invalid email format");

    const exists = await this.userRepository.findByEmail(data.email);
    if (exists) throw new Error("Email already in use");

    const user = new User(Date.now().toString(), data.email, data.password);
    return await this.userRepository.save(user);
  }
}

3. Infrastructure Layer: Real-world Database Connection

What if your boss asks to switch from MongoDB to PostgreSQL tomorrow? You only need to create a new Repository file here. The Use Case logic above won’t require a single line of change.

// src/infrastructure/repositories/mongo-user.repository.ts
export class MongoUserRepository implements IUserRepository {
  async findByEmail(email: string) {
    // Call Mongoose Model here
    return null; 
  }

  async save(user: User) {
    console.log("Successfully saved to MongoDB");
    return user;
  }
}

Real-world Experience: When to Break the Rules?

Applying Clean Architecture dogmatically can lead to over-engineering. For MVP (Minimum Viable Product) projects with only 3-5 APIs, too many layers will slow down development. Sometimes, a traditional Controller-Service pattern is more efficient.

However, if you know the project is long-term, prioritize Unit Testing. The greatest strength of this architecture is how easy it is to test. You can fully mock the Repository to test Use Case logic without ever touching a database. I always encourage teams to aim for 80% code coverage in the Use Case layer and use Playwright E2E testing to ensure safe refactoring.

A pro tip: Try writing the Use Case before setting up Express. If you can run your business logic through a test script without a web server, you’ve succeeded.

Conclusion

Clean Architecture isn’t a magic wand; it’s an investment. Initially, it might feel time-consuming to create so many files and interfaces. But trust me, a year from now, when you need to swap technologies or add complex features, you’ll be grateful for having clear layers from day one. Give it a try in your next project module!

Share: