SOLID na Prática

SOLID na Prática

Cada um dos cinco princípios apresentado em par: o código que viola e o que corrige. E, mais útil que o nome de cada um, o sinal que denuncia a violação na leitura — a exceção no método herdado, o if que cresce a cada tipo, o new dentro do construtor.
PHP

• • 23 min de leitura

SOLID é um acrônimo para cinco princípios de design orientado a objetos formulados por Robert C. Martin. Eles não são regras rígidas — são diretrizes para escrever código que resiste ao tempo: fácil de estender, difícil de quebrar acidentalmente, e que comunica sua intenção com clareza.

Cada princípio resolve um problema específico que aparece quando sistemas crescem. Neste artigo, cada princípio é apresentado com um exemplo problemático e a versão corrigida — a diferença de qualidade fica imediatamente visível no código.

S — Single Responsibility Principle

Uma classe deve ter apenas um motivo para mudar. Se uma classe faz mais de uma coisa, mudanças em qualquer uma dessas coisas podem quebrar as outras — e os testes ficam mais difíceis de escrever.

<?php
declare(strict_types=1);

// ── PROBLEMA: UserManager faz tudo ───────────────────────────────────
class UserManager
{
    public function criarUsuario(array $dados): void
    {
        // 1. Valida dados
        if (empty($dados['email'])) throw new \InvalidArgumentException('Email obrigatório.');

        // 2. Salva no banco
        $pdo = new \PDO('mysql:host=localhost;dbname=app', 'root', '');
        $pdo->prepare('INSERT INTO usuarios (email) VALUES (?)')->execute([$dados['email']]);

        // 3. Envia email de boas-vindas
        mail($dados['email'], 'Bem-vindo!', 'Sua conta foi criada.');

        // 4. Registra log
        file_put_contents('app.log', "Usuário criado: {$dados['email']}\n", FILE_APPEND);
    }
    // Motivos para mudar: validação, acesso a dados, email, log — são 4 motivos
}

// ── SOLUÇÃO: cada classe com uma responsabilidade ─────────────────────
class ValidadorUsuario
{
    public function validar(array $dados): void
    {
        if (empty($dados['email']) || !filter_var($dados['email'], FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException('Email inválido.');
        }
        if (empty($dados['nome'])) {
            throw new \InvalidArgumentException('Nome obrigatório.');
        }
    }
}

class UsuarioRepositorio
{
    public function __construct(private readonly \PDO $pdo) {}

    public function salvar(array $dados): int
    {
        $stmt = $this->pdo->prepare('INSERT INTO usuarios (nome, email) VALUES (?, ?)');
        $stmt->execute([$dados['nome'], $dados['email']]);
        return (int) $this->pdo->lastInsertId();
    }
}

class EmailDeBoasVindas
{
    public function enviar(string $email, string $nome): void
    {
        // Em produção: usa biblioteca de email (PHPMailer, Symfony Mailer)
        mail($email, 'Bem-vindo!', "Olá {$nome}, sua conta foi criada.");
    }
}

// Orquestrador — usa as peças, mas não as implementa
class RegistroDeUsuarioService
{
    public function __construct(
        private readonly ValidadorUsuario   $validador,
        private readonly UsuarioRepositorio $repositorio,
        private readonly EmailDeBoasVindas  $emailBoasVindas,
    ) {}

    public function registrar(array $dados): int
    {
        $this->validador->validar($dados);
        $id = $this->repositorio->salvar($dados);
        $this->emailBoasVindas->enviar($dados['email'], $dados['nome']);
        return $id;
    }
}

O — Open/Closed Principle

Classes devem ser abertas para extensão e fechadas para modificação. Quando um novo requisito chega, você adiciona código — não modifica o que já funciona e está testado.

<?php
declare(strict_types=1);

// ── PROBLEMA: if/switch que cresce a cada novo tipo ───────────────────
class CalculadoraDeDesconto
{
    public function calcular(string $tipoCliente, float $total): float
    {
        if ($tipoCliente === 'vip') {
            return $total * 0.20;
        } elseif ($tipoCliente === 'fidelidade') {
            return $total * 0.10;
        } elseif ($tipoCliente === 'funcionario') {
            return $total * 0.30;
        }
        // Para cada novo tipo, modifica esta classe — viola OCP
        return 0.0;
    }
}

// ── SOLUÇÃO: polimorfismo — nova regra = nova classe ──────────────────
interface RegraDeDesconto
{
    public function calcular(float $total): float;
    public function aplicavelA(string $tipoCliente): bool;
}

class DescontoVip implements RegraDeDesconto
{
    public function calcular(float $total): float { return $total * 0.20; }
    public function aplicavelA(string $t): bool   { return $t === 'vip'; }
}

class DescontoFidelidade implements RegraDeDesconto
{
    public function calcular(float $total): float { return $total * 0.10; }
    public function aplicavelA(string $t): bool   { return $t === 'fidelidade'; }
}

class DescontoFuncionario implements RegraDeDesconto
{
    public function calcular(float $total): float { return $total * 0.30; }
    public function aplicavelA(string $t): bool   { return $t === 'funcionario'; }
}

// Calculadora fechada para modificação — aberta para extensão via injeção
class CalculadoraDeDescontoOCP
{
    /** @param RegraDeDesconto[] $regras */
    public function __construct(private readonly array $regras = []) {}

