Robert C. Martin's Clean Architecture is one of those ideas that looks almost too simple on paper — four concentric circles, one rule about dependencies — yet consistently proves to be one of the most effective structural decisions you can make on a long-lived codebase. The payoff isn't elegance; it's the ability to defer infrastructure decisions, swap frameworks without rewriting business logic, and test the entire application core without spinning up a database or HTTP server.A Clean Architecture de Robert C. Martin é uma dessas ideias que parecem simples demais no papel — quatro círculos concêntricos, uma regra sobre dependências — mas consistentemente se provam como uma das decisões estruturais mais eficazes para codebases de longa duração. O retorno não é elegância; é a capacidade de adiar decisões de infraestrutura, trocar frameworks sem reescrever lógica de negócio e testar todo o core da aplicação sem subir banco de dados ou servidor HTTP.
In this article, I'll build a concrete TypeScript example — a payment processing module — to show exactly how each layer works, where the boundaries sit, and what discipline is required to keep the dependency rule from eroding over time.Neste artigo, vou construir um exemplo TypeScript concreto — um módulo de processamento de pagamentos — para mostrar exatamente como cada camada funciona, onde ficam os limites e qual disciplina é necessária para evitar que a regra de dependência se deteriore com o tempo.
The One Rule That MattersA Única Regra que Importa
Clean Architecture is built on a single constraint called the Dependency Rule: source code dependencies can only point inward. Nothing in an inner circle can know anything about something in an outer circle.A Clean Architecture é construída sobre uma única restrição chamada Regra de Dependência: as dependências de código-fonte só podem apontar para dentro. Nada em um círculo interno pode saber nada sobre algo em um círculo externo.
- Entities: Enterprise-wide business rules. Pure objects encoding invariants that would exist even if there were no software — a
Paymentwith a positive amount, aUserthat must have a valid email. No framework imports, no I/O.Entities: Regras de negócio da empresa. Objetos puros que codificam invariantes que existiriam mesmo sem software — umPaymentcom valor positivo, umUserque deve ter e-mail válido. Sem imports de framework, sem I/O. - Use Cases: Application-specific business rules. Each use case is a single, named operation (
ProcessPayment,RefundOrder). Orchestrates entities and calls output boundaries (interfaces) to reach the outside world.Use Cases: Regras de negócio específicas da aplicação. Cada caso de uso é uma operação única e nomeada (ProcessPayment,RefundOrder). Orquestra entidades e chama boundaries de saída (interfaces) para alcançar o mundo externo. - Interface Adapters: Controllers, presenters, gateways. Convert data between the format convenient for use cases and entities, and the format convenient for the web or database. This is where Express routes, Prisma repositories, and request/response DTOs live.Interface Adapters: Controllers, presenters, gateways. Convertem dados entre o formato conveniente para casos de uso/entidades e o formato conveniente para a web ou banco de dados. É aqui que ficam as rotas Express, repositórios Prisma e DTOs de request/response.
- Frameworks & Drivers: The outermost ring. Express, PostgreSQL, Redis, Stripe SDK. Glue code that plugs into the adapters. Should contain as little custom logic as possible.Frameworks & Drivers: O círculo mais externo. Express, PostgreSQL, Redis, Stripe SDK. Código de cola que conecta os adaptadores. Deve conter o mínimo de lógica customizada possível.
Project StructureEstrutura de Projeto
src/
├── domain/
│ └── entities/
│ ├── Payment.ts # Enterprise business rules
│ ├── Money.ts # Value object
│ └── PaymentStatus.ts
│
├── application/
│ ├── use-cases/
│ │ ├── ProcessPayment.ts # Use case implementation
│ │ └── RefundPayment.ts
│ └── ports/
│ ├── PaymentRepository.ts # Output boundary (interface)
│ └── PaymentGateway.ts # Output boundary (interface)
│
├── adapters/
│ ├── controllers/
│ │ └── PaymentController.ts # HTTP → use case
│ ├── presenters/
│ │ └── PaymentPresenter.ts # Use case result → HTTP response
│ └── gateways/
│ ├── PrismaPaymentRepository.ts # Implements PaymentRepository
│ └── StripePaymentGateway.ts # Implements PaymentGateway
│
└── infrastructure/
├── http/
│ └── server.ts # Express setup
└── database/
└── prisma.ts # Prisma client setupEntities: Pure Business RulesEntities: Regras de Negócio Puras
Entities encode invariants — rules that are always true regardless of the application context. A Payment must always have a positive amount. This check belongs here, not in the use case or the controller.Entities codificam invariantes — regras que são sempre verdadeiras independentemente do contexto da aplicação. Um Payment deve sempre ter um valor positivo. Essa verificação pertence aqui, não no caso de uso nem no controller.
// domain/entities/Money.ts
export class Money {
private constructor(
readonly amount: number,
readonly currency: string
) {}
static of(amount: number, currency: string): Money {
if (amount <= 0) throw new Error("Amount must be positive");
if (!currency.match(/^[A-Z]{3}$/)) throw new Error("Invalid currency code");
return new Money(amount, currency);
}
add(other: Money): Money {
if (this.currency !== other.currency) throw new Error("Currency mismatch");
return Money.of(this.amount + other.amount, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
}
// domain/entities/Payment.ts
import { Money } from "./Money";
import { PaymentStatus } from "./PaymentStatus";
export class Payment {
private status: PaymentStatus = PaymentStatus.PENDING;
private constructor(
readonly id: string,
readonly customerId: string,
readonly amount: Money,
readonly createdAt: Date
) {}
static create(customerId: string, amount: Money): Payment {
return new Payment(
crypto.randomUUID(),
customerId,
amount,
new Date()
);
}
authorize(): void {
if (this.status !== PaymentStatus.PENDING)
throw new Error("Only pending payments can be authorized");
this.status = PaymentStatus.AUTHORIZED;
}
fail(reason: string): void {
if (this.status !== PaymentStatus.PENDING)
throw new Error("Only pending payments can fail");
this.status = PaymentStatus.FAILED;
this._failureReason = reason;
}
getStatus(): PaymentStatus { return this.status; }
private _failureReason?: string;
getFailureReason(): string | undefined { return this._failureReason; }
}Use Cases: Application Business RulesUse Cases: Regras de Negócio da Aplicação
Each use case implements a single, well-named operation. It depends only on entities and on output port interfaces — never on Express, Prisma, or Stripe directly. This is the key discipline that enables testing without infrastructure.Cada caso de uso implementa uma operação única e bem nomeada. Depende apenas de entities e de interfaces de portas de saída — nunca de Express, Prisma ou Stripe diretamente. Esta é a disciplina central que permite testar sem infraestrutura.
// application/ports/PaymentRepository.ts
export interface PaymentRepository {
save(payment: Payment): Promise<void>;
findById(id: string): Promise<Payment | null>;
}
// application/ports/PaymentGateway.ts
export interface PaymentGateway {
charge(customerId: string, amount: Money): Promise<ChargeResult>;
}
export interface ChargeResult {
success: boolean;
transactionId?: string;
failureReason?: string;
}
// application/use-cases/ProcessPayment.ts
import { Payment } from "../../domain/entities/Payment";
import { Money } from "../../domain/entities/Money";
import { PaymentRepository } from "../ports/PaymentRepository";
import { PaymentGateway } from "../ports/PaymentGateway";
export interface ProcessPaymentInput {
customerId: string;
amountInCents: number;
currency: string;
}
export interface ProcessPaymentOutput {
paymentId: string;
status: string;
transactionId?: string;
}
export class ProcessPayment {
constructor(
private readonly paymentRepository: PaymentRepository,
private readonly paymentGateway: PaymentGateway
) {}
async execute(input: ProcessPaymentInput): Promise<ProcessPaymentOutput> {
const amount = Money.of(input.amountInCents / 100, input.currency);
const payment = Payment.create(input.customerId, amount);
const result = await this.paymentGateway.charge(
input.customerId,
amount
);
if (result.success) {
payment.authorize();
} else {
payment.fail(result.failureReason ?? "Unknown error");
}
await this.paymentRepository.save(payment);
return {
paymentId: payment.id,
status: payment.getStatus(),
transactionId: result.transactionId,
};
}
}Interface Adapters: Translating Between WorldsInterface Adapters: Traduzindo Entre Mundos
ControllerController
The controller's only job is to parse the HTTP request into the use case input DTO and invoke the use case. It has no business logic — none. Any conditional logic here is likely a sign that something belongs in the use case or entity.O único trabalho do controller é parsear o request HTTP para o DTO de entrada do caso de uso e invocar o caso de uso. Ele não tem lógica de negócio — nenhuma. Qualquer lógica condicional aqui é provavelmente um sinal de que algo pertence ao caso de uso ou à entidade.
// adapters/controllers/PaymentController.ts
import { Request, Response } from "express";
import { ProcessPayment } from "../../application/use-cases/ProcessPayment";
export class PaymentController {
constructor(private readonly processPayment: ProcessPayment) {}
async handle(req: Request, res: Response): Promise<void> {
const { customerId, amountInCents, currency } = req.body;
const output = await this.processPayment.execute({
customerId,
amountInCents,
currency,
});
res.status(201).json(output);
}
}Persistence Gateway (Prisma)Gateway de Persistência (Prisma)
The gateway translates between the domain entity and the persistence model. Notice that the domain Payment entity has no Prisma decorators — the gateway absorbs all that mapping complexity.O gateway traduz entre a entidade de domínio e o modelo de persistência. Observe que a entidade Payment do domínio não tem decoradores Prisma — o gateway absorve toda essa complexidade de mapeamento.
// adapters/gateways/PrismaPaymentRepository.ts
import { PrismaClient } from "@prisma/client";
import { Payment } from "../../domain/entities/Payment";
import { Money } from "../../domain/entities/Money";
import { PaymentRepository } from "../../application/ports/PaymentRepository";
export class PrismaPaymentRepository implements PaymentRepository {
constructor(private readonly prisma: PrismaClient) {}
async save(payment: Payment): Promise<void> {
await this.prisma.payment.upsert({
where: { id: payment.id },
update: {
status: payment.getStatus(),
failureReason: payment.getFailureReason() ?? null,
},
create: {
id: payment.id,
customerId: payment.customerId,
amountInCents: Math.round(payment.amount.amount * 100),
currency: payment.amount.currency,
status: payment.getStatus(),
createdAt: payment.createdAt,
},
});
}
async findById(id: string): Promise<Payment | null> {
const row = await this.prisma.payment.findUnique({ where: { id } });
if (!row) return null;
return Payment.reconstitute(
row.id,
row.customerId,
Money.of(row.amountInCents / 100, row.currency),
row.status,
row.createdAt
);
}
}Testability: The Real Return on InvestmentTestabilidade: O Verdadeiro Retorno do Investimento
Because neither entities nor use cases have any dependency on infrastructure, you can test all business logic with pure in-memory fakes — no database, no HTTP server, no Stripe sandbox account required.Como nem entities nem casos de uso têm qualquer dependência de infraestrutura, você pode testar toda a lógica de negócio com fakes em memória — sem banco de dados, sem servidor HTTP, sem conta sandbox da Stripe.
// In-memory fake for tests — not a mock, a real implementation
class InMemoryPaymentRepository implements PaymentRepository {
private store = new Map<string, Payment>();
async save(p: Payment) { this.store.set(p.id, p); }
async findById(id: string) { return this.store.get(id) ?? null; }
}
class FakePaymentGateway implements PaymentGateway {
private shouldSucceed = true;
failNextCharge(reason = "Insufficient funds") {
this.shouldSucceed = false;
this._reason = reason;
}
private _reason?: string;
async charge(): Promise<ChargeResult> {
if (this.shouldSucceed) return { success: true, transactionId: "txn_test" };
return { success: false, failureReason: this._reason };
}
}
// Tests — millisecond execution, zero infrastructure
describe("ProcessPayment", () => {
let repo: InMemoryPaymentRepository;
let gateway: FakePaymentGateway;
let useCase: ProcessPayment;
beforeEach(() => {
repo = new InMemoryPaymentRepository();
gateway = new FakePaymentGateway();
useCase = new ProcessPayment(repo, gateway);
});
it("authorizes payment when gateway succeeds", async () => {
const output = await useCase.execute({
customerId: "cust_1",
amountInCents: 5000,
currency: "BRL",
});
expect(output.status).toBe("AUTHORIZED");
expect(output.transactionId).toBe("txn_test");
const saved = await repo.findById(output.paymentId);
expect(saved?.getStatus()).toBe("AUTHORIZED");
});
it("marks payment as failed when gateway rejects", async () => {
gateway.failNextCharge("Card declined");
const output = await useCase.execute({
customerId: "cust_1",
amountInCents: 5000,
currency: "BRL",
});
expect(output.status).toBe("FAILED");
});
it("rejects non-positive amounts at the entity level", async () => {
await expect(
useCase.execute({ customerId: "cust_1", amountInCents: 0, currency: "BRL" })
).rejects.toThrow("Amount must be positive");
});
});Key insight: The InMemoryPaymentRepository is not a mock — it's a real implementation of the port interface. Using in-memory fakes instead of mocks means tests verify behavior, not implementation details. Swap the fake for a Testcontainers-backed Prisma instance only for adapter-level integration tests.Insight chave: O InMemoryPaymentRepository não é um mock — é uma implementação real da interface de porta. Usar fakes em memória em vez de mocks significa que os testes verificam comportamento, não detalhes de implementação. Troque o fake por uma instância Prisma com Testcontainers apenas para testes de integração no nível do adaptador.
Clean Architecture vs. Hexagonal ArchitectureClean Architecture vs. Arquitetura Hexagonal
These two patterns share the same core idea — inward-pointing dependencies, isolated domain — but differ in vocabulary and emphasis:Esses dois padrões compartilham a mesma ideia central — dependências apontando para dentro, domínio isolado — mas diferem em vocabulário e ênfase:
Feature | Clean Architecture | Hexagonal Architecture
---------------------|---------------------------|-------------------------
Vocabulary | Entities, Use Cases, | Domain, Ports, Adapters
| Interface Adapters |
Boundary abstraction | Input/Output boundaries | Input/Output ports
| (interfaces in use cases) | (interfaces owned by core)
Layers | 4 explicit circles | 3 zones (core + adapters)
Primary distinction | Separates Entities from | Emphasizes driving vs.
| Use Cases explicitly | driven adapters
Framework coupling | Zero in inner two circles | Zero in core
Typical stack | Any — language-agnostic | Java/Spring Boot commonIn practice, they are complementary. Many teams use Clean Architecture's layer model with Hexagonal Architecture's port/adapter terminology. The concepts that matter — the Dependency Rule, framework isolation, port interfaces — are shared by both.Na prática, eles são complementares. Muitas equipes usam o modelo de camadas da Clean Architecture com a terminologia de port/adapter da Arquitetura Hexagonal. Os conceitos que importam — a Regra de Dependência, isolamento de framework, interfaces de porta — são compartilhados por ambos.
The Disciplines That Actually MatterAs Disciplinas que Realmente Importam
The pattern erodes when teams don't maintain two specific habits:O padrão se deteriora quando as equipes não mantêm dois hábitos específicos:
1. No infrastructure imports in entities or use cases. If you see import { PrismaClient } or import { Request } from 'express' inside application/ or domain/, the boundary has been violated. Enforce this with ESLint import rules or architectural fitness functions (using dependency-cruiser).1. Nenhum import de infraestrutura em entities ou use cases. Se você ver import { PrismaClient } ou import { Request } from 'express' dentro de application/ ou domain/, o limite foi violado. Imponha isso com regras de import do ESLint ou funções de aptidão arquitetural (usando dependency-cruiser).
2. Use cases speak in domain terms, not HTTP terms. The input DTO to a use case should be a plain object with domain-meaningful field names, not a shaped-after-the-HTTP-request body. Controllers translate; use cases don't know about HTTP.2. Casos de uso falam em termos de domínio, não em termos HTTP. O DTO de entrada para um caso de uso deve ser um objeto simples com nomes de campo significativos para o domínio, não um espelho do corpo do request HTTP. Controllers traduzem; casos de uso não sabem sobre HTTP.
When Not to Apply ItQuando Não Aplicar
Clean Architecture has a real overhead: more files, more interfaces, more mapping. It pays off when:A Clean Architecture tem um overhead real: mais arquivos, mais interfaces, mais mapeamento. Compensa quando:
- The domain is complex enough that isolating business rules from infrastructure actually reduces cognitive load.O domínio é complexo o suficiente para que isolar regras de negócio da infraestrutura realmente reduza a carga cognitiva.
- The system is expected to evolve over multiple years with rotating teams.O sistema deve evoluir por vários anos com equipes rotativas.
- Fast, deterministic unit tests on business logic are a priority.Testes unitários rápidos e determinísticos na lógica de negócio são uma prioridade.
For simple CRUD APIs, a three-tier structure (route → service → repository) often serves better. Clean Architecture's overhead is proportional to the richness of the domain it protects. Don't apply it uniformly across a monorepo — apply it where the domain warrants it.Para APIs CRUD simples, uma estrutura de três camadas (route → service → repository) frequentemente serve melhor. O overhead da Clean Architecture é proporcional à riqueza do domínio que ela protege. Não a aplique uniformemente em um monorepo — aplique onde o domínio justifica.
Key TakeawaysPontos Principais
- The Dependency Rule is the entire pattern: source code dependencies can only point inward, never outward.A Regra de Dependência é todo o padrão: dependências de código-fonte só podem apontar para dentro, nunca para fora.
- Entities hold invariants — rules that are always true, regardless of the use case. Keep them free of infrastructure.Entities guardam invariantes — regras que são sempre verdadeiras, independentemente do caso de uso. Mantenha-as livres de infraestrutura.
- Use cases depend on port interfaces (output boundaries), not on concrete implementations. This enables in-memory fakes in tests.Casos de uso dependem de interfaces de porta (boundaries de saída), não de implementações concretas. Isso habilita fakes em memória nos testes.
- Interface Adapters translate, they don't decide. Business logic that leaks into controllers or presenters violates the boundary.Interface Adapters traduzem, não decidem. Lógica de negócio que vaza para controllers ou presenters viola o limite.
- Use in-memory fakes over mocks for port implementations in unit tests — they verify behavior, not call sequences.Use fakes em memória em vez de mocks para implementações de porta em testes unitários — eles verificam comportamento, não sequências de chamadas.
- Enforce the Dependency Rule automatically with
dependency-cruiseror ESLint import boundary rules — human discipline alone doesn't scale.Imponha a Regra de Dependência automaticamente comdependency-cruiserou regras de import do ESLint — disciplina humana sozinha não escala.