Ir para o conteúdo principal
Programação12 min de leitura

Princípios SOLID com exemplos simples em sistemas reais

Aprenda os cinco princípios SOLID com exemplos em TypeScript aplicados a cadastros, descontos, consultas e notificações. Entenda quando separar responsabilidades e quando manter o código simples.

M
Publicado em
Revisado em
Princípios SOLID com exemplos simples em sistemas reais
Síntese Executiva & Principais Pontos

Princípios SOLID: exemplos simples aplicados a sistemas reais

Princípios SOLID: exemplos simples aplicados a sistemas reais

Entenda os cinco princípios e veja como aplicá-los em cadastros, pagamentos e integrações para facilitar a manutenção de sites e sistemas.

Uma alteração no envio de e-mails quebra o cadastro. Um novo desconto exige mexer em todo o checkout. Trocar o banco de dados obriga a revisar regras comerciais. Esses problemas têm algo em comum: partes do sistema dependem umas das outras mais do que precisam.

Os princípios SOLID ajudam a avaliar essas dependências e a organizar responsabilidades. Para entender como funcionam, vamos acompanhar exemplos em TypeScript ligados a uma loja virtual, sem transformar cada funcionalidade em uma coleção de camadas.

O que são os princípios SOLID e quais problemas ajudam a resolver?

SOLID reúne cinco princípios de projeto associados à programação orientada a objetos. Eles orientam a organização de classes, interfaces e dependências, mas não definem uma arquitetura completa.

S — SRP: Single Responsibility PrinciplePrincípio da responsabilidade única: reunir responsabilidades que mudam pelo mesmo motivo.O — OCP: Open/Closed PrinciplePrincípio aberto e fechado: permitir extensões sem alterar o núcleo estável correspondente.L — LSP: Liskov Substitution PrinciplePrincípio da substituição de Liskov: preservar o comportamento contratado ao substituir implementações.I — ISP: Interface Segregation PrinciplePrincípio da segregação de interfaces: oferecer contratos adequados às necessidades de cada consumidor.D — DIP: Dependency Inversion PrinciplePrincípio da inversão de dependência: fazer políticas de negócio e detalhes externos dependerem de abstrações apropriadas.

Imagine uma classe Loja que valida pedidos, calcula descontos, executa SQL e envia e-mails. Mudanças comerciais e mudanças de infraestrutura atingem o mesmo componente. Isso amplia o esforço de revisão e pode provocar regressões em comportamentos aparentemente distantes.

Duas ideias ajudam a reconhecer esse cenário. Coesão indica quanto as responsabilidades de um componente estão relacionadas. Acoplamento descreve sua dependência de outros componentes. Buscamos aproximar o que faz sentido junto e reduzir dependências desnecessárias.

O contrato define o que um componente oferece: entradas aceitas, resultados e comportamentos de erro. Nos exemplos, interface declara esse contrato, implements indica sua implementação e Promise representa um resultado assíncrono. As interfaces do TypeScript são verificadas durante a análise de tipos; elas não validam dados externos em tempo de execução.

Essa discussão complementa as práticas de Clean Code: a organização deve facilitar a compreensão e as mudanças reais. SOLID, sozinho, não garante desempenho, segurança nem ausência de erros.

SRP: separe responsabilidades que mudam por motivos diferentes

O princípio da responsabilidade única trata dos motivos de mudança. Uma classe pode ter vários métodos e continuar coesa. Também pode ter um único método que mistura responsabilidades de equipes, regras e integrações diferentes.

Considere este cadastro, apresentado em pseudocódigo:

class CadastroCliente {
  cadastrar(dados) {
    validarDados(dados);
    executarSQL(dados);
    enviarEmailDeBoasVindas(dados);
  }
}

Uma nova regra para aceitar clientes, uma alteração no banco e uma troca de provedor de e-mail exigem mudanças no mesmo lugar. A sequência é curta, mas concentra decisões que evoluem por razões distintas.

Uma separação possível em TypeScript é:

type Cliente = { nome: string; email: string };