    public function calcular(string $tipoCliente, float $total): float
    {
        foreach ($this->regras as $regra) {
            if ($regra->aplicavelA($tipoCliente)) {
                return $regra->calcular($total);
            }
        }
        return 0.0;
    }
}

// Adicionar novo tipo = criar nova classe, não tocar na calculadora
class DescontoEstudante implements RegraDeDesconto
{
    public function calcular(float $total): float { return $total * 0.05; }
    public function aplicavelA(string $t): bool   { return $t === 'estudante'; }
}

$calculadora = new CalculadoraDeDescontoOCP([
    new DescontoVip(),
    new DescontoFidelidade(),
    new DescontoFuncionario(),
    new DescontoEstudante(), // nova regra — zero modificação na calculadora
]);

L — Liskov Substitution Principle

Subtipos devem ser substituíveis por seus tipos base sem alterar a corretude do programa. Em PHP prático: se você aceita uma interface, qualquer implementação deve se comportar conforme o contrato — sem surpresas, sem restrições adicionais.

<?php
declare(strict_types=1);

// ── PROBLEMA: subclasse viola o contrato ─────────────────────────────
class Retangulo
{
    public function __construct(
        protected float $largura,
        protected float $altura,
    ) {}

    public function setLargura(float $l): void { $this->largura = $l; }
    public function setAltura(float $a): void  { $this->altura  = $a; }
    public function area(): float { return $this->largura * $this->altura; }
}

class Quadrado extends Retangulo
{
    // VIOLA LSP: setLargura altera AMBAS as dimensões — surpresa para o caller
    public function setLargura(float $l): void { $this->largura = $this->altura = $l; }
    public function setAltura(float $a): void  { $this->largura = $this->altura = $a; }
}

function calcularArea(Retangulo $r): float
{
    $r->setLargura(5);
    $r->setAltura(4);
    return $r->area();
    // Para Retangulo: retorna 20 — correto
    // Para Quadrado:  retorna 16 — viola a expectativa do caller!
}

// ── SOLUÇÃO: modelar corretamente o domínio ───────────────────────────
interface Forma
{
    public function area(): float;
    public function perimetro(): float;
}

final class Retangulo2 implements Forma
{
    public function __construct(
        private readonly float $largura,
        private readonly float $altura,
    ) {}

    public function area(): float      { return $this->largura * $this->altura; }
    public function perimetro(): float { return 2 * ($this->largura + $this->altura); }
}

final class Quadrado2 implements Forma
{
    public function __construct(private readonly float $lado) {}

