Clean Architecture com PHP

Clean Architecture com PHP

Um módulo de pedidos montado camada por camada, com a entidade guardando as transições de estado e a interface de repositório morando no domínio. E a pergunta que se faz arquivo por arquivo para descobrir se a regra de dependência está sendo respeitada.
PHP

• • 23 min de leitura

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

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());
    }
}
Comentários

Mais em PHP

Domain-Driven Design
Domain-Driven Design

Value Objects que recusam estado inválido no construtor, um Aggregate que…

Deploy e Ambiente de Produção
Deploy e Ambiente de Produção

NGINX com PHP-FPM, script de deploy, pipeline no GitHub Actions, Docker e logs…

Observer, Decorator e Strategy
Observer, Decorator e Strategy

Três padrões que aparecem em praticamente qualquer sistema: o Observer, que…