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
- Uncle Bob — The SOLID Principles: https://blog.cleancoder.com/uncle-bob/2020/10/18/Solid-Relevance.html
- PHP The Right Way — Design Patterns: https://phptherightway.com/#design_patterns
- Refactoring Guru — SOLID: https://refactoring.guru/pt-br/design-patterns
- PHP Manual — Interfaces: https://www.php.net/manual/pt_BR/language.oop5.interfaces.php
- Robert C. Martin — The Single Responsibility Principle, o mais mal-entendido dos cinco: https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html
- PHP Manual — classes abstratas, o outro lado da inversao de dependencia: https://www.php.net/manual/pt_BR/language.oop5.abstract.php
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."
);
}
}
}