Clean Architecture, formulada por Robert C. Martin, organiza o código em camadas concêntricas onde as dependências sempre apontam para dentro — do framework para o domínio, nunca o contrário. O resultado é um sistema onde a lógica de negócio não sabe que existe um banco de dados, um framework HTTP ou qualquer infraestrutura específica.
A regra central é a Regra de Dependência: código nas camadas internas não pode conhecer nada das camadas externas. O domínio não importa classes do Laravel. Os use cases não importam PDO. As interfaces de repositório vivem no domínio — as implementações concretas vivem na infraestrutura.
Neste artigo construímos um módulo completo de pedidos seguindo Clean Architecture: Entidades, Use Cases, Interfaces de Repositório, Controllers e Infraestrutura — cada camada no seu lugar.
As quatro camadas
┌─────────────────────────────────────────────────────────┐
│ Frameworks & Drivers (Laravel, Slim, PDO, Redis) │ ← Camada mais externa
├─────────────────────────────────────────────────────────┤
│ Interface Adapters (Controllers, Presenters, Gateways)│
├─────────────────────────────────────────────────────────┤
│ Application (Use Cases / Interactors) │
├─────────────────────────────────────────────────────────┤
│ Domain (Entities, Value Objects, Rules) │ ← Camada mais interna
└─────────────────────────────────────────────────────────┘
Dependências apontam SEMPRE para dentro →
src/
├── Domain/
│ ├── Pedido/
│ │ ├── Pedido.php # Entidade
│ │ ├── ItemDePedido.php # Value Object
│ │ ├── StatusDoPedido.php # Enum
│ │ └── PedidoRepositorioInterface.php
│ └── Produto/
│ ├── Produto.php
│ └── ProdutoRepositorioInterface.php
├── Application/
│ └── Pedido/
│ ├── CriarPedido/
│ │ ├── CriarPedidoUseCase.php
│ │ ├── CriarPedidoInput.php
│ │ └── CriarPedidoOutput.php
│ └── CancelarPedido/
│ ├── CancelarPedidoUseCase.php
│ └── CancelarPedidoInput.php
├── Infrastructure/
│ └── Persistence/
│ ├── PedidoRepositorioPDO.php
│ └── ProdutoRepositorioPDO.php
└── Presentation/
└── Http/
└── PedidoController.php
Camada de Domínio — Entidades e Value Objects
<?php
declare(strict_types=1);
namespace App\Domain\Pedido;
// ── Value Object — imutável, sem identidade ──────────────────────────
final class ItemDePedido
{
public function __construct(
public readonly int $produtoId,
public readonly int $quantidade,
public readonly float $precoUnitario,
) {
if ($quantidade <= 0) {
throw new \DomainException("Quantidade deve ser positiva, recebido: {$quantidade}");
}
if ($precoUnitario < 0) {
throw new \DomainException("Preço não pode ser negativo.");
}
}
public function subtotal(): float
{
return round($this->precoUnitario * $this->quantidade, 2);
}
}
// ── Enum de status — estados válidos do pedido ────────────────────────
enum StatusDoPedido: string
{
case Pendente = 'pendente';
case Confirmado = 'confirmado';
case EmPreparo = 'em_preparo';
case Enviado = 'enviado';
case Entregue = 'entregue';
case Cancelado = 'cancelado';
public function podeTransicionarPara(self $destino): bool
{
return match($this) {
self::Pendente => in_array($destino, [self::Confirmado, self::Cancelado]),
self::Confirmado => in_array($destino, [self::EmPreparo, self::Cancelado]),
self::EmPreparo => in_array($destino, [self::Enviado, self::Cancelado]),
self::Enviado => $destino === self::Entregue,
default => false,
};
}
}
// ── Entidade — tem identidade, contém regras de negócio ──────────────
class Pedido
{
private array $itens = [];
private string $numero;
private function __construct(
public readonly int $id,
public readonly int $clienteId,
private StatusDoPedido $status,
private \DateTimeImmutable $criadoEm,
) {
$this->numero = sprintf('PED-%06d', $id);
}
public static function criar(int $clienteId): static
{
return new static(
id: 0,
clienteId: $clienteId,
status: StatusDoPedido::Pendente,
criadoEm: new \DateTimeImmutable(),
);
}
public static function reconstituir(
int $id,
int $clienteId,
StatusDoPedido $status,
\DateTimeImmutable $criadoEm,
array $itens = [],
): static {
$pedido = new static($id, $clienteId, $status, $criadoEm);
$pedido->itens = $itens;
return $pedido;
}
public function adicionarItem(ItemDePedido $item): void
{
if ($this->status !== StatusDoPedido::Pendente) {
throw new \DomainException(
"Não é possível adicionar itens a um pedido com status '{$this->status->value}'."
);
}
$this->itens[] = $item;
}
public function confirmar(): void
{
if (empty($this->itens)) {
throw new \DomainException('Não é possível confirmar um pedido sem itens.');
}
$this->transicionarPara(StatusDoPedido::Confirmado);
}
public function cancelar(): void
{
$this->transicionarPara(StatusDoPedido::Cancelado);
}
private function transicionarPara(StatusDoPedido $destino): void
{
if (!$this->status->podeTransicionarPara($destino)) {
throw new \DomainException(
"Transição inválida: '{$this->status->value}' → '{$destino->value}'"
);
}
$this->status = $destino;
}
public function total(): float
{
return array_reduce(
$this->itens,
fn(float $acc, ItemDePedido $item) => $acc + $item->subtotal(),
0.0
);
}
public function status(): StatusDoPedido { return $this->status; }
public function numero(): string { return $this->numero; }
public function itens(): array { return $this->itens; }
public function criadoEm(): \DateTimeImmutable { return $this->criadoEm; }
}
Interface de Repositório — no Domínio, não na Infraestrutura
<?php
declare(strict_types=1);
namespace App\Domain\Pedido;
// A interface vive no Domínio — o Domínio define o CONTRATO
// A implementação PDO vive na Infraestrutura — um detalhe externo
interface PedidoRepositorioInterface
{
public function salvar(Pedido $pedido): Pedido;
public function buscarPorId(int $id): ?Pedido;
public function buscarPorCliente(int $clienteId, int $limite = 20, int $offset = 0): array;
public function proximo(): int; // próximo ID disponível (ou use autoincrement)
}
Camada de Aplicação — Use Cases
<?php
declare(strict_types=1);
namespace App\Application\Pedido\CriarPedido;
// ── DTOs de entrada e saída — sem lógica de negócio ──────────────────
final class CriarPedidoInput
{
public function __construct(
public readonly int $clienteId,
/** @var array<array{produto_id: int, quantidade: int}> */
public readonly array $itens,
) {}
}
final class ItemDePedidoOutput
{
public function __construct(
public readonly int $produtoId,
public readonly string $nomeProduto,
public readonly int $quantidade,
public readonly float $precoUnitario,
public readonly float $subtotal,
) {}
}
final class CriarPedidoOutput
{
public function __construct(
public readonly int $pedidoId,
public readonly string $numero,
public readonly string $status,
public readonly float $total,
/** @var ItemDePedidoOutput[] */
public readonly array $itens,
public readonly string $criadoEm,
) {}
}
<?php
declare(strict_types=1);
namespace App\Application\Pedido\CriarPedido;
use App\Domain\Pedido\{Pedido, ItemDePedido, PedidoRepositorioInterface};
use App\Domain\Produto\ProdutoRepositorioInterface;
class CriarPedidoUseCase
{
public function __construct(
private readonly PedidoRepositorioInterface $pedidos,
private readonly ProdutoRepositorioInterface $produtos,
) {}
public function executar(CriarPedidoInput $input): CriarPedidoOutput
{
if (empty($input->itens)) {
throw new \DomainException('Um pedido deve ter pelo menos um item.');
}
$pedido = Pedido::criar($input->clienteId);
$itensOutput = [];
foreach ($input->itens as $dadosItem) {
$produto = $this->produtos->buscarPorId($dadosItem['produto_id']);
if ($produto === null) {
throw new \DomainException(
"Produto #{$dadosItem['produto_id']} não encontrado."
);
}
if (!$produto->temEstoque($dadosItem['quantidade'])) {
throw new \DomainException(
"Estoque insuficiente para '{$produto->nome}'. "
. "Disponível: {$produto->estoque}, solicitado: {$dadosItem['quantidade']}"
);
}
$item = new ItemDePedido(
produtoId: $produto->id,
quantidade: $dadosItem['quantidade'],
precoUnitario: $produto->preco,
);
$pedido->adicionarItem($item);
$itensOutput[] = new ItemDePedidoOutput(
produtoId: $produto->id,
nomeProduto: $produto->nome,
quantidade: $dadosItem['quantidade'],
precoUnitario: $produto->preco,
subtotal: $item->subtotal(),
);
}
$pedido->confirmar();
$salvo = $this->pedidos->salvar($pedido);
return new CriarPedidoOutput(
pedidoId: $salvo->id,
numero: $salvo->numero(),
status: $salvo->status()->value,
total: $salvo->total(),
itens: $itensOutput,
criadoEm: $salvo->criadoEm()->format('Y-m-d H:i:s'),
);
}
}
<?php
declare(strict_types=1);
namespace App\Application\Pedido\CancelarPedido;
use App\Domain\Pedido\PedidoRepositorioInterface;
final class CancelarPedidoInput
{
public function __construct(
public readonly int $pedidoId,
public readonly int $clienteId, // para verificar que é o dono
) {}
}
class CancelarPedidoUseCase
{
public function __construct(
private readonly PedidoRepositorioInterface $pedidos,
) {}
public function executar(CancelarPedidoInput $input): void
{
$pedido = $this->pedidos->buscarPorId($input->pedidoId);
if ($pedido === null) {
throw new \DomainException("Pedido #{$input->pedidoId} não encontrado.");
}
if ($pedido->clienteId !== $input->clienteId) {
throw new \DomainException('Acesso negado: este pedido pertence a outro cliente.');
}
$pedido->cancelar(); // a entidade valida a transição de estado
$this->pedidos->salvar($pedido);
}
}
Camada de Apresentação — Controller
<?php
declare(strict_types=1);
namespace App\Presentation\Http;
use App\Application\Pedido\CriarPedido\{CriarPedidoUseCase, CriarPedidoInput};
use App\Application\Pedido\CancelarPedido\{CancelarPedidoUseCase, CancelarPedidoInput};
class PedidoController
{
public function __construct(
private readonly CriarPedidoUseCase $criarPedido,
private readonly CancelarPedidoUseCase $cancelarPedido,
) {}
// POST /api/pedidos
public function criar(array $params): void
{
$usuario = $_REQUEST['usuario_autenticado'] ?? null;
if ($usuario === null) Resposta::naoAutorizado();
$corpo = $this->lerCorpo();
if (empty($corpo['itens']) || !is_array($corpo['itens'])) {
Resposta::badRequest('O campo "itens" é obrigatório e deve ser um array.');
}
try {
$output = $this->criarPedido->executar(new CriarPedidoInput(
clienteId: $usuario['id'],
itens: $corpo['itens'],
));
Resposta::criado([
'pedido_id' => $output->pedidoId,
'numero' => $output->numero,
'status' => $output->status,
'total' => $output->total,
'itens' => array_map(fn($i) => [
'produto_id' => $i->produtoId,
'nome_produto' => $i->nomeProduto,
'quantidade' => $i->quantidade,
'preco_unitario' => $i->precoUnitario,
'subtotal' => $i->subtotal,
], $output->itens),
'criado_em' => $output->criadoEm,
]);
} catch (\DomainException $e) {
// Erros de domínio são erros do cliente — 422 Unprocessable Entity
Resposta::erro(422, $e->getMessage());
}
}
// POST /api/pedidos/{id}/cancelar
public function cancelar(array $params): void
{
$usuario = $_REQUEST['usuario_autenticado'] ?? null;
if ($usuario === null) Resposta::naoAutorizado();
try {
$this->cancelarPedido->executar(new CancelarPedidoInput(
pedidoId: $params['id'],
clienteId: $usuario['id'],
));
Resposta::semConteudo();
} catch (\DomainException $e) {
Resposta::erro(422, $e->getMessage());
}
}
private function lerCorpo(): array
{
$dados = json_decode(file_get_contents('php://input') ?: '', associative: true);
if (json_last_error() !== JSON_ERROR_NONE) Resposta::badRequest('JSON inválido.');
return $dados ?? [];
}
}
Resumo do artigo
| Camada | Conteúdo | Depende de |
|---|---|---|
| Domain | Entidades, Value Objects, Enums, Interfaces de Repo | Nada externo |
| Application | Use Cases, DTOs (Input/Output) | Domain |
| Infrastructure | Repositórios PDO, cache, email | Domain (interfaces) |
| Presentation | Controllers, Middlewares | Application |
Exercício da semana
-
Implemente o
PedidoRepositorioPDOna camada de Infrastructure: métodosalvar()deve inserir ou atualizar dependendo se$pedido->id === 0. MétodobuscarPorId()deve reconstituir a entidade viaPedido::reconstituir(). -
Crie o Use Case
AtualizarStatusDoPedidocomAtualizarStatusInput(pedidoId, novoStatus, usuarioId). Valide que o usuário tem perfiladminou é o dono do pedido. Use o métodotransicionarPara()da entidade. -
Escreva testes unitários para
CriarPedidoUseCase: mock dePedidoRepositorioInterfaceeProdutoRepositorioInterface. Teste: produto não encontrado, estoque insuficiente, pedido criado com sucesso. -
Adicione um
EventosDeDominioà entidadePedido: ao confirmar, registrePedidoConfirmado(pedidoId, clienteId, total). O Use Case coleta os eventos apóssalvar()e os despacha para umEventDispatcher. -
Desafio: implemente um segundo adaptador de repositório —
PedidoRepositorioEmMemoriaque armazena os pedidos em um array PHP. Use-o nos testes de integração do Use Case para testar o fluxo completo sem banco de dados.
Referências
- Clean Architecture — Robert C. Martin (livro): https://www.oreilly.com/library/view/clean-architecture-a/9780134494272/
- The Clean Architecture — artigo original: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
- PHP The Right Way: https://phptherightway.com
- Herberto Graça — DDD, Hexagonal, Onion, Clean Architecture: https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/