PHP

Clean Architecture com PHP Já leu

9 min de leitura

Clean Architecture com PHP
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 ond

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

  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().

  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.

  3. Escreva testes unitários para CriarPedidoUseCase: mock de PedidoRepositorioInterface e ProdutoRepositorioInterface. Teste: produto não encontrado, estoque insuficiente, pedido criado com sucesso.

  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.

  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.


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/
Comentários

Mais em PHP

Upload de Arquivos
Upload de Arquivos

Upload de arquivos parece simples — e na superfície é. Mas quando você consid...

Domain-Driven Design
Domain-Driven Design

Domain-Driven Design (DDD) é uma abordagem para desenvolver software complexo...

Configurando o Ambiente de Desenvolvimento
Configurando o Ambiente de Desenvolvimento

Antes de escrever qualquer programa, voc&ecirc; precisa de um ambiente onde o...