interface ValidadorCliente {
  validar(cliente: Cliente): void;
}

interface RepositorioClientes {
  salvar(cliente: Cliente): Promise<void>;
}

interface BoasVindas {
  enviar(cliente: Cliente): Promise<void>;
}

class CadastroCliente {
  constructor(
    private validador: ValidadorCliente,
    private clientes: RepositorioClientes,
    private boasVindas: BoasVindas
  ) {}

  async cadastrar(cliente: Cliente): Promise<void> {
    this.validador.validar(cliente);
    await this.clientes.salvar(cliente);
    await this.boasVindas.enviar(cliente);
  }
}

Agora, o validador concentra as regras de entrada. O repositório cuida do armazenamento. O componente de boas-vindas conhece o envio da mensagem. O serviço de cadastro coordena a sequência, que também é uma responsabilidade.

Isso permite escrever testes automatizados para regras de negócio sem depender de um banco ou disparar e-mails reais. Também permite verificar se o serviço interrompe o cadastro quando a validação falha.

Existe uma decisão adicional: se o cliente for salvo e o envio falhar, o cadastro continuará existindo. Separar componentes não resolve essa política. A aplicação precisa definir se haverá nova tentativa, registro da falha ou processamento posterior da mensagem.

Use a separação quando houver responsabilidades distintas que justifiquem o custo. Criar um componente para cada linha não é um objetivo do SRP.

OCP: adicione comportamentos sem alterar regras estáveis

O princípio aberto e fechado busca proteger uma parte estável do código das variações que ela deve aceitar. No checkout, o cálculo do total pode permanecer igual enquanto as políticas de desconto evoluem.

Uma implementação inicial pode usar condicionais para cupom, campanha e cliente recorrente. Isso pode funcionar bem. O problema aparece quando cada promoção exige modificar um bloco central que acumula regras independentes.

interface PoliticaDesconto {
  // Recebe e devolve valores em centavos.
  // O desconto deve ficar entre zero e o subtotal.
  calcular(subtotal: number): number;
}

class DescontoCupom implements PoliticaDesconto {
  calcular(subtotal: number): number {
    return Math.round(subtotal * 0.10);
  }
}

class DescontoClienteRecorrente implements PoliticaDesconto {
  calcular(subtotal: number): number {
    return Math.round(subtotal * 0.05);
  }
}

class CalculadorTotal {
  constructor(private desconto: PoliticaDesconto) {}

  calcular(subtotal: number): number {
    return subtotal - this.desconto.calcular(subtotal);
  }
}

const calculador = new CalculadorTotal(
  new DescontoClienteRecorrente()
);

calculador.calcular(10000); // 9500 centavos

As porcentagens são ilustrativas. O exemplo assume um subtotal não negativo em centavos inteiros e uma única política por cálculo. Elegibilidade, combinação de promoções e critérios de arredondamento precisam seguir as regras do negócio.

Ao adicionar DescontoClienteRecorrente, não precisamos mudar o algoritmo de CalculadorTotal. Essa organização é uma aplicação do padrão Strategy: comportamentos alternativos seguem um contrato comum.

A configuração que escolhe a política ainda pode mudar. OCP não significa que nenhum arquivo será editado, nem impede corrigir ou refatorar código existente. Ele delimita qual parte deve permanecer estável diante de determinada variação.

Se existe apenas uma regra simples, estável e sem alternativas concretas, uma função direta pode ser suficiente. O ponto de extensão deve responder a uma necessidade observada.

LSP: preserve o comportamento esperado ao trocar implementações

O princípio da substituição de Liskov exige mais do que métodos com os mesmos nomes e tipos. Uma implementação substituta precisa preservar as garantias usadas pelo consumidor.

Considere um serviço que consulta produtos:

type Produto = { id: string; nome: string };

interface ConsultaProdutos {
  // ID não vazio. Retorna null quando não encontra.
  // Falhas de infraestrutura podem rejeitar a operação.
  buscarPorId(id: string): Promise<Produto | null>;
}

