
O problema que Clean Architecture resolve
Todo projeto Node.js bem-sucedido eventualmente bate no mesmo muro: a regra de negócio gruda no Express, no Prisma, no Stripe, no SDK do dia. Trocar qualquer uma dessas peças vira reescrita. Testar uma função simples exige subir banco e mock de HTTP. Onboarding de novo dev demora semanas porque a lógica está espalhada em controllers de 800 linhas.
Clean Architecture, popularizada por Robert Martin, propõe uma solução simples: dependências apontam pra dentro. Frameworks são detalhes externos. A regra de negócio não conhece HTTP, não conhece SQL, não conhece nada que possa mudar amanhã.
O problema é que muita gente lê o livro e cria 47 pastas pra um CRUD. Vamos pelo caminho do meio: aplicar os princípios sem perder a praticidade.
As 4 camadas (versão pragmática)
src/
├── domain/ # Entidades e regras puras
├── application/ # Casos de uso (use cases)
├── infrastructure/ # Adapters: DB, HTTP clients, cache
└── interfaces/ # Entry points: HTTP routes, CLI, jobs
A regra é estrita: camadas internas não importam de camadas externas. domain não importa nada. application importa só de domain. infrastructure e interfaces importam de application e domain.
Domain: o coração
Entidades são objetos com identidade e regras invariantes. Em 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 o que NÃO está aqui: ORM decorators, request/response, framework. É TypeScript puro. Você consegue testar User.create sem subir nada.
Application: casos de uso
Um caso de uso é uma operação que o sistema oferece. Login, criar pedido, cancelar assinatura. Cada um vira uma classe ou função com uma responsabilidade.
// 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) };
}
}
Repare: UserRepository e TokenService são interfaces definidas aqui, não importadas. O caso de uso declara o que precisa. A implementação concreta vive em infrastructure. Isso é a inversão de dependência (o "D" do SOLID).
Infrastructure: adapters
Aqui vivem as implementações concretas das interfaces declaradas em 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 que recria a entidade do DB
}
}
Quando você quiser trocar Prisma por Drizzle, ou Postgres por DynamoDB, você só toca aqui. Os casos de uso e o domínio ficam intactos.
Interfaces: entry points
Routes Express/Hono/Fastify são finos. Validam input, chamam o caso de uso, formatam a resposta.
// 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;
}
Note que a route não sabe nada de banco, nem de hash, nem de JWT. Só sabe HTTP.
Wiring: composition root
Em algum lugar (geralmente src/main.ts), você instancia tudo e injeta as dependências.
// 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);
Esse é o único lugar onde camadas externas se conectam. Se ficou feio, você pode usar um container de DI (tsyringe, awilix), mas honestamente, pra projetos médios, o composition root manual é mais simples e mais fácil de debugar.
Quando NÃO usar Clean Architecture
Honestamente: pra um script de 200 linhas, um CRUD simples, ou uma landing page com formulário, isso é overkill. Use quando:
- O projeto vai durar mais de 1 ano.
- Você espera trocar tecnologias (DB, framework, providers).
- A lógica de negócio é complexa (cálculos, regras condicionais, máquinas de estado).
- Vai ter mais de 3 devs mexendo simultaneamente.
Se nenhum desses se aplica, um Express com controllers diretos e Prisma resolve.
Vantagens reais
Depois de aplicar isso em produção por anos, os ganhos concretos:
- Testes unitários rápidos — testa caso de uso com mocks de interfaces, sem subir nada.
- Trocar de framework é viável — migrar de Express pra Hono leva horas, não meses.
- Onboarding — dev novo entende o domínio lendo
src/domainsem se perder em código de framework. - Bugs ficam isolados — bug de banco fica em
infrastructure, bug de regra fica emdomain.
Clean Architecture não é dogma. É uma ferramenta pra projetos que vão crescer. Use com cabeça.


