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

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
// 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.
// 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.
// 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.
// 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.
// 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.
$ 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 142msClean 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ário | Clean Arch | Vertical Slice |
|---|---|---|
| Domínio complexo, regras de negócio densas | Recomendado | Limitado |
| Time grande, features paralelas | Estrutura previsível | Reduz conflitos de merge |
| CRUD simples, MVP enxuto | Excesso de pastas | Ideal |
| Reuso pesado entre features | Forte | Pode duplicar código |
| Refactor frequente de UI/infra | Excelente isolamento | Mais 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.



