class ProdutosEmMemoria implements ConsultaProdutos {
  constructor(private produtos: Produto[]) {}

  async buscarPorId(id: string): Promise<Produto | null> {
    return this.produtos.find(p => p.id === id) ?? null;
  }
}

Um repositório SQL deve devolver o produto encontrado ou null quando a consulta não retornar registros. A versão em memória acima segue essa mesma regra. Assim, o serviço consumidor consegue tratar a ausência sem conhecer o armazenamento.

Uma implementação que lança Error('Produto inexistente') quando não encontra o registro quebra esse contrato. O tipo de retorno, sozinho, não impede esse comportamento em TypeScript.

Também haveria um problema se o contrato aceitasse qualquer identificador não vazio e um novo adaptador passasse a aceitar apenas UUIDs. Ele estaria fortalecendo uma pré-condição. Se prometesse devolver o produto encontrado, mas retornasse null para itens existentes por uma limitação silenciosa, estaria enfraquecendo uma pós-condição.

Restrições de fornecedores precisam ser tratadas explicitamente na integração ou refletidas em uma revisão consciente do contrato. Não devem aparecer como surpresa ao trocar implementações.

Testes de contrato ajudam: execute as mesmas verificações contra as versões SQL e em memória, usando dados preparados para cada uma. Confira produto existente, produto ausente e demais garantias documentadas. Os adaptadores ainda precisam de testes próprios para suas particularidades.

ISP: ofereça interfaces adequadas a cada consumidor

O princípio da segregação de interfaces diz que consumidores não devem depender de operações que não utilizam. Isso também evita obrigar implementações a oferecer capacidades que não possuem.

Em uma área administrativa, uma interface Documento com métodos de leitura, edição e exportação pode parecer conveniente. Porém, um relatório fechado não permite edição. Implementar editar() apenas para lançar um erro permanente indica que o contrato pode estar amplo demais.

interface LeituraDocumento {
  ler(): string;
}

interface EdicaoDocumento {
  editar(conteudo: string): void;
}

interface ExportacaoDocumento {
  exportar(): Uint8Array;
}

function exibirDocumento(documento: LeituraDocumento): string {
  return documento.ler();
}

function atualizarRascunho(
  documento: EdicaoDocumento,
  conteudo: string
): void {
  documento.editar(conteudo);
}

Uma classe de relatório fechado pode implementar LeituraDocumento e ExportacaoDocumento. Um rascunho editável pode implementar os três contratos. A tela de visualização recebe apenas a capacidade de leitura.

A separação acompanha papéis reais dos consumidores. Ela não determina que toda interface deva ter um único método: operações usadas juntas e com finalidade coesa podem pertencer ao mesmo contrato.

Também não substitui autorização. Declarar uma interface de edição não verifica se o usuário tem permissão para alterar um documento; essa regra precisa ser aplicada pelo sistema.

DIP: faça regras de negócio dependerem de abstrações

O princípio da inversão de dependência evita que políticas de negócio dependam diretamente de detalhes como bibliotecas de e-mail, clientes HTTP ou SDKs de pagamento. A abstração deve expressar uma necessidade da aplicação.

Em um fluxo de pedidos, a regra precisa solicitar uma notificação de confirmação. Ela não precisa conhecer tokens, cabeçalhos HTTP ou formatos específicos de um fornecedor.

interface NotificadorPedido {
  enviarConfirmacao(pedidoId: string): Promise<void>;
}

class ComunicarPedidoConfirmado {
  constructor(private notificador: NotificadorPedido) {}

  async executar(pedidoId: string): Promise<void> {
    await this.notificador.enviarConfirmacao(pedidoId);
  }
}

class NotificadorEmMemoria implements NotificadorPedido {
  readonly enviados: string[] = [];

  async enviarConfirmacao(pedidoId: string): Promise<void> {
    this.enviados.push(pedidoId);
  }
}

