A Clean Architecture é hoje um dos padrões mais discutidos em projetos Node.js sérios. Além disso, ela define como separar regras de negócio de detalhes de infraestrutura. Portanto, dominar esse modelo em TypeScript permite construir sistemas que sobrevivem a trocas de framework, banco e UI sem reescrever a lógica central.

Neste guia prático, você vai ver código real de Entidades, Use Cases e Repositórios em TypeScript. Em seguida, vamos mostrar testes com Vitest e comparar essa abordagem com Vertical Slice. Por fim, abordamos quando esse modelo simplesmente não vale o esforço.

O que é Clean Architecture (e por que ela existe)

Esse padrão foi popularizado por Robert C. Martin (Uncle Bob) em 2012. Em essência, ele consolida ideias de Hexagonal Architecture, Onion Architecture e DCI sob uma regra única. Portanto, o objetivo não é seguir um diagrama bonito, mas garantir que sua lógica de negócio independa de framework, banco ou UI.

O princípio central é simples. Dependências de código-fonte apontam apenas para dentro, em direção ao núcleo. Dessa forma, a camada externa pode conhecer a interna, mas o contrário é proibido. Por exemplo, sua regra de cálculo de juros não importa que o banco seja PostgreSQL ou MongoDB.

Na prática, isso traz três benefícios diretos. Primeiro, testes ficam triviais porque a lógica não toca infraestrutura. Em seguida, trocar Express por Fastify vira detalhe de adapter. Finalmente, novos devs entendem a regra de negócio sem precisar abrir o ORM.

As 4 camadas da Clean Architecture

Diagrama das 4 camadas da Clean Architecture: Entities, Use Cases, Interface Adapters e Frameworks & Drivers
As 4 camadas da Clean Architecture e a regra de dependência: setas apontam sempre pra dentro, do mais externo (frameworks) pro mais interno (entities).

O modelo clássico define quatro camadas concêntricas. Cada uma tem responsabilidade específica e regras claras de dependência. Veja a seguir o papel de cada camada.

Entities: regras de negócio empresariais

As Entidades encapsulam regras que existem mesmo sem aplicação. Por exemplo, “um pedido só pode ser cancelado em até 24h após criação” é uma regra de Entidade. Da mesma forma, validações como “CPF deve ter 11 dígitos” pertencem aqui. Em resumo, são as leis do seu domínio.

Use Cases: regras de aplicação

Os Use Cases descrevem fluxos específicos. Por exemplo, “Cadastrar Usuário” orquestra: validar email único, hashear senha, persistir e enviar boas-vindas. Além disso, cada Use Case representa uma feature concreta. Logo, listar os Use Cases é listar o que o produto faz.

Interface Adapters: tradutores

Esta camada converte dados entre o mundo externo e os Use Cases. Por exemplo, controllers HTTP transformam JSON em DTOs. De forma similar, repositórios traduzem rows do Postgres em Entidades. Inclusive, presenters formatam respostas para a UI.

Frameworks & Drivers: detalhes

Por fim, a camada mais externa contém Express, Prisma, Redis, NestJS e tudo que pode mudar. Da mesma forma, scripts de migração e configuração de bundlers vivem aqui. Em resumo, é a parte que você quer poder trocar sem dor.

Clean Architecture na prática: implementação em TypeScript

Agora vamos sair da teoria. A seguir, você verá uma feature completa de “Criar Usuário” implementada com TypeScript. Primeiro, definimos a Entidade User. Em seguida, criamos o Use Case e seus contratos.

Entidade User com validação

TypeScript
// src/domain/entities/User.ts
export class User {
  private constructor(
    public readonly id: string,
    public readonly email: string,
    public readonly passwordHash: string,
    public readonly createdAt: Date,
  ) {}

  static create(props: {
    id: string;
    email: string;
    passwordHash: string;
  }): User {
    if (!props.email.includes('@')) {
      throw new Error('Email inválido');
    }
    if (props.passwordHash.length < 20) {
      throw new Error('Hash de senha inválido');
    }
    return new User(props.id, props.email, props.passwordHash, new Date());
  }
}

Contrato do repositório

