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
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 };
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 };
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 };
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 };
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 };
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');
});
});
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]);
}
// ...
}
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
- Domain entities are pure business logic — no framework imports
- Use cases orchestrate — they depend on interfaces, not implementations
- Infrastructure implements the interfaces — this is where your DB, ORM, and external APIs live
- Controllers are thin — they translate HTTP to use-case language
- 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)