O trecho isola apenas a comunicação posterior à confirmação. Nos testes, NotificadorEmMemoria permite conferir quais pedidos geraram solicitações de envio. Em produção, um adaptador de e-mail implementaria o mesmo contrato e traduziria a operação para o serviço externo.

Essa direção importa: o contrato pertence às necessidades da aplicação; o adaptador se ajusta a ele. Copiar todos os métodos do SDK para uma interface própria pode manter o acoplamento conceitual ao fornecedor.

Injeção de dependências é uma técnica; inversão de dependência é um princípio. Receber uma dependência pelo construtor ajuda a fornecê-la de fora. Entretanto, receber diretamente uma classe específica de um fornecedor ainda pode manter a regra de negócio dependente desse detalhe.

O ponto de composição da aplicação instancia os adaptadores e os fornece aos serviços. Não é obrigatório usar um contêiner de injeção: essa montagem pode ser explícita.

Nas integrações, também é preciso acompanhar falhas e latência. Uma estratégia de observabilidade com métricas, logs e rastreamento ajuda a investigar o que acontece entre a aplicação e os serviços externos.

Como os princípios SOLID se combinam em um fluxo de pedidos

Os princípios SOLID na prática costumam aparecer juntos. Ao separar a notificação do cálculo comercial, por exemplo, podemos melhorar a responsabilidade única e criar uma fronteira útil para a inversão de dependência.

Um fluxo compacto recebe o pedido, calcula seu total, salva a confirmação e solicita a notificação. Suas dependências podem ser organizadas assim:

ConfirmarPedido
  |-- CalculadorTotal -- PoliticaDesconto
  |-- GravacaoPedidos
  |-- NotificadorPedido

Composição da aplicação:
  GravacaoPedidos    <- adaptador SQL
  NotificadorPedido <- adaptador de e-mail

No esquema, o serviço depende das operações de que precisa. A composição fornece as implementações concretas. Uma tela de consulta pode receber outro contrato, voltado apenas à leitura de pedidos.

Decisões de projeto no fluxo de confirmação
PrincípioAplicação no exemplo
SRPCálculo comercial, armazenamento e comunicação têm responsabilidades separadas.
OCPUma política de desconto pode ser acrescentada sem alterar o cálculo central.
LSPAdaptadores preservam os resultados e os comportamentos de erro contratados.
ISPO fluxo recebe a capacidade de gravação, sem depender de operações administrativas que não usa.
DIPO serviço depende de contratos da aplicação, implementados pelos adaptadores externos.

É preciso definir o significado de sucesso em cada fronteira. Se uma implementação de notificação promete entrega e outra promete apenas entrada em uma fila, elas não oferecem a mesma garantia. O contrato deve deixar essa diferença clara.

Salvar um pedido e enviar uma mensagem também não formam automaticamente uma operação atômica. Se houver necessidade de recuperação após falhas, avalie mecanismos como registro persistente da notificação pendente e novas tentativas, conforme os requisitos.

A quantidade de componentes deve acompanhar o problema. Não é necessário criar uma estrutura extensa para demonstrar cada letra da sigla.

Quando aplicar SOLID e quando simplificar

Uma página institucional com conteúdo e um formulário simples costuma ter necessidades diferentes de um portal que integra CRM, pagamentos e regras comerciais. O segundo cenário oferece mais pontos de mudança e mais fronteiras externas para administrar.

Mesmo em um sistema pequeno, separar uma integração instável pode ser útil. Em um sistema grande, uma função local e estável pode continuar direta. O tamanho do projeto, isoladamente, não decide a estrutura de cada trecho.

Considere uma refatoração quando houver sinais concretos:

  • Uma alteração comercial exige editar componentes sem relação aparente.
  • Testar uma validação exige acessar banco, rede ou serviço de mensagens.
  • A troca de fornecedor espalha mudanças por várias regras de negócio.
  • Implementações supostamente equivalentes produzem comportamentos incompatíveis.
  • Interfaces obrigam classes a manter métodos vazios ou sempre indisponíveis.

Esses sinais também ajudam a identificar dívida técnica em sistemas e priorizar melhorias com efeito concreto na manutenção.