Veja a seguir o contrato do repositório. Note que ele vive na camada de domínio, mas a implementação fica em infra. Dessa forma, o Use Case depende da abstração, nunca do detalhe.

TypeScript
// src/domain/repositories/IUserRepository.ts
import { User } from '../entities/User';

export interface IUserRepository {
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

Use Case orquestrando o fluxo

Agora o Use Case. Ele recebe as dependências por construtor e contém apenas orquestração. Além disso, não importa Express, Prisma ou bcrypt diretamente.

TypeScript
// src/application/use-cases/CreateUser.ts
import { randomUUID } from 'node:crypto';
import { User } from '../../domain/entities/User';
import { IUserRepository } from '../../domain/repositories/IUserRepository';

export interface IHasher {
  hash(plain: string): Promise<string>;
}

export class CreateUser {
  constructor(
    private readonly userRepo: IUserRepository,
    private readonly hasher: IHasher,
  ) {}

  async execute(input: { email: string; password: string }): Promise<User> {
    const existing = await this.userRepo.findByEmail(input.email);
    if (existing) throw new Error('Email já cadastrado');

    const passwordHash = await this.hasher.hash(input.password);
    const user = User.create({
      id: randomUUID(),
      email: input.email,
      passwordHash,
    });

    await this.userRepo.save(user);
    return user;
  }
}

Controller na camada de adapters

Por fim, o controller fica fininho. Ele só traduz HTTP em chamada do Use Case e devolve resposta. Logo, trocar Express por Fastify mexe apenas neste arquivo.

Code
// src/adapters/http/UserController.ts
import { Request, Response } from 'express';
import { CreateUser } from '../../application/use-cases/CreateUser';

export class UserController {
  constructor(private readonly createUser: CreateUser) {}

  async create(req: Request, res: Response) {
    try {
      const user = await this.createUser.execute(req.body);
      return res.status(201).json({ id: user.id, email: user.email });
    } catch (err) {
      return res.status(400).json({ error: (err as Error).message });
    }
  }
}

Testes em Clean Architecture: TDD com Vitest

Um dos maiores ganhos dessa abordagem aparece nos testes. Como Use Cases dependem de interfaces, você injeta mocks em memória. Dessa forma, os testes rodam em milissegundos, sem banco, sem rede e sem servidor HTTP.

Veja a seguir um teste real do Use Case acima, escrito em estilo TDD com Vitest. Note como o repositório vira um array em memória.

TypeScript
// tests/CreateUser.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { CreateUser, IHasher } from '../src/application/use-cases/CreateUser';
import { User } from '../src/domain/entities/User';
import { IUserRepository } from '../src/domain/repositories/IUserRepository';

class InMemoryUserRepo implements IUserRepository {
  public items: User[] = [];
  async findByEmail(email: string) {
    return this.items.find((u) => u.email === email) ?? null;
  }
  async save(user: User) {
    this.items.push(user);
  }
}

class FakeHasher implements IHasher {
  async hash(plain: string) {
    return `hashed-${plain}-padding-to-twenty-chars`;
  }
}

describe('CreateUser', () => {
  let repo: InMemoryUserRepo;
  let sut: CreateUser;

  beforeEach(() => {
    repo = new InMemoryUserRepo();
    sut = new CreateUser(repo, new FakeHasher());
  });

  it('deve cadastrar um novo usuário', async () => {
    const user = await sut.execute({ email: 'ana@dev.com', password: '123456' });
    expect(user.email).toBe('ana@dev.com');
    expect(repo.items).toHaveLength(1);
  });

  it('deve recusar email duplicado', async () => {
    await sut.execute({ email: 'ana@dev.com', password: '123456' });
    await expect(
      sut.execute({ email: 'ana@dev.com', password: 'outra' }),
    ).rejects.toThrow('Email já cadastrado');
  });
});

Execute o comando abaixo no terminal. Logo, observe que toda a suíte roda em menos de 200ms, mesmo com dezenas de casos.

Code
$ npx vitest run