    public function area(): float      { return $this->lado ** 2; }
    public function perimetro(): float { return 4 * $this->lado; }
}

// Qualquer Forma pode ser substituída — sem surpresas
function imprimirInfo(Forma $f): void
{
    echo "Área: {$f->area()}, Perímetro: {$f->perimetro()}\n";
}

I — Interface Segregation Principle

Clientes não devem depender de interfaces que não usam. Interfaces gordas forçam implementações vazias ou lançam UnsupportedOperationException — sinal claro de violação.

<?php
declare(strict_types=1);

// ── PROBLEMA: interface gorda ─────────────────────────────────────────
interface Trabalhador
{
    public function trabalhar(): void;
    public function comer(): void;
    public function dormir(): void;
    public function receberSalario(): void;
}

// Robô não come nem dorme — mas é forçado a implementar
class RoboTrabalhador implements Trabalhador
{
    public function trabalhar(): void       { echo "Trabalhando...\n"; }
    public function comer(): void           { throw new \LogicException('Robôs não comem!'); }
    public function dormir(): void          { throw new \LogicException('Robôs não dormem!'); }
    public function receberSalario(): void  { /* Robô não recebe */ }
}

// ── SOLUÇÃO: interfaces coesas e focadas ──────────────────────────────
interface Trabalhavel
{
    public function trabalhar(): void;
}

interface Alimentavel
{
    public function comer(): void;
    public function dormir(): void;
}

interface Remuneravel
{
    public function receberSalario(float $valor): void;
}

// Funcionário humano implementa tudo
class FuncionarioHumano implements Trabalhavel, Alimentavel, Remuneravel
{
    public function trabalhar(): void              { echo "Trabalhando...\n"; }
    public function comer(): void                  { echo "Almoçando...\n"; }
    public function dormir(): void                 { echo "Descansando...\n"; }
    public function receberSalario(float $v): void { echo "Recebeu R$ {$v}\n"; }
}

// Robô só implementa o que faz sentido
class RoboTrabalhador2 implements Trabalhavel
{
    public function trabalhar(): void { echo "Processando...\n"; }
}

// Código que depende apenas do que precisa
function executarTurno(Trabalhavel $trabalhador): void
{
    $trabalhador->trabalhar();
    // Não sabe e não precisa saber se é humano ou robô
}

D — Dependency Inversion Principle

Módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações. A abstração não deve depender de detalhes — os detalhes devem depender da abstração.

<?php
declare(strict_types=1);

// ── PROBLEMA: acoplamento direto à implementação ──────────────────────
class ProcessadorDePedido
{
    // Acoplado ao MySQL — trocar de banco = modificar esta classe
    private \PDO $pdo;

    public function __construct()
    {
        // new dentro do construtor = dependência hardcoded
        $this->pdo = new \PDO('mysql:host=localhost;dbname=loja', 'root', '');
    }

    public function processar(int $pedidoId): void
    {
        $stmt = $this->pdo->prepare('UPDATE pedidos SET status = ? WHERE id = ?');
        $stmt->execute(['processado', $pedidoId]);
    }
}

// ── SOLUÇÃO: depender de abstrações, não de implementações ────────────
interface PedidoRepositorioInterface
{
    public function atualizarStatus(int $pedidoId, string $status): void;
    public function buscarPorId(int $id): ?array;
}

interface NotificadorInterface
{
    public function notificar(string $destinatario, string $mensagem): void;
}

// Módulo de alto nível depende apenas de interfaces
class ProcessadorDePedidoDIP
{
    public function __construct(
        private readonly PedidoRepositorioInterface $repositorio,
        private readonly NotificadorInterface       $notificador,
    ) {}

    public function processar(int $pedidoId): void
    {
        $pedido = $this->repositorio->buscarPorId($pedidoId);

        if ($pedido === null) {
            throw new \RuntimeException("Pedido #{$pedidoId} não encontrado.");
        }

        $this->repositorio->atualizarStatus($pedidoId, 'processado');
        $this->notificador->notificar($pedido['email_cliente'], "Pedido #{$pedidoId} processado.");
    }
}

// Implementações concretas — detalhes que dependem da abstração
class PedidoRepositorioPDO implements PedidoRepositorioInterface
{
    public function __construct(private readonly \PDO $pdo) {}

    public function atualizarStatus(int $id, string $status): void
    {
        $this->pdo->prepare('UPDATE pedidos SET status = ? WHERE id = ?')
                  ->execute([$status, $id]);
    }