SOLID não exige uma interface para cada classe. Condicionais não representam automaticamente um defeito. Mais arquivos e camadas podem aumentar o custo de entender uma mudança sem oferecer flexibilidade útil.

Antes de criar uma hierarquia de herança, avalie a composição: receber uma política de desconto costuma permitir variar o cálculo sem criar subclasses para cada combinação de cliente e campanha.

Depois da alteração, faça três perguntas: ficou mais fácil entender o fluxo, testar suas regras e implementar uma mudança provável? Se as respostas forem negativas, a abstração merece revisão.

Como começar a aplicar SOLID em um sistema existente

Escolha um fluxo que já apresenta dificuldade real. Um cadastro que mistura validação, SQL e envio de e-mail é um candidato melhor do que uma parte estável escolhida apenas por ter muitas linhas.

  1. Delimite o problema. Identifique qual mudança recente foi difícil e quais dependências ampliaram o trabalho.
  2. Registre o comportamento atual. Liste entradas, saídas, erros e efeitos externos relevantes. Diferencie regras desejadas de defeitos conhecidos.
  3. Proteja o que precisa ser preservado. Crie testes úteis para casos válidos, rejeições e falhas importantes.
  4. Faça uma extração pequena. No cadastro, mova o envio de e-mail para um componente próprio, mantendo o comportamento acordado.
  5. Execute os testes e revise o fluxo. Confira a ordem das operações e o tratamento de falhas, além dos resultados de sucesso.
  6. Avalie o ganho. Verifique se trocar o serviço de mensagens ficou restrito à integração e se a regra de cadastro ficou mais compreensível.

Não é necessário aplicar os cinco princípios de uma vez. Se o problema é a mistura entre regra comercial e fornecedor de e-mail, responsabilidade única e inversão de dependência podem orientar uma primeira melhoria suficiente.

A manutenção de sistemas melhora quando as decisões respondem a dificuldades observáveis. Essa abordagem incremental ajuda a evoluir sites, lojas virtuais e sistemas empresariais, inclusive em projetos de empresas em Recife, sem exigir uma reescrita apenas para adequar o código à sigla SOLID.

Seu site ou sistema ficou difícil de atualizar? Entre em contato com a CodePhix para conversar sobre as necessidades do seu projeto em Recife e avaliar os próximos passos para sua evolução.

Perguntas e Respostas Rápidas

Resumo de dúvidas sobre este tema

O que são os princípios SOLID e quais problemas ajudam a resolver?

SOLID reúne cinco princípios de projeto associados à programação orientada a objetos. Eles orientam a organização de classes, interfaces e dependências, mas não definem uma arquitetura completa.

Como funciona: SRP: separe responsabilidades que mudam por motivos diferentes?

O princípio da responsabilidade única trata dos motivos de mudança. Uma classe pode ter vários métodos e continuar coesa. Também pode ter um único método que mistura responsabilidades de equipes, regras e integrações diferentes.

Como funciona: OCP: adicione comportamentos sem alterar regras estáveis?

O princípio aberto e fechado busca proteger uma parte estável do código das variações que ela deve aceitar. No checkout, o cálculo do total pode permanecer igual enquanto as políticas de desconto evoluem.

Como funciona: LSP: preserve o comportamento esperado ao trocar implementações?

O princípio da substituição de Liskov exige mais do que métodos com os mesmos nomes e tipos. Uma implementação substituta precisa preservar as garantias usadas pelo consumidor.

C

Elaborado e revisado por

Max Alex · Equipe de Engenharia Codephix

Engenharia de Software

A Codephix é uma software house sediada em Recife (PE) com atuação em todo o Brasil, especializada no desenvolvimento de sistemas sob medida, arquitetura escalável, integrações críticas e inteligência artificial aplicada a negócios.

Pronto para conversar sobre o seu projeto?

A Codephix transforma desafios operacionais em sistemas que funcionam. Fale com a nossa equipe de engenharia.

WhatsApp