<h2>O que é Clean Architecture e Por Que Importa</h2>
<p>Clean Architecture é um conjunto de princípios de design que visa criar sistemas de software independentes de frameworks, testáveis e fáceis de manter. Proposta originalmente por Robert C. Martin (Uncle Bob), ela coloca a lógica de negócio no centro da aplicação, isolando-a de detalhes técnicos como bancos de dados, frameworks web e bibliotecas externas.</p>
<p>A razão pela qual Clean Architecture importa é simples: ao longo do tempo, a maioria dos projetos acumula dívida técnica. Quando a lógica de negócio fica misturada com frameworks e detalhes de infraestrutura, torna-se extremamente difícil fazer mudanças, testar o código e compreender o que realmente o sistema faz. Com Clean Architecture, você inverte essa dinâmica: primeiro vem o negócio, depois as ferramentas. Isso significa que você pode trocar um banco de dados PostgreSQL por MongoDB, ou um framework Express por Fastify, sem precisar reescrever toda a lógica central da aplicação.</p>
<p>TypeScript é a escolha perfeita para implementar Clean Architecture porque oferece tipagem estática, que força você a ser explícito sobre as interfaces entre camadas. Isso funciona como uma grade de proteção contra violações arquiteturais.</p>
<h2>Estrutura de Camadas: A Espinha Dorsal</h2>
<p>Clean Architecture organiza o código em camadas concêntricas, cada uma com responsabilidades bem definidas. Vamos começar pelos conceitos e depois mostrar como implementar.</p>
<h3>As Quatro Camadas Principais</h3>
<p><strong>Entities (Entidades)</strong> são as objetos core do seu negócio. Elas encapsulam regras críticas de negócio que seriam úteis em muitos contextos diferentes. Uma entidade não conhece nada sobre bancos de dados ou frameworks.</p>
<p><strong>Use Cases (Casos de Uso)</strong> orquestram as entidades para produzir ações específicas do aplicativo. Um caso de uso representa uma ação que o usuário quer fazer: criar um pedido, autenticar um usuário, gerar um relatório. Casos de uso não dependem de detalhes técnicos.</p>
<p><strong>Interface Adapters (Adaptadores de Interface)</strong> convertem dados entre o formato mais conveniente para os casos de uso e entidades, e o formato mais conveniente para agentes externos como bancos de dados e web frameworks. Controllers, presenters e repositories vivem aqui.</p>
<p><strong>Frameworks & Drivers (Frameworks e Drivers)</strong> é a camada mais externa, onde frameworks web, bancos de dados e bibliotecas externas residem. É aqui que você coloca Express, Prisma, ou qualquer outra ferramenta.</p>
<h3>Implementação Prática: Estrutura de Pastas</h3>
<pre><code>src/
├── domain/ # Entities e lógica pura de negócio
│ ├── entities/
│ │ └── User.ts
│ └── errors/
│ └── UserError.ts
├── application/ # Use Cases e Business Rules
│ ├── use-cases/
│ │ ├── CreateUserUseCase.ts
│ │ └── GetUserByIdUseCase.ts
│ ├── dtos/ # Data Transfer Objects
│ │ └── UserDTO.ts
│ └── interfaces/ # Interfaces para dependências
│ └── UserRepository.ts
├── infrastructure/ # Implementações técnicas
│ ├── persistence/
│ │ ├── UserRepositoryImpl.ts
│ │ └── database.ts
│ ├── http/
│ │ └── UserController.ts
│ └── config/
│ └── env.ts
└── main.ts # Ponto de entrada e setup</code></pre>
<p>Esta estrutura garante que as dependências apontam sempre para dentro (em direção ao domain). O domain nunca importa da infrastructure; a infrastructure importa do domain.</p>
<h2>Construindo um Exemplo Real: Sistema de Usuários</h2>
<p>Vamos implementar um sistema simples de criação e consulta de usuários para entender como as camadas funcionam na prática.</p>
<h3>Domain Layer: Entities</h3>
<pre><code class="language-typescript">// src/domain/entities/User.ts
export class User {
constructor(
readonly id: string,
readonly email: string,
readonly name: string,
readonly passwordHash: string,
readonly createdAt: Date
) {
this.validate();
}
private validate(): void {
if (!this.email.includes('@')) {
throw new Error('Email inválido');
}
if (this.name.trim().length < 3) {
throw new Error('Nome deve ter no mínimo 3 caracteres');
}
}
static create(
id: string,
email: string,
name: string,
passwordHash: string
): User {
return new User(id, email, name, passwordHash, new Date());
}
}</code></pre>
<pre><code class="language-typescript">// src/domain/errors/UserError.ts
export class UserNotFoundError extends Error {
constructor(id: string) {
super(Usuário com ID ${id} não encontrado);
this.name = 'UserNotFoundError';
}
}
export class UserAlreadyExistsError extends Error {
constructor(email: string) {
super(Usuário com email ${email} já existe);
this.name = 'UserAlreadyExistsError';
}
}</code></pre>
<p>A camada domain não sabe sobre HTTP, bancos de dados ou qualquer framework. Ela só conhece regras de negócio puras. Se uma regra de negócio muda, você muda aqui. Se um framework muda, você não toca aqui.</p>
<h3>Application Layer: Use Cases e Interfaces</h3>
<pre><code class="language-typescript">// src/application/interfaces/UserRepository.ts
import { User } from '../../domain/entities/User';
export interface IUserRepository {
save(user: User): Promise<void>;
findById(id: string): Promise<User | null>; findByEmail(email: string): Promise<User | null>;
}</code></pre>
<pre><code class="language-typescript">// src/application/dtos/UserDTO.ts
export interface CreateUserRequest {
email: string;
name: string;
password: string;
}
export interface UserResponse {
id: string;
email: string;
name: string;
createdAt: Date;
}</code></pre>
<pre><code class="language-typescript">// src/application/use-cases/CreateUserUseCase.ts
import { User } from '../../domain/entities/User';
import { UserAlreadyExistsError } from '../../domain/errors/UserError';
import { IUserRepository } from '../interfaces/UserRepository';
import { CreateUserRequest, UserResponse } from '../dtos/UserDTO';
import { randomUUID } from 'crypto';
import bcrypt from 'bcrypt';
export class CreateUserUseCase {
constructor(private userRepository: IUserRepository) {}
async execute(request: CreateUserRequest): Promise<UserResponse> {
const existingUser = await this.userRepository.findByEmail(
request.email
);
if (existingUser) {
throw new UserAlreadyExistsError(request.email);
}
const passwordHash = await bcrypt.hash(request.password, 10);
const user = User.create(
randomUUID(),
request.email,
request.name,
passwordHash
);
await this.userRepository.save(user);
return {
id: user.id,
email: user.email,
name: user.name,
createdAt: user.createdAt
};
}
}</code></pre>
<pre><code class="language-typescript">// src/application/use-cases/GetUserByIdUseCase.ts
import { IUserRepository } from '../interfaces/UserRepository';
import { UserResponse } from '../dtos/UserDTO';
import { UserNotFoundError } from '../../domain/errors/UserError';
export class GetUserByIdUseCase {
constructor(private userRepository: IUserRepository) {}
async execute(id: string): Promise<UserResponse> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new UserNotFoundError(id);
}
return {
id: user.id,
email: user.email,
name: user.name,
createdAt: user.createdAt
};
}
}</code></pre>
<p>Note que os casos de uso usam interfaces (IUserRepository), não implementações concretas. Isso permite que você trocar a implementação sem tocar no caso de uso. Se hoje usa PostgreSQL e amanhã quer usar MongoDB, o caso de uso não muda.</p>
<h3>Infrastructure Layer: Implementações Concretas</h3>
<pre><code class="language-typescript">// src/infrastructure/persistence/database.ts
import sqlite3 from 'sqlite3';
import { promisify } from 'util';
export class Database {
private db: sqlite3.Database;
constructor(filename: string) {
this.db = new sqlite3.Database(filename);
}
async initialize(): Promise<void> {
const run = promisify(this.db.run.bind(this.db));
await run(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
passwordHash TEXT NOT NULL,
createdAt TEXT NOT NULL
)
`);
}
getDb(): sqlite3.Database {
return this.db;
}
async close(): Promise<void> {
return new Promise((resolve, reject) => {
this.db.close((err) => {
if (err) reject(err);
else resolve();
});
});
}
}</code></pre>
<pre><code class="language-typescript">// src/infrastructure/persistence/UserRepositoryImpl.ts
import { User } from '../../domain/entities/User';
import { IUserRepository } from '../../application/interfaces/UserRepository';
import sqlite3 from 'sqlite3';
export class UserRepositoryImpl implements IUserRepository {
constructor(private db: sqlite3.Database) {}
async save(user: User): Promise<void> {
return new Promise((resolve, reject) => {
this.db.run(
'INSERT INTO users (id, email, name, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?)',
[
user.id,
user.email,
user.name,
user.passwordHash,
user.createdAt.toISOString()
],
(err) => {
if (err) reject(err);
else resolve();
}
);
});
}
async findById(id: string): Promise<User | null> {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT * FROM users WHERE id = ?',
[id],
(err, row: any) => {
if (err) {
reject(err);
} else if (!row) {
resolve(null);
} else {
resolve(
new User(
row.id,
row.email,
row.name,
row.passwordHash,
new Date(row.createdAt)
)
);
}
}
);
});
}
async findByEmail(email: string): Promise<User | null> {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT * FROM users WHERE email = ?',
[email],
(err, row: any) => {
if (err) {
reject(err);
} else if (!row) {
resolve(null);
} else {
resolve(
new User(
row.id,
row.email,
row.name,
row.passwordHash,
new Date(row.createdAt)
)
);
}
}
);
});
}
}</code></pre>
<pre><code class="language-typescript">// src/infrastructure/http/UserController.ts
import { Request, Response } from 'express';
import { CreateUserUseCase } from '../../application/use-cases/CreateUserUseCase';
import { GetUserByIdUseCase } from '../../application/use-cases/GetUserByIdUseCase';
export class UserController {
constructor(
private createUserUseCase: CreateUserUseCase,
private getUserByIdUseCase: GetUserByIdUseCase
) {}
async create(req: Request, res: Response): Promise<void> {
try {
const { email, name, password } = req.body;
const result = await this.createUserUseCase.execute({
email,
name,
password
});
res.status(201).json(result);
} catch (error: any) {
res.status(400).json({ error: error.message });
}
}
async getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const result = await this.getUserByIdUseCase.execute(id);
res.status(200).json(result);
} catch (error: any) {
res.status(404).json({ error: error.message });
}
}
}</code></pre>
<h3>Composição e Configuração</h3>
<pre><code class="language-typescript">// src/main.ts
import express from 'express';
import { Database } from './infrastructure/persistence/database';
import { UserRepositoryImpl } from './infrastructure/persistence/UserRepositoryImpl';
import { CreateUserUseCase } from './application/use-cases/CreateUserUseCase';
import { GetUserByIdUseCase } from './application/use-cases/GetUserByIdUseCase';
import { UserController } from './infrastructure/http/UserController';
async function bootstrap() {
const app = express();
app.use(express.json());
// Setup de banco de dados
const database = new Database(':memory:');
await database.initialize();
// Setup de repositório (implementação)
const userRepository = new UserRepositoryImpl(database.getDb());
// Setup de casos de uso
const createUserUseCase = new CreateUserUseCase(userRepository);
const getUserByIdUseCase = new GetUserByIdUseCase(userRepository);
// Setup de controller
const userController = new UserController(
createUserUseCase,
getUserByIdUseCase
);
// Rotas
app.post('/users', (req, res) => userController.create(req, res));
app.get('/users/:id', (req, res) => userController.getById(req, res));
const PORT = 3000;
app.listen(PORT, () => {
console.log(Server rodando na porta ${PORT});
});
}
bootstrap().catch(console.error);</code></pre>
<h2>Testabilidade: O Grande Benefício</h2>
<p>A estrutura em camadas não é apenas para organização bonita—ela torna o código extremamente testável. Como a lógica de negócio não depende de frameworks ou bancos de dados, você pode testá-la diretamente com dados fake.</p>
<h3>Testando um Caso de Uso</h3>
<pre><code class="language-typescript">// tests/application/use-cases/CreateUserUseCase.spec.ts
import { CreateUserUseCase } from '../../../src/application/use-cases/CreateUserUseCase';
import { IUserRepository } from '../../../src/application/interfaces/UserRepository';
import { User } from '../../../src/domain/entities/User';
import { UserAlreadyExistsError } from '../../../src/domain/errors/UserError';
class MockUserRepository implements IUserRepository {
private users: Map<string, User> = new Map();
async save(user: User): Promise<void> {
this.users.set(user.id, user);
}
async findById(id: string): Promise<User | null> { return this.users.get(id) || null;
}
async findByEmail(email: string): Promise<User | null> {
for (const user of this.users.values()) {
if (user.email === email) return user;
}
return null;
}
}
describe('CreateUserUseCase', () => {
let useCase: CreateUserUseCase;
let mockRepository: MockUserRepository;
beforeEach(() => {
mockRepository = new MockUserRepository();
useCase = new CreateUserUseCase(mockRepository);
});
it('deve criar um novo usuário com sucesso', async () => {
const result = await useCase.execute({
email: 'test@example.com',
name: 'John Doe',
password: 'secure123'
});
expect(result.email).toBe('test@example.com');
expect(result.name).toBe('John Doe');
expect(result.id).toBeDefined();
});
it('deve lançar erro se email já existe', async () => {
await useCase.execute({
email: 'test@example.com',
name: 'John Doe',
password: 'secure123'
});
await expect(
useCase.execute({
email: 'test@example.com',
name: 'Jane Doe',
password: 'secure456'
})
).rejects.toThrow(UserAlreadyExistsError);
});
it('deve validar email inválido', async () => {
await expect(
useCase.execute({
email: 'invalid-email',
name: 'John Doe',
password: 'secure123'
})
).rejects.toThrow('Email inválido');
});
});</code></pre>
<p>Veja como o teste é limpo: não precisa de banco de dados real, não precisa de servidor HTTP, não precisa de qualquer framework externo. Você está testando apenas a lógica de negócio. Esta é a razão pela qual Clean Architecture é tão poderosa.</p>
<h2>Princípios Fundamentais e Boas Práticas</h2>
<h3>Inversão de Dependência</h3>
<p>O princípio mais importante em Clean Architecture é a <strong>Inversão de Dependência</strong> (Dependency Inversion Principle). Módulos de alto nível (casos de uso) não devem depender de módulos de baixo nível (repositórios de banco de dados). Ambos devem depender de abstrações (interfaces).</p>
<p>No nosso exemplo, <code>CreateUserUseCase</code> depende de <code>IUserRepository</code> (interface), não de <code>UserRepositoryImpl</code> (implementação). Se você precisa trocar de SQLite para PostgreSQL, cria uma nova implementação de <code>IUserRepository</code> e conecta no bootstrap. Os casos de uso não sabem disso.</p>
<h3>Isolamento de Dependências Externas</h3>
<p>Frameworks e bibliotecas externas devem estar nas camadas mais externas. Seu domain nunca importa Express, Prisma ou qualquer outra coisa. Se você quiser mudar de Express para Fastify, você não toca em uma única linha de lógica de negócio.</p>
<h3>DTOs vs Entities</h3>
<p>Use <strong>Data Transfer Objects</strong> (DTOs) para trafegar dados entre camadas. Uma DTO é apenas um objeto com propriedades, sem lógica. Uma Entity é um objeto de domínio com lógica de negócio. Não misture os dois. Um caso de uso recebe um DTO, cria/manipula Entities, e retorna um DTO.</p>
<pre><code class="language-typescript">// Errado
async execute(user: User): Promise<User> {
// Retornar Entity diretamente
return user;
}
// Correto
async execute(request: CreateUserRequest): Promise<UserResponse> {
// Receber DTO, retornar DTO
const user = User.create(...);
return {
id: user.id,
email: user.email,
name: user.name
};
}</code></pre>
<h2>Conclusão</h2>
<p>Aprendemos que <strong>Clean Architecture é um conjunto de princípios que coloca a lógica de negócio no centro, isolando-a de detalhes técnicos através de camadas com responsabilidades bem definidas</strong>. A estrutura em Domain → Application → Infrastructure garante que mudanças em frameworks ou banco de dados não afetam o coração da sua aplicação.</p>
<p>Em segundo lugar, <strong>TypeScript amplifica os benefícios de Clean Architecture pela tipagem estática</strong>. As interfaces forçam contratos explícitos entre camadas, e o compilador avisa você se violar a arquitetura. Isso é uma grade de proteção contra más decisões de design.</p>
<p>Por fim, <strong>a testabilidade é uma consequência natural dessa estrutura</strong>. Como a lógica de negócio não depende de nada externo, ela é simples de testar, e você consegue criar testes rápidos, confiáveis e focados. Você não está testando o framework; está testando o que realmente importa: se o sistema faz o que deveria fazer.</p>
<h2>Referências</h2>
<ul>
<li><a href="https://www.oreilly.com/library/view/clean-architecture-a/9780134494272/" target="_blank" rel="noopener noreferrer">Clean Architecture: A Craftsman's Guide to Software Structure and Design</a> - Robert C. Martin</li>
<li><a href="https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html" target="_blank" rel="noopener noreferrer">The Clean Architecture - Blog Post Original</a> - Uncle Bob</li>
<li><a href="https://www.typescriptlang.org/docs/handbook/2/objects.html" target="_blank" rel="noopener noreferrer">TypeScript Handbook - Interfaces</a> - Documentação Oficial</li>
<li><a href="https://github.com/goldbergyoni/nodebestpractices#6-testing-best-practices" target="_blank" rel="noopener noreferrer">Node.js Testing Best Practices</a> - Yoni Goldberg</li>
<li><a href="https://www.digitalocean.com/community/tutorials/solid-principles-using-typescript" target="_blank" rel="noopener noreferrer">SOLID Principles with TypeScript</a> - Digital Ocean</li>
</ul>
<p><!-- FIM --></p>