Clean Architecture e Arquitetura Hexagonal Explicadas
Por que o Domínio deve estar no centro de tudo. Entidades ricas, Casos de Uso, Ports & Adapters, Regra de Dependência e como isso tudo se traduz em código TypeScript real.
Imagine que você construiu toda a sua aplicação usando as abstrações do TypeORM diretamente nos seus services — getRepository(User), decorators @Entity(), @Column(), @BeforeInsert(). Um dia, a empresa decide migrar para o Prisma por razões de performance. Resultado: você precisa reescrever não só a camada de persistência, mas os services e as entidades, porque eles estavam acoplados ao TypeORM.
Esse é o problema que a Clean Architecture (Robert C. Martin, 2017) e a Arquitetura Hexagonal (Alistair Cockburn, 2005) resolvem — com abordagens diferentes mas com o mesmo princípio central: as regras de negócio não devem depender de nada. Frameworks, banco de dados, HTTP, filas de mensagem — tudo isso são detalhes que ficam nas bordas.
As Camadas da Clean Architecture
A Clean Architecture é frequentemente representada como anéis concêntricos. A Regra de Dependência é absoluta: código em uma camada só pode depender de camadas mais internas, nunca das externas.
- Entities (Entidades) — o núcleo. Classes ou objetos que encapsulam as regras de negócio de mais alto nível. Sem nenhuma dependência externa.
- Use Cases (Casos de Uso) — orquestram as Entidades para executar uma ação de negócio específica (criar pedido, processar pagamento). Conhecem as Entidades, mas não sabem nada sobre HTTP ou banco de dados.
- Interface Adapters — Controllers, Presenters, Gateways. Traduzem dados do mundo externo para o formato que os Use Cases esperam, e vice-versa.
- Frameworks & Drivers — Express, TypeORM, Redis, React. A camada mais externa. Pode ser trocada sem tocar nas camadas internas.
Entidades Ricas: Além dos Anemic Models
O erro mais comum ao aplicar Clean Architecture é criar Anemic Domain Models — entidades que são apenas bags de dados sem comportamento. Uma entidade rica encapsula as invariantes de negócio nos seus próprios métodos:
// Sem nenhum import de framework, ORM ou biblioteca externa.
// Apenas TypeScript descrevendo o que é um Pedido no domínio.
type OrderStatus = 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELED';
interface OrderItem {
productId: string;
quantity: number;
unitPrice: number;
}
export class Order {
private _status: OrderStatus = 'PENDING';
private _items: Array<OrderItem> = [];
private _paidAt: Date | null = null;
constructor(
public readonly id: string,
public readonly customerId: string,
public readonly createdAt: Date = new Date()
) {}
// Regra de negócio encapsulada: só pode adicionar item se o pedido está PENDING
addItem(item: OrderItem): void {
if (this._status !== 'PENDING') {
throw new Error('Itens só podem ser adicionados a pedidos pendentes.');
}
if (item.quantity <= 0) {
throw new Error('A quantidade deve ser positiva.');
}
this._items.push(item);
}
// Regra: só pode pagar se está PENDING e tem pelo menos um item
pay(): void {
if (this._status !== 'PENDING') {
throw new Error(`Não é possível pagar um pedido com status '${this._status}'.`);
}
if (this._items.length === 0) {
throw new Error('Não é possível pagar um pedido sem itens.');
}
this._status = 'PAID';
this._paidAt = new Date();
}
cancel(): void {
if (this._status === 'SHIPPED') {
throw new Error('Pedidos enviados não podem ser cancelados.');
}
this._status = 'CANCELED';
}
// Getters — expõem o estado sem permitir mutação direta
get status(): OrderStatus { return this._status; }
get items(): ReadonlyArray<OrderItem> { return this._items; }
get paidAt(): Date | null { return this._paidAt; }
get total(): number {
return this._items.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0
);
}
}Entidades ricas violam o princípio do Active Record (onde a entidade sabe salvar a si mesma). Em Clean Architecture, a entidade nunca sabe salvar. Ela apenas encapsula regras. Salvar é responsabilidade do repositório.
Ports and Adapters: O Coração da Arquitetura Hexagonal
A Arquitetura Hexagonal formaliza a separação entre o núcleo e o mundo externo com dois conceitos: Ports (interfaces que o núcleo define) e Adapters (implementações dessas interfaces que ficam nas bordas).
Existem dois tipos de ports:
- Driving Ports (Primary) — interfaces que o mundo externo usa para entrar no núcleo. O Controller chama o Use Case por uma driving port.
- Driven Ports (Secondary) — interfaces que o núcleo define para chamar o mundo externo (banco de dados, e-mail, filas). O Use Case chama o repositório por uma driven port.
// DRIVEN PORT: O núcleo declara o contrato que qualquer persistência deve cumprir.
// O Use Case depende dessa interface — não da implementação concreta.
import type { Order } from '../entities/Order';
export interface IOrdersRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByCustomerId(customerId: string): Promise<Array<Order>>;
}// USE CASE: orquestra Entidades e Ports, sem saber NADA sobre Express ou TypeORM.
import { Order } from '../entities/Order';
import type { IOrdersRepository } from '../repositories/IOrdersRepository';
import type { ICustomersRepository } from '../repositories/ICustomersRepository';
interface CreateOrderDTO {
customerId: string;
items: Array<{ productId: string; quantity: number; unitPrice: number }>;
}
export class CreateOrderUseCase {
// Injeção de dependência pelo construtor — depende das Interfaces, não das classes concretas
constructor(
private readonly ordersRepository: IOrdersRepository,
private readonly customersRepository: ICustomersRepository
) {}
async execute(dto: CreateOrderDTO): Promise<Order> {
// Valida que o cliente existe
const customer = await this.customersRepository.findById(dto.customerId);
if (!customer) {
throw new Error('Cliente não encontrado.');
}
// Cria e popula a entidade com as regras de negócio
const order = new Order(crypto.randomUUID(), dto.customerId);
for (const item of dto.items) {
order.addItem(item); // Entidade valida as invariantes
}
// Persiste — não sabe se é TypeORM, Prisma ou um array em memória
await this.ordersRepository.save(order);
return order;
}
}// DRIVEN ADAPTER: implementa a interface do núcleo usando TypeORM.
// O núcleo não conhece este arquivo. A dependência é invertida.
import { Repository, DataSource } from 'typeorm';
import type { IOrdersRepository } from '../../domain/repositories/IOrdersRepository';
import { Order } from '../../domain/entities/Order';
import { OrderModel } from './models/OrderModel'; // Modelo do TypeORM (diferente da entidade de domínio)
export class TypeORMOrdersRepository implements IOrdersRepository {
private repo: Repository<OrderModel>;
constructor(dataSource: DataSource) {
this.repo = dataSource.getRepository(OrderModel);
}
async save(order: Order): Promise<void> {
// Mapeia da entidade de domínio para o modelo de persistência
const model = this.repo.create({
id: order.id,
customerId: order.customerId,
status: order.status,
total: order.total,
paidAt: order.paidAt,
});
await this.repo.save(model);
}
async findById(id: string): Promise<Order | null> {
const model = await this.repo.findOneBy({ id });
if (!model) return null;
// Reconstrói a entidade de domínio a partir do modelo de persistência
return this.rehydrate(model);
}
async findByCustomerId(customerId: string): Promise<Array<Order>> {
const models = await this.repo.findBy({ customerId });
return models.map((m) => this.rehydrate(m));
}
// Rehidratação: reconstrói o estado interno da entidade sem passar pelo construtor normal
private rehydrate(model: OrderModel): Order {
// Em projetos reais, você usaria um factory method estático ou
// um mapper dedicado para não expor estado interno da entidade.
const order = Object.assign(new Order(model.id, model.customerId, model.createdAt), {
_status: model.status,
_paidAt: model.paidAt,
});
return order;
}
}O Controller como Driving Adapter
O Controller Express é um Driving Adapter: recebe a requisição HTTP, converte para o formato que o Use Case entende, chama o Use Case e traduz o resultado de volta para HTTP.
import type { Request, Response, NextFunction } from 'express';
import { CreateOrderUseCase } from '../../../domain/usecases/CreateOrderUseCase';
export class OrderController {
constructor(private readonly createOrder: CreateOrderUseCase) {}
async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { customerId, items } = req.body;
// Traduz HTTP request → DTO do Use Case
const order = await this.createOrder.execute({ customerId, items });
// Traduz resultado do Use Case → HTTP response
res.status(201).json({
id: order.id,
status: order.status,
total: order.total,
createdAt: order.createdAt,
});
} catch (error) {
// Erros de domínio passam para o error handler global
next(error);
}
}
}
// Perceba: o Controller não contém NENHUMA regra de negócio.
// Ele apenas faz a tradução HTTP ↔ Use Case.
// Se amanhã você trocar Express por Fastify, só o Controller muda.Estrutura de Pastas
A estrutura de pastas deve refletir as camadas arquiteturais, não os tipos de arquivo:
src/
├── domain/ # NÚCLEO — sem dependências externas
│ ├── entities/
│ │ ├── Order.ts
│ │ └── Customer.ts
│ ├── repositories/ # Driven Ports (Interfaces)
│ │ ├── IOrdersRepository.ts
│ │ └── ICustomersRepository.ts
│ └── usecases/
│ ├── CreateOrderUseCase.ts
│ └── ProcessPaymentUseCase.ts
│
└── infra/ # BORDAS — conhece o domínio, mas não vice-versa
├── http/ # Driving Adapter: Express
│ ├── controllers/
│ │ └── OrderController.ts
│ ├── middlewares/
│ └── routes/
├── typeorm/ # Driven Adapter: Persistência
│ ├── models/ # Modelos do TypeORM (≠ Entidades de domínio)
│ └── TypeORMOrdersRepository.ts
├── messaging/ # Driven Adapter: RabbitMQ, Bull
└── container.ts # Composição de dependências (DI)Por que Entidade de Domínio ≠ Modelo de Persistência
Em projetos que usam TypeORM com decorators (@Entity(), @Column()), existe uma armadilha comum: usar a mesma classe como entidade de domínio e modelo de persistência. Isso vaza a infra dentro do domínio:
// ERRADO: A entidade de domínio tem decorators do TypeORM.
// Agora o domínio depende da infra. Regra de dependência violada.
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity('orders') // ← Dependência do TypeORM no domínio
export class Order {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
status: string;
pay(): void {
this.status = 'PAID'; // Regra de negócio misturada com persistência
}
}// CORRETO: O modelo de persistência fica na infra.
// Ele pode ter todos os decorators do TypeORM que quiser.
import { Entity, Column, PrimaryColumn, CreateDateColumn } from 'typeorm';
@Entity('orders')
export class OrderModel {
@PrimaryColumn('uuid')
id: string;
@Column()
customerId: string;
@Column()
status: string;
@Column('decimal', { precision: 10, scale: 2 })
total: number;
@Column({ nullable: true, type: 'timestamp' })
paidAt: Date | null;
@CreateDateColumn()
createdAt: Date;
}
// A entidade de domínio (Order.ts) não tem nenhum decorator.
// O Repositório faz o mapeamento entre os dois mundos.Testabilidade: O Benefício Mais Concreto
Toda essa abstração tem um benefício concreto e mensurável: seus Use Cases podem ser testados sem banco de dados, sem HTTP, sem nada de infra. Basta injetar um repositório em memória:
import { CreateOrderUseCase } from './CreateOrderUseCase';
import type { IOrdersRepository } from '../repositories/IOrdersRepository';
import type { ICustomersRepository } from '../repositories/ICustomersRepository';
import { Order } from '../entities/Order';
// Repositório em memória — zero dependência de infra
class InMemoryOrdersRepository implements IOrdersRepository {
private orders: Array<Order> = [];
async save(order: Order): Promise<void> {
this.orders.push(order);
}
async findById(id: string): Promise<Order | null> {
return this.orders.find((o) => o.id === id) ?? null;
}
async findByCustomerId(customerId: string): Promise<Array<Order>> {
return this.orders.filter((o) => o.customerId === customerId);
}
}
class InMemoryCustomersRepository implements ICustomersRepository {
async findById(id: string) {
// Simula um cliente existente para testes
if (id === 'customer-123') return { id, name: 'João Silva' };
return null;
}
}
describe('CreateOrderUseCase', () => {
let useCase: CreateOrderUseCase;
beforeEach(() => {
useCase = new CreateOrderUseCase(
new InMemoryOrdersRepository(),
new InMemoryCustomersRepository()
);
});
it('deve criar um pedido com status PENDING', async () => {
const order = await useCase.execute({
customerId: 'customer-123',
items: [{ productId: 'prod-1', quantity: 2, unitPrice: 49.90 }],
});
expect(order.status).toBe('PENDING');
expect(order.total).toBe(99.80);
});
it('deve lançar erro se o cliente não existir', async () => {
await expect(
useCase.execute({
customerId: 'cliente-inexistente',
items: [{ productId: 'prod-1', quantity: 1, unitPrice: 10 }],
})
).rejects.toThrow('Cliente não encontrado.');
});
});
// Sem banco. Sem Docker. Sem setup. Roda em milissegundos.Conclusão
Clean Architecture e Hexagonal Architecture não são opostas — são complementares. A Clean Architecture define as camadas e a Regra de Dependência. A Hexagonal nomeia os contratos (Ports) e as implementações (Adapters) de forma mais explícita. Na prática, a maioria das equipes mescla os dois conceitos.
O custo é real: mais arquivos, mais interfaces, mais mapeamento. O benefício também é real: Use Cases testáveis sem infra, liberdade de trocar frameworks sem reescrever regras de negócio, e um código que comunica a intenção de negócio antes de comunicar detalhes técnicos. Para sistemas que precisam durar anos, esse custo compensa na primeira vez que você troca um banco de dados ou um serviço de e-mail sem quebrar nada.