The Core Problem Clean Architecture Solves
In typical Node.js applications, business logic is entangled with infrastructure:
// ❌ TIGHTLY COUPLED — business logic knows about PostgreSQL and Express
app.post("/api/users", async (req, res) => {
const { email, password } = req.body;
// Business rule mixed with SQL
const exists = await pool.query(
"SELECT id FROM users WHERE email = $1", [email]
);
if (exists.rows.length) return res.status(409).json({ error: "Email taken" });
// Business rule mixed with bcrypt
const hash = await bcrypt.hash(password, 12);
const user = await pool.query(
"INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *",
[email, hash]
);
res.json(user.rows[0]);
});To test "a user with a duplicate email gets a 409 error," you need a running PostgreSQL instance and an HTTP server. Clean Architecture eliminates this dependency.
The Layer Structure
src/
├── domain/ ← Enterprise business rules (no dependencies)
│ ├── entities/ ← Core business objects
│ ├── repositories/ ← Interface definitions (contracts)
│ └── errors/ ← Domain-specific errors
├── application/ ← Application business rules
│ └── use-cases/ ← Orchestrate domain + repositories
├── infrastructure/ ← Implementation details
│ ├── database/ ← PostgreSQL implementations
│ ├── cache/ ← Redis implementations
│ └── email/ ← Email service implementations
└── presentation/ ← HTTP layer (Express, Next.js)
├── controllers/
└── routes/The Dependency Rule: Arrows only point inward. Infrastructure depends on Application. Application depends on Domain. Domain depends on nothing.
Domain Layer — Pure Business Logic
// domain/entities/User.ts — No imports, no dependencies
export class User {
private constructor(
public readonly id: string,
public readonly email: string,
private passwordHash: string,
public readonly createdAt: Date,
) {}
static create(id: string, email: string, passwordHash: string): User {
if (!email.includes("@")) {
throw new InvalidEmailError(email);
}
return new User(id, email, passwordHash, new Date());
}
// Business rule: users can be deactivated, not deleted
isActive(): boolean {
return this.status === "active";
}
}
// domain/repositories/UserRepository.ts — Interface, not implementation
export interface UserRepository {
findByEmail(email: string): Promise<User | null>;
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
delete(id: string): Promise<void>;
}Application Layer — Use Cases
// application/use-cases/RegisterUser.ts
import { UserRepository } from "@/domain/repositories/UserRepository";
import { PasswordHasher } from "@/domain/services/PasswordHasher";
import { User } from "@/domain/entities/User";
import { EmailAlreadyTakenError } from "@/domain/errors";
import { randomUUID } from "crypto";
export interface RegisterUserCommand {
email: string;
password: string;
}
export class RegisterUserUseCase {
constructor(
private readonly userRepository: UserRepository, // Interface, not implementation!
private readonly passwordHasher: PasswordHasher, // Interface, not bcrypt directly!
) {}
async execute(command: RegisterUserCommand): Promise<User> {
// Pure business logic — no SQL, no Express, no bcrypt import
const existingUser = await this.userRepository.findByEmail(command.email);
if (existingUser) {
throw new EmailAlreadyTakenError(command.email);
}
const passwordHash = await this.passwordHasher.hash(command.password);
const user = User.create(randomUUID(), command.email, passwordHash);
await this.userRepository.save(user);
return user;
}
}Testing the Use Case — No Database Needed
// application/use-cases/RegisterUser.test.ts
import { RegisterUserUseCase } from "./RegisterUser";
import { InMemoryUserRepository } from "@/infrastructure/database/InMemoryUserRepository";
import { BcryptPasswordHasher } from "@/infrastructure/auth/BcryptPasswordHasher";
describe("RegisterUserUseCase", () => {
let useCase: RegisterUserUseCase;
let userRepository: InMemoryUserRepository;
beforeEach(() => {
userRepository = new InMemoryUserRepository(); // In-memory, no DB!
const passwordHasher = new BcryptPasswordHasher();
useCase = new RegisterUserUseCase(userRepository, passwordHasher);
});
it("should register a new user", async () => {
const user = await useCase.execute({
email: "bipin@example.com",
password: "SecurePass123",
});
expect(user.email).toBe("bipin@example.com");
expect(await userRepository.findByEmail("bipin@example.com")).toBeDefined();
});
it("should throw EmailAlreadyTakenError for duplicate email", async () => {
await useCase.execute({ email: "bipin@example.com", password: "pass1" });
await expect(
useCase.execute({ email: "bipin@example.com", password: "pass2" })
).rejects.toThrow(EmailAlreadyTakenError);
});
});Tests run in milliseconds, no database required, no mocking framework needed.
Infrastructure Layer — PostgreSQL Implementation
// infrastructure/database/PostgresUserRepository.ts
import { UserRepository } from "@/domain/repositories/UserRepository";
import { User } from "@/domain/entities/User";
import { Pool } from "pg";
export class PostgresUserRepository implements UserRepository {
constructor(private readonly pool: Pool) {}
async findByEmail(email: string): Promise<User | null> {
const result = await this.pool.query(
"SELECT id, email, password_hash, created_at FROM users WHERE email = $1",
[email]
);
if (!result.rows.length) return null;
const row = result.rows[0];
return User.reconstitute(row.id, row.email, row.password_hash, row.created_at);
}
async save(user: User): Promise<void> {
await this.pool.query(
`INSERT INTO users (id, email, password_hash, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET email = $2, password_hash = $3`,
[user.id, user.email, user.passwordHash, user.createdAt]
);
}
}Presentation Layer — HTTP Controller
// presentation/controllers/UserController.ts
import { RegisterUserUseCase } from "@/application/use-cases/RegisterUser";
import { EmailAlreadyTakenError } from "@/domain/errors";
import { Request, Response } from "express";
export class UserController {
constructor(private readonly registerUser: RegisterUserUseCase) {}
async register(req: Request, res: Response): Promise<void> {
try {
const user = await this.registerUser.execute(req.body);
res.status(201).json({ id: user.id, email: user.email });
} catch (error) {
if (error instanceof EmailAlreadyTakenError) {
res.status(409).json({ error: "Email already registered" });
} else {
res.status(500).json({ error: "Internal server error" });
}
}
}
}Conclusion
Clean Architecture is an investment that pays back in testability, maintainability, and the ability to swap infrastructure components. Need to switch from PostgreSQL to MongoDB? Implement a new MongoUserRepository and inject it — your use cases don't change. Need to add a second HTTP framework? The controllers change, but all business logic is untouched. The initial boilerplate is real, but for long-lived systems, it's the most maintainable architecture pattern available.



