
The problem Clean Architecture solves
Every successful Node.js project eventually hits the same wall: business logic gets glued to Express, Prisma, Stripe, the SDK of the day. Swapping any of those becomes a rewrite. Testing a simple function requires spinning up a database and mocking HTTP. Onboarding a new dev takes weeks because logic is scattered across 800-line controllers.
Clean Architecture, popularized by Robert Martin, proposes a simple solution: dependencies point inward. Frameworks are external details. Business logic doesn't know HTTP, doesn't know SQL, doesn't know anything that might change tomorrow.
The problem is many people read the book and create 47 folders for a CRUD. Let's go down the middle path: apply the principles without losing pragmatism.
The 4 layers (pragmatic version)
src/
├── domain/ # Pure entities and rules
├── application/ # Use cases
├── infrastructure/ # Adapters: DB, HTTP clients, cache
└── interfaces/ # Entry points: HTTP routes, CLI, jobs
The rule is strict: inner layers don't import from outer layers. domain imports nothing. application imports only from domain. infrastructure and interfaces import from application and domain.
Domain: the heart
Entities are objects with identity and invariant rules. In TypeScript:
// src/domain/user.ts
export class User {
private constructor(
public readonly id: string,
public readonly email: string,
public readonly name: string,
private hashedPassword: string,
) {}
static create(props: { email: string; name: string; password: string }): User {
if (!props.email.includes("@")) {
throw new Error("Invalid email");
}
if (props.password.length < 12) {
throw new Error("Password too short");
}
const id = crypto.randomUUID();
const hashed = hashPassword(props.password);
return new User(id, props.email, props.name, hashed);
}
verifyPassword(plain: string): boolean {
return verifyHash(plain, this.hashedPassword);
}
}
Note what's NOT here: ORM decorators, request/response, framework. It's pure TypeScript. You can test User.create without spinning anything up.
Application: use cases
A use case is an operation the system offers. Login, create order, cancel subscription. Each becomes a class or function with one responsibility.
// src/application/use-cases/login-user.ts
export interface UserRepository {
findByEmail(email: string): Promise<User | null>;
}
export interface TokenService {
sign(userId: string): string;
}
export class LoginUserUseCase {
constructor(
private users: UserRepository,
private tokens: TokenService,
) {}
async execute(input: { email: string; password: string }): Promise<{ token: string }> {
const user = await this.users.findByEmail(input.email);
if (!user || !user.verifyPassword(input.password)) {
throw new Error("Invalid credentials");
}
return { token: this.tokens.sign(user.id) };
}
}
Notice: UserRepository and TokenService are interfaces defined here, not imported. The use case declares what it needs. The concrete implementation lives in infrastructure. This is dependency inversion (the "D" in SOLID).
Infrastructure: adapters
Here live the concrete implementations of interfaces declared in application.
// src/infrastructure/repositories/prisma-user-repository.ts
export class PrismaUserRepository implements UserRepository {
constructor(private prisma: PrismaClient) {}
async findByEmail(email: string): Promise<User | null> {
const row = await this.prisma.user.findUnique({ where: { email } });
if (!row) return null;
return User.reconstitute(row); // factory that rebuilds the entity from DB
}
}
When you want to swap Prisma for Drizzle, or Postgres for DynamoDB, you only touch here. Use cases and the domain stay untouched.
Interfaces: entry points
Express/Hono/Fastify routes are thin. They validate input, call the use case, format the response.
// src/interfaces/http/routes/auth.ts
import { Hono } from "hono";
import { z } from "zod";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export function authRoutes(useCase: LoginUserUseCase) {
const app = new Hono();
app.post("/login", async (c) => {
const body = loginSchema.parse(await c.req.json());
try {
const result = await useCase.execute(body);
return c.json(result);
} catch {
return c.json({ error: "Invalid credentials" }, 401);
}
});
return app;
}
The route knows nothing about the database, hashing, or JWT. It only knows HTTP.
Wiring: composition root
Somewhere (usually src/main.ts), you instantiate everything and inject dependencies.
// src/main.ts
const prisma = new PrismaClient();
const userRepo = new PrismaUserRepository(prisma);
const tokens = new JwtTokenService(process.env.JWT_SECRET!);
const loginUseCase = new LoginUserUseCase(userRepo, tokens);
const app = new Hono();
app.route("/auth", authRoutes(loginUseCase));
serve(app);
This is the only place outer layers connect. If it gets ugly, you can use a DI container (tsyringe, awilix), but honestly, for medium projects, the manual composition root is simpler and easier to debug.
When NOT to use Clean Architecture
Honestly: for a 200-line script, a simple CRUD, or a landing page with a form, this is overkill. Use it when:
- The project will live more than 1 year.
- You expect to swap technologies (DB, framework, providers).
- The business logic is complex (calculations, conditional rules, state machines).
- There will be more than 3 devs working simultaneously.
If none of those apply, an Express with direct controllers and Prisma works fine.
Real benefits
After applying this in production for years, the concrete gains:
- Fast unit tests — test use cases with interface mocks, no infra needed.
- Swapping frameworks is feasible — migrating from Express to Hono takes hours, not months.
- Onboarding — new devs understand the domain by reading
src/domainwithout getting lost in framework code. - Bugs stay isolated — DB bug stays in
infrastructure, business rule bug stays indomain.
Clean Architecture isn't dogma. It's a tool for projects that will grow. Use it with judgment.