    public function buscarPorId(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM pedidos WHERE id = ?');
        $stmt->execute([$id]);
        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
    }
}

class NotificadorEmail implements NotificadorInterface
{
    public function notificar(string $destinatario, string $mensagem): void
    {
        mail($destinatario, 'Atualização do pedido', $mensagem);
    }
}

// Fácil de testar — basta injetar mocks
class NotificadorFake implements NotificadorInterface
{
    public array $mensagensEnviadas = [];
    public function notificar(string $d, string $m): void
    {
        $this->mensagensEnviadas[] = ['destinatario' => $d, 'mensagem' => $m];
    }
}

Os cinco princípios convergem para a mesma ideia vista de ângulos diferentes: separar o que muda por motivos diferentes. O SRP separa por razão de mudança, o OCP por ponto de extensão, o LSP por contrato, o ISP por cliente e o DIP por direção da dependência. Nenhum deles é meta em si — aplicados a esmo produzem uma interface por classe e uma pasta de arquivos de dez linhas, que é a forma mais cansativa de complicar algo simples.

O sinal de violação é mais útil que o princípio: um throw new LogicException num método herdado denuncia ISP ou LSP antes de qualquer discussão teórica, e um new dentro de construtor denuncia DIP sem precisar do nome. Procurar por esses sinais no código que você já tem rende mais do que decorar as cinco letras.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Refatore o ProdutoController do Artigo 23 para aplicar SRP: extraia a validação para ProdutoValidator, a leitura do corpo para RequestReader, e a serialização para ProdutoSerializer. O controller deve apenas orquestrar.

Ver resposta

✓ Resposta: O teste de SRP é a pergunta "quantos motivos para mudar?". Trocar o formato da API mexe só no Serializer; endurecer uma regra, só no Validator; aceitar XML além de JSON, só no Reader. O controller fica intocado nos três casos.

<?php

declare(strict_types=1);

// ---------- ANTES: o controller fazia tudo ------------------------------
// class ProdutoController {
//     public function criar(): string {
//         $dados = json_decode(file_get_contents('php://input'), true);  // I/O
//         if (trim($dados['nome'] ?? '') === '') { ... }                 // validação
//         $stmt = $this->pdo->prepare('INSERT ...');                     // persistência
//         return json_encode(['id' => ..., 'nome' => ...]);              // serialização
//     }
// }
// Quatro motivos para mudar essa classe: formato de entrada, regra de
// validação, esquema do banco e formato de saída. SRP diz: um motivo.

// ---------------------------------------- src/Http/RequestReader.php
final class RequestReader
{
    /** Só sabe transformar corpo HTTP em array. */
    public function corpoJson(): array
    {
        $bruto = file_get_contents('php://input');

        if ($bruto === false || trim($bruto) === '') {
            return [];
        }

        try {
            $dados = json_decode($bruto, true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException $e) {
            throw new InvalidArgumentException('Corpo JSON malformado.', 0, $e);
        }

        return is_array($dados) ? $dados : [];
    }
}

// ---------------------------------------- src/Validation/ProdutoValidator.php
final class ProdutoValidator
{
    /** @return array<string, string> campo => erro; vazio = válido */
    public function validar(array $dados, bool $parcial = false): array
    {
        $erros = [];

        if (!$parcial || array_key_exists('nome', $dados)) {
            $nome = trim((string) ($dados['nome'] ?? ''));

            if ($nome === '') {
                $erros['nome'] = 'Obrigatório.';
            } elseif (mb_strlen($nome) > 160) {
                $erros['nome'] = 'Máximo de 160 caracteres.';
            }
        }

        if (!$parcial || array_key_exists('preco', $dados)) {
            $preco = $dados['preco'] ?? null;

            if (!is_numeric($preco)) {
                $erros['preco'] = 'Precisa ser numérico.';
            } elseif ((float) $preco <= 0) {
                $erros['preco'] = 'Precisa ser maior que zero.';
            }
        }

        if (isset($dados['estoque']) && (!is_int($dados['estoque']) || $dados['estoque'] < 0)) {
            $erros['estoque'] = 'Precisa ser inteiro não negativo.';
        }

        return $erros;
    }
}

// ---------------------------------------- src/Http/ProdutoSerializer.php
final class ProdutoSerializer
{
    /** Só sabe transformar entidade em array de saída. */
    public function um(Produto $produto): array
    {
        return [
            'id'    => $produto->id,
            'nome'  => $produto->nome,
            'preco' => [
                'centavos'  => $produto->precoCentavos,
                'formatado' => 'R$ ' . number_format($produto->precoCentavos / 100, 2, ',', '.'),
            ],
            'estoque' => $produto->estoque,
        ];
    }

    /** @param Produto[] $produtos */
    public function colecao(array $produtos, array $meta = []): array
    {
        return ['dados' => array_map($this->um(...), $produtos)] + ($meta ? ['meta' => $meta] : []);
    }
}

// ---------------------------------------- src/Http/ProdutoController.php
final class ProdutoController
{
    public function __construct(
        private readonly RequestReader $reader,
        private readonly ProdutoValidator $validator,
        private readonly ProdutoSerializer $serializer,
        private readonly ProdutoRepositorioInterface $repositorio,
    ) {}

    /** Orquestra: lê, valida, delega, serializa. Nenhuma regra própria. */
    public function criar(): array
    {
        $dados = $this->reader->corpoJson();

        if ($erros = $this->validator->validar($dados)) {
            return ['status' => 422, 'corpo' => ['erro' => 'validacao', 'campos' => $erros]];
        }

        $produto = $this->repositorio->salvar(
            Produto::novo(
                nome: trim($dados['nome']),
                precoCentavos: (int) round(((float) $dados['preco']) * 100),
                estoque: (int) ($dados['estoque'] ?? 0),
            ),
        );

        return ['status' => 201, 'corpo' => $this->serializer->um($produto)];
    }
}

Exercício 2

Aplique OCP ao sistema de notificações: crie uma interface CanalDeNotificacao com método enviar(string $destinatario, string $mensagem): void e implemente NotificacaoEmail, NotificacaoSMS e NotificacaoSlack. O NotificacaoService deve aceitar um array de canais.

Ver resposta

✓ Resposta: O sinal de que o OCP foi respeitado é não existir switch/match por tipo de canal dentro do serviço. Repare que a regra dos 160 caracteres mora no SMS: colocá-la no serviço seria um vazamento de detalhe de um canal para quem coordena todos.

<?php

declare(strict_types=1);

interface CanalDeNotificacao
{
    public function enviar(string $destinatario, string $mensagem): void;

