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) {
// O terceiro argumento do in_array não é decoração: sem ele a
// comparação é frouxa, e o hábito de omitir cobra caro em listas
// que um dia deixem de ser só de enums.
self::Pendente => in_array($destino, [self::Confirmado, self::Cancelado], true),
self::Confirmado => in_array($destino, [self::EmPreparo, self::Cancelado], true),
self::EmPreparo => in_array($destino, [self::Enviado, self::Cancelado], true),
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);
}
// O id entra já aqui, e não depois de gravar. O número do pedido deriva
// dele no construtor: criado com id 0, o pedido nasce PED-000000 — e é
// esse valor que chega ao cliente sempre que o repositório devolver o mesmo
// objeto que recebeu, que é o caso comum. É para isso que o repositório
// expõe proximo(): na Clean Architecture a identidade pertence ao domínio,
// não ao autoincrement do banco.
public static function criar(int $id, int $clienteId): static
{
return new static(
id: $id,
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;
// Reserva a identidade antes de persistir, para que a entidade já nasça
// completa — com número definitivo — em vez de depender do id que o
// banco devolveria só depois do INSERT.
public function proximo(): int;
}
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($this->pedidos->proximo(), $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 ?? [];
}
}
A Regra de Dependência cabe em uma pergunta que se faz arquivo por arquivo: este use aponta para dentro ou para fora? A entidade Pedido não importa nada — nem PDO, nem HTTP, nem o nome da tabela — e por isso as regras de transição de estado podem ser testadas sem levantar infraestrutura nenhuma. A interface de repositório morar no domínio é o que torna isso possível: quem define o contrato é quem precisa dele, não quem o implementa.
O preço é visível e vale dizer em voz alta: quatro camadas, DTOs de entrada e saída, uma pasta por caso de uso. Para um CRUD de cadastro isso é cerimônia pura. A arquitetura se paga quando as regras de negócio têm vida própria — estados que não podem pular etapas, invariantes que precisam valer sempre — e começa a custar caro no dia em que a lógica cabe em um UPDATE.
Fontes e leituras recomendadas
- 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/
- Alistair Cockburn — Hexagonal Architecture (ports and adapters), a origem da ideia: https://alistair.cockburn.us/hexagonal-architecture/
- Matthias Noback — Ports & adapters, a camada de infraestrutura vista de dentro, com exemplos em PHP: https://matthiasnoback.nl/2017/08/layers-ports-and-adapters-part-3-ports-and-adapters/
Exercícios
Exercício 1
Implemente o PedidoRepositorioPDO na camada de Infrastructure: método salvar() deve inserir ou atualizar dependendo se $pedido->id === 0. Método buscarPorId() deve reconstituir a entidade via Pedido::reconstituir().
Ver resposta
✓ Resposta: A distinção entre reconstituir() e o construtor é o detalhe que mais se erra: o construtor aplica as regras de criação; a reconstituição apenas devolve a entidade ao estado que ela já tinha. Passar dado do banco pelo construtor faz registro histórico legítimo ser rejeitado por regra nova.
<?php
declare(strict_types=1);
namespace Infrastructure\Persistence;
use Domain\Pedido\Pedido;
use Domain\Pedido\PedidoRepositorioInterface;
use Domain\Pedido\StatusPedido;
use PDO;
// A interface mora no Domain; a implementação, aqui. É a inversão que mantém
// a seta de dependência apontando para dentro: o domínio não conhece PDO.
final class PedidoRepositorioPDO implements PedidoRepositorioInterface
{
public function __construct(private readonly PDO $pdo) {}
public function salvar(Pedido $pedido): Pedido
{
// id === 0 é a marca de "ainda não persistido". Quem decide é o
// repositório, não quem chama.
return $pedido->id === 0 ? $this->inserir($pedido) : $this->atualizar($pedido);
}
private function inserir(Pedido $pedido): Pedido
{
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare(
'INSERT INTO pedidos (cliente_id, status, total, criado_em)
VALUES (:cliente, :status, :total, :criado)'
);
$stmt->execute([
':cliente' => $pedido->clienteId,
':status' => $pedido->status()->value,
':total' => $pedido->total()->emCentavos(),
':criado' => $pedido->criadoEm->format('Y-m-d H:i:s'),
]);
$id = (int) $this->pdo->lastInsertId();
$this->salvarItens($id, $pedido);
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
// Devolve a entidade com o id que o banco gerou.
return $pedido->comId($id);
}
private function atualizar(Pedido $pedido): Pedido
{
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare(
'UPDATE pedidos SET status = :status, total = :total WHERE id = :id'
);
$stmt->execute([
':status' => $pedido->status()->value,
':total' => $pedido->total()->emCentavos(),
':id' => $pedido->id,
]);
// Regravar os itens é mais simples que fazer diff — e correto,
// desde que dentro da transação.
$this->pdo->prepare('DELETE FROM pedido_itens WHERE pedido_id = :id')
->execute([':id' => $pedido->id]);
$this->salvarItens($pedido->id, $pedido);
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
return $pedido;
}
private function salvarItens(int $pedidoId, Pedido $pedido): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO pedido_itens (pedido_id, produto_id, quantidade, preco_unitario)
VALUES (:pedido, :produto, :qtd, :preco)'
);
foreach ($pedido->itens() as $item) {
$stmt->execute([
':pedido' => $pedidoId,
':produto' => $item->produtoId,
':qtd' => $item->quantidade,
':preco' => $item->precoUnitario->emCentavos(),
]);
}
}
public function buscarPorId(int $id): ?Pedido
{
$stmt = $this->pdo->prepare('SELECT * FROM pedidos WHERE id = :id');
$stmt->execute([':id' => $id]);
$linha = $stmt->fetch(PDO::FETCH_ASSOC);
if ($linha === false) {
return null;
}
$itens = $this->pdo->prepare(
'SELECT produto_id, quantidade, preco_unitario
FROM pedido_itens WHERE pedido_id = :id'
);
$itens->execute([':id' => $id]);
// reconstituir() ≠ construtor: recria a entidade num estado JÁ
// existente, pulando as regras de criação (um pedido antigo pode
// estar num status que hoje o fluxo não permitiria criar direto).
return Pedido::reconstituir(
id: (int) $linha['id'],
clienteId: (int) $linha['cliente_id'],
status: StatusPedido::from($linha['status']),
itens: $itens->fetchAll(PDO::FETCH_ASSOC),
criadoEm: new \DateTimeImmutable($linha['criado_em']),
);
}
public function proximaIdentidade(): int
{
return 0; // delegado ao AUTO_INCREMENT
}
}
Exercício 2
Crie o Use Case AtualizarStatusDoPedido com AtualizarStatusInput(pedidoId, novoStatus, usuarioId). Valide que o usuário tem perfil admin ou é o dono do pedido. Use o método transicionarPara() da entidade.
Ver resposta
✓ Resposta: A divisão que sustenta a arquitetura: autorização (quem pode) é do Use Case; transição válida (o que pode) é da entidade. E a tradução de exceção para código HTTP fica no controller — o Use Case não sabe que existe HTTP.
<?php
declare(strict_types=1);
namespace Application\Pedido;
use Domain\Pedido\PedidoRepositorioInterface;
use Domain\Pedido\StatusPedido;
use Domain\Usuario\UsuarioRepositorioInterface;
// DTO de entrada: o Use Case não conhece $_POST, Request nem JSON.
final readonly class AtualizarStatusInput
{
public function __construct(
public int $pedidoId,
public string $novoStatus,
public int $usuarioId,
) {}
}
final readonly class AtualizarStatusOutput
{
public function __construct(
public int $pedidoId,
public string $statusAnterior,
public string $statusAtual,
) {}
}
final class PedidoNaoEncontradoException extends \DomainException {}
final class AcessoNegadoException extends \DomainException {}
final class AtualizarStatusDoPedido
{
public function __construct(
private readonly PedidoRepositorioInterface $pedidos,
private readonly UsuarioRepositorioInterface $usuarios,
) {}
public function executar(AtualizarStatusInput $input): AtualizarStatusOutput
{
$pedido = $this->pedidos->buscarPorId($input->pedidoId)
?? throw new PedidoNaoEncontradoException("Pedido {$input->pedidoId} não existe.");
$usuario = $this->usuarios->buscarPorId($input->usuarioId)
?? throw new AcessoNegadoException('Usuário não encontrado.');
// Autorização é regra de aplicação: quem PODE fazer. A transição em
// si (o que é permitido) é regra de domínio, e mora na entidade.
$ehDono = $pedido->clienteId === $usuario->id;
if (!$usuario->ehAdmin() && !$ehDono) {
throw new AcessoNegadoException(
'Apenas o dono do pedido ou um admin podem alterar o status.'
);
}
$anterior = $pedido->status();
$novo = StatusPedido::tryFrom($input->novoStatus)
?? throw new \InvalidArgumentException("Status desconhecido: {$input->novoStatus}.");
// Cliente não avança o pedido para qualquer lugar: só cancela.
if (!$usuario->ehAdmin() && $novo !== StatusPedido::Cancelado) {
throw new AcessoNegadoException('O cliente só pode cancelar o pedido.');
}
// A entidade valida a transição e lança se for inválida.
$pedido->transicionarPara($novo);
$this->pedidos->salvar($pedido);
return new AtualizarStatusOutput(
pedidoId: $pedido->id,
statusAnterior: $anterior->value,
statusAtual: $novo->value,
);
}
}
// ---------- O controller só traduz HTTP ↔ DTO ---------------------------
final class PedidoStatusController
{
public function __construct(private readonly AtualizarStatusDoPedido $useCase) {}
public function patch(array $params): array
{
$corpo = json_decode(file_get_contents('php://input') ?: '{}', true) ?? [];
try {
$saida = $this->useCase->executar(new AtualizarStatusInput(
pedidoId: (int) $params['id'],
novoStatus: (string) ($corpo['status'] ?? ''),
usuarioId: (int) $params['usuario_id'],
));
return ['status' => 200, 'corpo' => [
'pedido_id' => $saida->pedidoId,
'de' => $saida->statusAnterior,
'para' => $saida->statusAtual,
]];
} catch (PedidoNaoEncontradoException $e) {
return ['status' => 404, 'corpo' => ['erro' => $e->getMessage()]];
} catch (AcessoNegadoException $e) {
return ['status' => 403, 'corpo' => ['erro' => $e->getMessage()]];
} catch (\DomainException | \InvalidArgumentException $e) {
return ['status' => 422, 'corpo' => ['erro' => $e->getMessage()]];
}
}
}
Exercício 3
Escreva testes unitários para CriarPedidoUseCase: mock de PedidoRepositorioInterface e ProdutoRepositorioInterface. Teste: produto não encontrado, estoque insuficiente, pedido criado com sucesso.
Ver resposta
✓ Resposta: O expects(self::never())->method('salvar') nos casos de erro é a asserção que mais vale: ela prova que a validação acontece antes da persistência. Sem ela, o teste passaria mesmo com um pedido inválido já gravado no banco.
<?php
declare(strict_types=1);
namespace Tests\Unit\Application;
use Application\Pedido\CriarPedidoInput;
use Application\Pedido\CriarPedidoUseCase;
use Domain\Pedido\PedidoRepositorioInterface;
use Domain\Produto\Produto;
use Domain\Produto\ProdutoRepositorioInterface;
use Domain\Shared\Dinheiro;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CriarPedidoUseCaseTest extends TestCase
{
private PedidoRepositorioInterface $pedidos;
private ProdutoRepositorioInterface $produtos;
private CriarPedidoUseCase $useCase;
protected function setUp(): void
{
// Sem banco, sem framework, sem I/O: o Use Case é lógica pura.
// Esta suíte roda em milissegundos.
$this->pedidos = $this->createMock(PedidoRepositorioInterface::class);
$this->produtos = $this->createMock(ProdutoRepositorioInterface::class);
$this->useCase = new CriarPedidoUseCase($this->pedidos, $this->produtos);
}
#[Test]
public function produtoNaoEncontradoInterrompeAntesDeSalvar(): void
{
$this->produtos->method('buscarPorId')->willReturn(null);
// A asserção mais importante: nada foi persistido.
$this->pedidos->expects(self::never())->method('salvar');
$this->expectException(\Domain\Produto\ProdutoNaoEncontradoException::class);
$this->useCase->executar(new CriarPedidoInput(
clienteId: 7,
itens: [['produto_id' => 999, 'quantidade' => 1]],
));
}
#[Test]
public function estoqueInsuficienteInterrompeAntesDeSalvar(): void
{
$this->produtos->method('buscarPorId')->willReturn(
new Produto(id: 1, nome: 'Teclado', preco: Dinheiro::deReais(349.90), estoque: 2)
);
$this->pedidos->expects(self::never())->method('salvar');
$this->expectException(\Domain\Produto\EstoqueInsuficienteException::class);
$this->useCase->executar(new CriarPedidoInput(
clienteId: 7,
itens: [['produto_id' => 1, 'quantidade' => 10]],
));
}
#[Test]
public function pedidoCriadoComSucessoCalculaOTotalESalva(): void
{
$this->produtos->method('buscarPorId')->willReturnCallback(
static fn(int $id): Produto => match ($id) {
1 => new Produto(1, 'Teclado', Dinheiro::deReais(349.90), 10),
2 => new Produto(2, 'Mouse', Dinheiro::deReais(129.50), 10),
}
);
$this->pedidos->expects(self::once())
->method('salvar')
->with(self::callback(static function ($pedido): bool {
// Verifica o que foi passado ao repositório: 2 itens e o
// total somado corretamente (349,90 + 2 × 129,50).
return count($pedido->itens()) === 2
&& $pedido->total()->emCentavos() === 60890;
}))
->willReturnArgument(0);
$saida = $this->useCase->executar(new CriarPedidoInput(
clienteId: 7,
itens: [
['produto_id' => 1, 'quantidade' => 1],
['produto_id' => 2, 'quantidade' => 2],
],
));
self::assertSame(60890, $saida->totalCentavos);
self::assertSame('rascunho', $saida->status);
}
#[Test]
public function pedidoSemItensEhRejeitado(): void
{
$this->pedidos->expects(self::never())->method('salvar');
$this->expectException(\InvalidArgumentException::class);
$this->useCase->executar(new CriarPedidoInput(clienteId: 7, itens: []));
}
#[Test]
public function oMesmoProdutoDuasVezesTemAsQuantidadesSomadas(): void
{
$this->produtos->method('buscarPorId')->willReturn(
new Produto(1, 'Teclado', Dinheiro::deReais(100.00), 10)
);
$this->pedidos->method('salvar')->willReturnArgument(0);
$saida = $this->useCase->executar(new CriarPedidoInput(
clienteId: 7,
itens: [
['produto_id' => 1, 'quantidade' => 2],
['produto_id' => 1, 'quantidade' => 3],
],
));
self::assertSame(50000, $saida->totalCentavos);
self::assertCount(1, $saida->itens);
}
}
Exercício 4
Adicione um EventosDeDominio à entidade Pedido: ao confirmar, registre PedidoConfirmado(pedidoId, clienteId, total). O Use Case coleta os eventos após salvar() e os despacha para um EventDispatcher.
Ver resposta
✓ Resposta: Duas decisões carregam esse desenho: a entidade registra e o Use Case despacha (assim o domínio não depende de infraestrutura), e o despacho acontece depois do salvar(). Inverter essa ordem produz notificação de pedido que não existe. O exercício 5 do artigo de DDD resolve o que sobra desse risco.
<?php
declare(strict_types=1);
namespace Domain\Shared;
interface EventoDeDominio
{
public function ocorridoEm(): \DateTimeImmutable;
public function nome(): string;
}
trait RegistraEventos
{
/** @var EventoDeDominio[] */
private array $eventos = [];
protected function registrarEvento(EventoDeDominio $evento): void
{
$this->eventos[] = $evento;
}
/**
* Devolve E LIMPA. Coletar duas vezes não pode despachar o mesmo evento
* duas vezes — é o que garante que a notificação não sai em dobro.
* @return EventoDeDominio[]
*/
public function liberarEventos(): array
{
$eventos = $this->eventos;
$this->eventos = [];
return $eventos;
}
}
// ---------------------------------------- Domain/Pedido/PedidoConfirmado.php
declare(strict_types=1);
namespace Domain\Pedido;
use Domain\Shared\EventoDeDominio;
final readonly class PedidoConfirmado implements EventoDeDominio
{
public function __construct(
public int $pedidoId,
public int $clienteId,
public int $totalCentavos,
private \DateTimeImmutable $em = new \DateTimeImmutable(),
) {}
public function ocorridoEm(): \DateTimeImmutable { return $this->em; }
public function nome(): string { return 'pedido.confirmado'; }
}
// ---------------------------------------- Domain/Pedido/Pedido.php
declare(strict_types=1);
namespace Domain\Pedido;
use Domain\Shared\RegistraEventos;
final class Pedido
{
use RegistraEventos;
private StatusPedido $status = StatusPedido::Rascunho;
public function confirmar(): void
{
if ($this->itens === []) {
throw new \DomainException('Pedido sem itens não pode ser confirmado.');
}
$this->transicionarPara(StatusPedido::Aguardando);
// A entidade só REGISTRA. Ela não despacha nada: despachar exigiria
// que o domínio conhecesse o dispatcher — e ele não conhece nada.
$this->registrarEvento(new PedidoConfirmado(
pedidoId: $this->id,
clienteId: $this->clienteId,
totalCentavos: $this->total()->emCentavos(),
));
}
}
// ---------------------------------------- Application/Pedido/ConfirmarPedido.php
declare(strict_types=1);
namespace Application\Pedido;
final class ConfirmarPedido
{
public function __construct(
private readonly PedidoRepositorioInterface $pedidos,
private readonly EventDispatcherInterface $eventos,
) {}
public function executar(int $pedidoId): void
{
$pedido = $this->pedidos->buscarPorId($pedidoId)
?? throw new PedidoNaoEncontradoException("Pedido {$pedidoId} não existe.");
$pedido->confirmar();
// A ORDEM É A REGRA: salvar primeiro, despachar depois. Despachar
// antes de persistir manda e-mail de pedido que pode nem existir se
// o INSERT falhar.
$this->pedidos->salvar($pedido);
foreach ($pedido->liberarEventos() as $evento) {
$this->eventos->despachar($evento);
}
}
}
// ---------------------------------------- Application/Shared/EventDispatcher.php
declare(strict_types=1);
namespace Application\Shared;
final class EventDispatcher implements EventDispatcherInterface
{
/** @var array<string, callable[]> */
private array $handlers = [];
public function registrar(string $nomeEvento, callable $handler): void
{
$this->handlers[$nomeEvento][] = $handler;
}
public function despachar(EventoDeDominio $evento): void
{
foreach ($this->handlers[$evento->nome()] ?? [] as $handler) {
try {
$handler($evento);
} catch (\Throwable $e) {
// Handler que falha não pode desfazer o que já foi salvo.
error_log("Handler de {$evento->nome()} falhou: {$e->getMessage()}");
}
}
}
}
$dispatcher = new EventDispatcher();
$dispatcher->registrar('pedido.confirmado', static function (PedidoConfirmado $e): void {
error_log("E-mail de confirmação do pedido {$e->pedidoId}");
});
$dispatcher->registrar('pedido.confirmado', static function (PedidoConfirmado $e): void {
error_log("Baixa de estoque do pedido {$e->pedidoId}");
});
Exercício 5
Desafio: implemente um segundo adaptador de repositório — PedidoRepositorioEmMemoria que 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.
Ver resposta
✓ Resposta: O clone é o que separa um fake honesto de um que mente: sem ele, o teste passa por acidente — o objeto "salvo" e o objeto em uso são o mesmo, coisa impossível com banco de verdade. O último teste existe justamente para travar esse comportamento.
<?php
declare(strict_types=1);
namespace Tests\Doubles;
use Domain\Pedido\Pedido;
use Domain\Pedido\PedidoRepositorioInterface;
/**
* Segundo adaptador da MESMA porta. O Use Case não distingue este do PDO —
* é a prova prática de que a inversão de dependência funcionou.
*/
final class PedidoRepositorioEmMemoria implements PedidoRepositorioInterface
{
/** @var array<int, Pedido> */
private array $pedidos = [];
private int $proximoId = 1;
public function salvar(Pedido $pedido): Pedido
{
if ($pedido->id === 0) {
$pedido = $pedido->comId($this->proximoId++);
}
// clone: sem isso, quem chamou continua com a MESMA instância que
// está "no banco", e alterações fora do repositório apareceriam
// magicamente lá dentro — coisa que o PDO nunca faria.
$this->pedidos[$pedido->id] = clone $pedido;
return $pedido;
}
public function buscarPorId(int $id): ?Pedido
{
return isset($this->pedidos[$id]) ? clone $this->pedidos[$id] : null;
}
/** @return Pedido[] */
public function buscarPorCliente(int $clienteId): array
{
return array_values(array_filter(
$this->pedidos,
static fn(Pedido $p): bool => $p->clienteId === $clienteId,
));
}
public function remover(int $id): bool
{
if (!isset($this->pedidos[$id])) {
return false;
}
unset($this->pedidos[$id]);
return true;
}
public function proximaIdentidade(): int
{
return $this->proximoId;
}
// ---- Apoio aos testes (fora da interface) ----
public function contar(): int { return count($this->pedidos); }
public function limpar(): void
{
$this->pedidos = [];
$this->proximoId = 1;
}
}
// ---------------------------------------- Tests/Integration/FluxoPedidoTest.php
declare(strict_types=1);
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class FluxoPedidoTest extends TestCase
{
private PedidoRepositorioEmMemoria $pedidos;
private EventDispatcher $eventos;
protected function setUp(): void
{
$this->pedidos = new PedidoRepositorioEmMemoria();
$this->eventos = new EventDispatcher();
}
#[Test]
public function fluxoCompletoDeCriacaoAteConfirmacao(): void
{
// Diferente do teste unitário com mocks: aqui os Use Cases conversam
// de verdade entre si, com estado real — só o banco é que não existe.
$produtos = new ProdutoRepositorioEmMemoria([
new Produto(1, 'Teclado', Dinheiro::deReais(349.90), estoque: 10),
]);
$criar = new CriarPedidoUseCase($this->pedidos, $produtos);
$saida = $criar->executar(new CriarPedidoInput(
clienteId: 7,
itens: [['produto_id' => 1, 'quantidade' => 2]],
));
self::assertSame(1, $this->pedidos->contar());
self::assertSame(69980, $saida->totalCentavos);
// Segundo Use Case, sobre o estado deixado pelo primeiro.
$recebidos = [];
$this->eventos->registrar('pedido.confirmado',
static function ($e) use (&$recebidos): void { $recebidos[] = $e; });
(new ConfirmarPedido($this->pedidos, $this->eventos))->executar($saida->pedidoId);
$pedido = $this->pedidos->buscarPorId($saida->pedidoId);
self::assertSame('aguardando_pagamento', $pedido->status()->value);
self::assertCount(1, $recebidos);
self::assertSame(69980, $recebidos[0]->totalCentavos);
// Os eventos foram liberados: uma segunda coleta não repete nada.
self::assertSame([], $pedido->liberarEventos());
}
#[Test]
public function buscarPorIdDevolveCopiaEnaoReferencia(): void
{
$pedido = $this->pedidos->salvar(Pedido::novo(clienteId: 7));
$copia = $this->pedidos->buscarPorId($pedido->id);
$copia->adicionarItem(produtoId: 1, quantidade: 1, preco: Dinheiro::deReais(10));
// Alterar a cópia não pode mexer no que está "persistido".
self::assertCount(0, $this->pedidos->buscarPorId($pedido->id)->itens());
}
}