Voltar ao blog
Desenvolvimento

Clean Architecture em Node.js: Princípios e Implementação

Bruno Bracaioli
Clean Architecture em Node.js: Princípios e Implementação

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:

  1. Testes unitários rápidos — testa caso de uso com mocks de interfaces, sem subir nada.
  2. Trocar de framework é viável — migrar de Express pra Hono leva horas, não meses.
  3. Onboarding — dev novo entende o domínio lendo src/domain sem se perder em código de framework.
  4. Bugs ficam isolados — bug de banco fica em infrastructure, bug de regra fica em domain.

Clean Architecture não é dogma. É uma ferramenta pra projetos que vão crescer. Use com cabeça.

Compartilhar:

Fique por dentro

Receba novos artigos sobre IA, desenvolvimento e tecnologia direto no seu email.