    public function nome(): string;

    /** Nem todo canal serve para todo destinatário. */
    public function suporta(string $destinatario): bool;
}

final class NotificacaoEmail implements CanalDeNotificacao
{
    public function enviar(string $destinatario, string $mensagem): void
    {
        // mail() de verdade, ou um mailer injetado.
        error_log("[email] para {$destinatario}: {$mensagem}");
    }

    public function nome(): string { return 'email'; }

    public function suporta(string $destinatario): bool
    {
        return filter_var($destinatario, FILTER_VALIDATE_EMAIL) !== false;
    }
}

final class NotificacaoSMS implements CanalDeNotificacao
{
    private const LIMITE = 160;

    public function enviar(string $destinatario, string $mensagem): void
    {
        // A regra de truncar é DESTE canal — não do serviço.
        $texto = mb_strlen($mensagem) > self::LIMITE
            ? mb_substr($mensagem, 0, self::LIMITE - 1) . '…'
            : $mensagem;

        error_log("[sms] para {$destinatario}: {$texto}");
    }

    public function nome(): string { return 'sms'; }

    public function suporta(string $destinatario): bool
    {
        return (bool) preg_match('/^\+?\d{10,15}$/', preg_replace('/\D/', '', $destinatario));
    }
}

final class NotificacaoSlack implements CanalDeNotificacao
{
    public function __construct(private readonly string $webhook) {}

    public function enviar(string $destinatario, string $mensagem): void
    {
        $payload = json_encode(['channel' => $destinatario, 'text' => $mensagem]);

        error_log("[slack] POST {$this->webhook} — {$payload}");
    }

    public function nome(): string { return 'slack'; }

    public function suporta(string $destinatario): bool
    {
        return str_starts_with($destinatario, '#') || str_starts_with($destinatario, '@');
    }
}

final class NotificacaoService
{
    /** @param CanalDeNotificacao[] $canais */
    public function __construct(private readonly array $canais) {}

    /** @return array<string, bool> canal => enviou */
    public function notificar(string $destinatario, string $mensagem): array
    {
        $resultado = [];

        foreach ($this->canais as $canal) {
            if (!$canal->suporta($destinatario)) {
                continue;
            }

            try {
                $canal->enviar($destinatario, $mensagem);
                $resultado[$canal->nome()] = true;
            } catch (Throwable $e) {
                // Um canal fora do ar não pode derrubar os outros.
                error_log("Falha no canal {$canal->nome()}: {$e->getMessage()}");
                $resultado[$canal->nome()] = false;
            }
        }

        return $resultado;
    }
}

// Canal novo = classe nova. O NotificacaoService NUNCA muda — é isso o
// "fechado para modificação, aberto para extensão".
$servico = new NotificacaoService([
    new NotificacaoEmail(),
    new NotificacaoSMS(),
    new NotificacaoSlack('https://hooks.slack.com/services/XXX'),
]);

print_r($servico->notificar('ana@exemplo.com', 'Seu pedido foi enviado.'));
// [email] => 1

print_r($servico->notificar('#alertas', 'Fila acima de 1000 jobs.'));
// [slack] => 1

// Amanhã, WhatsApp: basta implementar a interface e acrescentar ao array.
// Nenhum `if ($tipo === 'whatsapp')` em lugar nenhum.

Exercício 3

Identifique a violação de LSP no seguinte cenário: ColecaoOrdenada extends Colecao onde Colecao::adicionar() aceita qualquer posição e ColecaoOrdenada::adicionar() ignora a posição e mantém ordenação. Proponha uma solução com interfaces separadas.

Ver resposta

✓ Resposta: LSP não se quebra com tipo errado — se quebra com promessa errada: mesma assinatura, comportamento incompatível. O cheiro clássico é o método sobrescrito que ignora um parâmetro ou lança BadMethodCallException. A correção quase sempre é trocar herança por interfaces menores.

<?php

declare(strict_types=1);

// ---------- A VIOLAÇÃO ----------------------------------------------------
class Colecao
{
    protected array $itens = [];