 ✓ tests/CreateUser.spec.ts (2)
   ✓ CreateUser
     ✓ deve cadastrar um novo usuário
     ✓ deve recusar email duplicado

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Duration  142ms

Clean Architecture vs Vertical Slice: qual usar

Essa arquitetura organiza o código por camadas horizontais. Por outro lado, Vertical Slice Architecture agrupa código por feature. Em 2026, ambos têm espaço, mas em contextos diferentes.

No modelo vertical, cada feature contém seu próprio controller, handler e modelo. Assim, você evita atravessar pastas só para fechar um cadastro simples. Inclusive, frameworks como NestJS já estimulam organização por módulo, próxima do conceito vertical.

Veja na tabela abaixo quando cada abordagem brilha. De fato, muitos times mesclam as duas em projetos grandes.

CenárioClean ArchVertical Slice
Domínio complexo, regras de negócio densasRecomendadoLimitado
Time grande, features paralelasEstrutura previsívelReduz conflitos de merge
CRUD simples, MVP enxutoExcesso de pastasIdeal
Reuso pesado entre featuresFortePode duplicar código
Refactor frequente de UI/infraExcelente isolamentoMais acoplado

Quando NÃO usar Clean Architecture

Adotar esse padrão em todo projeto é receita para overengineering. Por exemplo, um MVP de duas telas não justifica quatro camadas e dezenas de interfaces. Da mesma forma, scripts de automação raramente precisam dessa separação.

Existem três cenários onde o custo supera o ganho. Primeiro, CRUDs simples sem regras de negócio reais. Em seguida, provas de conceito que serão jogadas fora. Por fim, times pequenos com prazo apertado que precisam validar produto antes de estrutura.

Por outro lado, considere essa abordagem quando o sistema cresce. Por exemplo, ao perceber que a regra de negócio aparece copiada em vários controllers. Inclusive, sempre que testes ficam lentos por dependerem de banco, ela costuma resolver. Em resumo, deixe a arquitetura emergir conforme a dor real surgir.

Próximos passos

Comece pequeno. Primeiro, isole um Use Case crítico do seu projeto atual em uma classe. Em seguida, crie a interface do repositório e mova a chamada do ORM para a implementação concreta. Por fim, escreva um teste com mock em memória e veja a sensação de rodar lógica em 5ms.

Para aprofundar, vale conhecer princípios SOLID em TypeScript e práticas modernas como Spec-Driven Development. De forma similar, dominar vibe coding com IA ajuda a manter qualidade sem desacelerar a entrega.

Leituras recomendadas

Veja o post original do Uncle Bob, os artigos de Khalil Stemmler e a documentação do NestJS. Além disso, vale conferir Vitest para testes rápidos e o conceito de Presentation Domain Data Layering de Martin Fowler.

Clean Architecture é o mesmo que Hexagonal Architecture?
São conceitos próximos, mas não idênticos. A Hexagonal foca em portas e adaptadores entre domínio e mundo externo. Por outro lado, esse modelo organiza camadas concêntricas com regra de dependência clara. De fato, Uncle Bob criou esse padrão consolidando Hexagonal, Onion e DCI.
Preciso usar Clean Architecture em todo projeto?
Não. Para MVPs, scripts e CRUDs simples, ela vira overengineering. Inclusive, projetos pequenos sofrem mais com excesso de pastas do que ganham em manutenibilidade. Em resumo, adote conforme a complexidade real do domínio crescer.
Como aplicar Clean Architecture em Next.js?
No Next.js, deixe Server Actions e API Routes apenas como camada de adapters. Em seguida, mova lógica para Use Cases em uma pasta separada. Dessa forma, você consegue testar regras sem depender do framework e migrar entre App Router e outros frameworks fica viável.
O Repository Pattern faz parte da Clean Architecture?
Sim. O Repository é uma das interfaces mais comuns na camada de domínio. Logo, ele abstrai persistência e permite que Use Cases trabalhem sem conhecer banco. Da mesma forma, garante que trocar Prisma por Drizzle ou Mongo afete apenas a implementação.
Clean Architecture é compatível com DDD?
Totalmente. DDD e essa abordagem se complementam: DDD define Entidades, Value Objects e Agregados, enquanto as camadas organizam essas peças no projeto. Inclusive, muitos times sérios usam ambos juntos para isolar domínio rico de infraestrutura volátil.