    /** Contrato: o item fica NA posição pedida. */
    public function adicionar(mixed $item, ?int $posicao = null): void
    {
        if ($posicao === null) {
            $this->itens[] = $item;
            return;
        }

        array_splice($this->itens, $posicao, 0, [$item]);
    }

    public function em(int $posicao): mixed { return $this->itens[$posicao] ?? null; }

    public function todos(): array { return $this->itens; }
}

class ColecaoOrdenada extends Colecao
{
    public function adicionar(mixed $item, ?int $posicao = null): void
    {
        // ⚠️ IGNORA a posição e reordena. Assinatura idêntica, comportamento
        // diferente: quem recebe uma Colecao não pode mais confiar no contrato.
        $this->itens[] = $item;
        sort($this->itens);
    }
}

// O código cliente quebra sem nenhum erro de tipo:
function inserirNoTopo(Colecao $colecao, mixed $item): mixed
{
    $colecao->adicionar($item, 0);

    return $colecao->em(0);   // o contrato promete que é $item
}

var_dump(inserirNoTopo(new Colecao(), 'zebra'));           // "zebra" ✅
var_dump(inserirNoTopo(new ColecaoOrdenada(), 'zebra'));   // outro item ❌

// ---------- A CORREÇÃO: separar as capacidades ---------------------------
// A pergunta certa não é "ColecaoOrdenada é uma Colecao?", e sim "ela cumpre
// TUDO o que Colecao promete?". Não cumpre — então não deve herdar dela.

interface ColecaoInterface
{
    public function adicionar(mixed $item): void;

    public function em(int $posicao): mixed;

    /** @return mixed[] */
    public function todos(): array;

    public function contar(): int;
}

/** Só quem realmente respeita posição declara isto. */
interface PosicionavelInterface
{
    public function inserirEm(int $posicao, mixed $item): void;

    public function mover(int $de, int $para): void;
}

final class ListaSimples implements ColecaoInterface, PosicionavelInterface
{
    private array $itens = [];

    public function adicionar(mixed $item): void { $this->itens[] = $item; }

    public function inserirEm(int $posicao, mixed $item): void
    {
        array_splice($this->itens, max(0, $posicao), 0, [$item]);
    }

    public function mover(int $de, int $para): void
    {
        if (!array_key_exists($de, $this->itens)) {
            throw new OutOfBoundsException("Posição {$de} não existe.");
        }

        $item = array_splice($this->itens, $de, 1)[0];
        array_splice($this->itens, $para, 0, [$item]);
    }

    public function em(int $posicao): mixed { return $this->itens[$posicao] ?? null; }

    public function todos(): array { return $this->itens; }

    public function contar(): int { return count($this->itens); }
}

/** Não implementa Posicionavel: a ordem é dela, e ela não mente sobre isso. */
final class ColecaoOrdenadaCorreta implements ColecaoInterface
{
    private array $itens = [];

    public function __construct(private readonly ?Closure $comparador = null) {}

    public function adicionar(mixed $item): void
    {
        $this->itens[] = $item;

        $this->comparador !== null
            ? usort($this->itens, $this->comparador)
            : sort($this->itens);
    }

    public function em(int $posicao): mixed { return $this->itens[$posicao] ?? null; }

    public function todos(): array { return $this->itens; }

    public function contar(): int { return count($this->itens); }
}

// Agora o tipo do parâmetro diz a verdade: esta função exige posicionamento,
// e ColecaoOrdenadaCorreta simplesmente não é aceita — erro em tempo de
// análise, não surpresa em produção.
function inserirNoTopoSeguro(PosicionavelInterface&ColecaoInterface $c, mixed $item): mixed
{
    $c->inserirEm(0, $item);

    return $c->em(0);
}

var_dump(inserirNoTopoSeguro(new ListaSimples(), 'zebra'));   // "zebra" ✅
// inserirNoTopoSeguro(new ColecaoOrdenadaCorreta(), 'x');    // TypeError ✅

Exercício 4

Refatore o MigrationRunner aplicando DIP: crie uma interface LoggerInterface com método log(string $mensagem): void e injete-a no runner em vez de usar echo diretamente. Implemente LoggerConsole e LoggerArray (para testes).

Ver resposta

✓ Resposta: O ganho concreto do DIP aparece no teste: com echo cravado, verificar a saída exigiria ob_start() e análise de string. Com a interface, a verificação é uma asserção sobre um array. O LoggerNulo como padrão evita o if de "tem logger?".

<?php

declare(strict_types=1);

interface LoggerInterface
{
    public function log(string $mensagem): void;
}

final class LoggerConsole implements LoggerInterface
{
    public function log(string $mensagem): void
    {
        echo $mensagem, PHP_EOL;
    }
}

/** Guarda em memória: é o que torna o runner testável sem capturar stdout. */
final class LoggerArray implements LoggerInterface
{
    /** @var string[] */
    private array $linhas = [];

    public function log(string $mensagem): void
    {
        $this->linhas[] = $mensagem;
    }

    /** @return string[] */
    public function linhas(): array { return $this->linhas; }

    public function contem(string $trecho): bool
    {
        foreach ($this->linhas as $linha) {
            if (str_contains($linha, $trecho)) {
                return true;
            }
        }

        return false;
    }
}

final class LoggerNulo implements LoggerInterface
{
    // Null Object: evita `if ($this->logger !== null)` espalhado.
    public function log(string $mensagem): void {}
}

final class MigrationRunner
{
    public function __construct(
        private readonly PDO $pdo,
        private readonly array $migrations,
        // Depende da ABSTRAÇÃO. Antes, o `echo` amarrava o runner ao terminal:
        // não dava para usar em fila, em teste ou gravando em arquivo.
        private readonly LoggerInterface $logger = new LoggerNulo(),
    ) {}

    public function migrate(): int
    {
        $aplicadas = 0;

        foreach ($this->pendentes() as $nome) {
            $inicio = hrtime(true);

            $this->migrations[$nome]->up($this->pdo);
            $this->registrar($nome);

            $this->logger->log(sprintf('✅ %s (%.2f ms)',
                $nome, (hrtime(true) - $inicio) / 1_000_000));

            $aplicadas++;
        }

        $this->logger->log($aplicadas === 0
            ? 'Nada a fazer: tudo já aplicado.'
            : "{$aplicadas} migration(s) aplicada(s).");

        return $aplicadas;
    }

    private function pendentes(): array
    {
        $executadas = $this->pdo->query('SELECT migration FROM migrations')
                                ->fetchAll(PDO::FETCH_COLUMN);

        return array_diff(array_keys($this->migrations), $executadas);
    }

    private function registrar(string $nome): void
    {
        $stmt = $this->pdo->prepare('INSERT INTO migrations (migration, lote) VALUES (:m, 1)');
        $stmt->execute([':m' => $nome]);
    }
}

// ---------- Produção ------------------------------------------------------
(new MigrationRunner($pdo, $migrations, new LoggerConsole()))->migrate();

// ---------- Teste: sem capturar saída, sem output buffering --------------
$logger = new LoggerArray();
$runner = new MigrationRunner($pdoSqlite, $migrations, $logger);

$runner->migrate();

assert($logger->contem('Migration001Usuarios'));
assert($logger->contem('3 migration(s) aplicada(s).'));

$runner->migrate();
assert($logger->contem('Nada a fazer'));

Exercício 5

Desafio: escreva testes unitários que provam os princípios SOLID no seu código. Por exemplo: para DIP, o teste injeta um mock e verifica que ProcessadorDePedido não instancia nada diretamente. Para OCP, adicione uma nova RegraDeDesconto e verifique que os testes existentes continuam passando.

Ver resposta

✓ Resposta: Testes estruturais por Reflection são incomuns e devem ser usados com parcimônia — mas aqui fazem sentido: eles transformam um princípio de projeto em regra executável. O teste de OCP é o mais didático: ele prova que a regra nova entrou sem alterar os resultados antigos.

<?php

declare(strict_types=1);

namespace Tests\Unit\Solid;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;

final class PrincipiosSolidTest extends TestCase
{
    // ---------- DIP -------------------------------------------------------
    #[Test]
    public function processadorDePedidoAceitaDublesEmVezDeInstanciarDependencias(): void
    {
        // Se a classe desse `new` internamente, seria impossível trocar por
        // mock — e este teste não compilaria.
        $repositorio = $this->createMock(PedidoRepositorioInterface::class);
        $pagamento   = $this->createMock(GatewayPagamentoInterface::class);

        $repositorio->expects(self::once())->method('salvar');
        $pagamento->expects(self::once())->method('cobrar')->willReturn('AUT-1');

        (new ProcessadorDePedido($repositorio, $pagamento))->processar(pedidoId: 1, valor: 100.0);
    }

    #[Test]
    public function processadorNaoInstanciaColaboradoresInternamente(): void
    {
        // Verificação estrutural: `new` de colaborador dentro do arquivo é o
        // acoplamento que o DIP proíbe. Falha se alguém "simplificar" o código.
        $arquivo = (new ReflectionClass(ProcessadorDePedido::class))->getFileName();
        $fonte = file_get_contents($arquivo);

        foreach (['new PedidoRepositorioPDO', 'new GatewayStripe', 'new PDO('] as $proibido) {
            self::assertStringNotContainsString(
                $proibido, $fonte,
                "ProcessadorDePedido não pode instanciar {$proibido} — injete."
            );
        }
    }

    #[Test]
    public function todasAsDependenciasDoConstrutorSaoAbstracoes(): void
    {
        $construtor = (new ReflectionClass(ProcessadorDePedido::class))->getConstructor();

        foreach ($construtor->getParameters() as $parametro) {
            $tipo = $parametro->getType();

            if ($tipo === null || $tipo->isBuiltin()) {
                continue;   // escalares são configuração, não dependência
            }

            $reflexaoTipo = new ReflectionClass($tipo->getName());

            self::assertTrue(
                $reflexaoTipo->isInterface() || $reflexaoTipo->isAbstract(),
                "{$parametro->getName()} depende da classe concreta "
                . "{$tipo->getName()} — deveria depender de uma abstração."
            );
        }
    }

    // ---------- OCP -------------------------------------------------------
    #[Test]
    public function regraDeDescontoNovaNaoQuebraAsExistentes(): void
    {
        $calculadora = new CalculadoraDeDesconto([
            new DescontoPrimeiraCompra(),
            new DescontoAcimaDe(500.00, 0.10),
        ]);

        self::assertSame(90.00, $calculadora->aplicar(100.00, ['primeira_compra' => true]));
        self::assertSame(540.00, $calculadora->aplicar(600.00, []));

        // Regra NOVA: só se acrescenta ao array. A calculadora não muda.
        $comCupom = new CalculadoraDeDesconto([
            new DescontoPrimeiraCompra(),
            new DescontoAcimaDe(500.00, 0.10),
            new DescontoCupom('BLACK20', 0.20),
        ]);

        // Os casos antigos continuam idênticos — é isso que o teste prova.
        self::assertSame(90.00, $comCupom->aplicar(100.00, ['primeira_compra' => true]));
        self::assertSame(540.00, $comCupom->aplicar(600.00, []));

        // E o novo funciona.
        self::assertSame(80.00, $comCupom->aplicar(100.00, ['cupom' => 'BLACK20']));
    }

    #[Test]
    public function calculadoraNaoTemCondicionalPorTipoDeRegra(): void
    {
        $fonte = file_get_contents(
            (new ReflectionClass(CalculadoraDeDesconto::class))->getFileName()
        );

        // `instanceof` numa regra concreta é o cheiro de OCP violado.
        self::assertDoesNotMatchRegularExpression(
            '/instanceof\s+Desconto[A-Z]/', $fonte,
            'A calculadora não deve conhecer regras concretas.'
        );
    }

    // ---------- LSP -------------------------------------------------------
    #[Test]
    public function todaImplementacaoRespeitaOContratoDaInterface(): void
    {
        // O MESMO teste roda contra todas as implementações: é a definição
        // prática de substituibilidade.
        $implementacoes = [
            new ColecaoOrdenadaCorreta(),
            new ListaSimples(),
        ];

        foreach ($implementacoes as $colecao) {
            $colecao->adicionar('b');
            $colecao->adicionar('a');

            self::assertSame(2, $colecao->contar(), $colecao::class . ' quebrou contar()');
            self::assertContains('a', $colecao->todos(), $colecao::class . ' perdeu um item');
        }
    }

    // ---------- ISP -------------------------------------------------------
    #[Test]
    public function nenhumMetodoImplementadoLancaBadMethodCall(): void
    {
        foreach ([AuditoriaRepositorio::class, ProdutoRepositorio::class] as $classe) {
            $fonte = file_get_contents((new ReflectionClass($classe))->getFileName());

            self::assertStringNotContainsString(
                'BadMethodCallException', $fonte,
                "{$classe} implementa método que não sabe cumprir — fatie a interface."
            );
        }
    }
}
Comentários

Mais em PHP

Dominando o PHP
Dominando o PHP

A série começa pelo terreno: o que a linguagem é hoje, onde ela realmente roda…

Projeto Final Parte 1: Planejamento e Arquitetura
Projeto Final Parte 1: Planejamento e Arquitetura

Escopo, decisões de arquitetura com justificativa, o schema completo em…

Autenticação e Autorização no Laravel
Autenticação e Autorização no Laravel

Login manual com Auth::attempt, middleware, Gates, Policies e o pacote da…