Se você já escreve código há algum tempo, provavelmente já passou por aquela situação clássica: um projeto começa pequeno, tudo parece simples, mas com o tempo o código vira um emaranhado difícil de entender, testar e manter. Se você está começando agora na programação, saiba que isso não acontece por falta de talento, e sim por falta de estrutura e princípios bem definidos.
É exatamente aqui que entram os princípios SOLID, um conjunto de cinco conceitos fundamentais da Programação Orientada a Objetos (OOP) que ajudam desenvolvedores a escrever código mais limpo, organizado, reutilizável e fácil de evoluir. SOLID não é um framework, não é uma biblioteca e nem algo específico de uma linguagem. É uma forma de pensar o código.
No ecossistema JavaScript e TypeScript, especialmente quando usamos frameworks como o NestJS, os princípios SOLID aparecem o tempo todo, mesmo que você ainda não perceba. Controllers, services, providers, injeção de dependência… tudo isso existe para facilitar a aplicação desses princípios na prática.
Neste artigo, vamos conversar de forma bem tranquila sobre cada um dos cinco princípios do SOLID, começando do zero, sem linguagem complicada e sem teoria desnecessária. A ideia é que você entenda o problema que cada princípio resolve, veja exemplos simples e consiga aplicar isso nos seus próprios projetos, seja em APIs, sistemas maiores ou até projetos pessoais.
Se você quer evoluir como desenvolvedor e escrever código mais profissional, entender SOLID é um passo obrigatório.
O que é SOLID e por que ele é tão importante no desenvolvimento de software?
Antes de mergulharmos em cada princípio separadamente, precisamos entender o que significa SOLID no contexto da Programação Orientada a Objetos.
SOLID é um acrônimo criado por Robert C. Martin (Uncle Bob) e representa cinco princípios que ajudam a escrever código mais bem estruturado, flexível e fácil de manter ao longo do tempo. Esses princípios surgiram a partir de problemas reais enfrentados por desenvolvedores em projetos que cresceram rápido demais sem uma base sólida.
O nome SOLID vem das iniciais de cada princípio:
- S – Single Responsibility Principle (Princípio da Responsabilidade Única)
- O – Open/Closed Principle (Princípio do Aberto/Fechado)
- L – Liskov Substitution Principle (Princípio da Substituição de Liskov)
- I – Interface Segregation Principle (Princípio da Segregação de Interfaces)
- D – Dependency Inversion Principle (Princípio da Inversão de Dependência)
Pode parecer muita coisa no começo, mas a ideia central é simples:
cada princípio existe para evitar um tipo específico de problema no código.
Quando você ignora SOLID, é comum acabar com classes gigantes, métodos que fazem tudo ao mesmo tempo, dependências difíceis de trocar e um código que quebra em vários lugares quando você tenta mudar algo simples. Quando você aplica SOLID, o código tende a ser mais modular, previsível e testável.
No caso do NestJS, esses princípios não são apenas recomendados, eles fazem parte da filosofia do framework. A separação entre controllers, services e repositories, por exemplo, já é uma aplicação direta do SOLID. A injeção de dependência, que o NestJS usa intensamente, é uma implementação clara do princípio da inversão de dependência.
É importante deixar claro que SOLID não é uma regra rígida. Você não precisa aplicar todos os princípios o tempo todo. Eles são guias. Com o tempo e a prática, você começa a perceber quando faz sentido aplicar um princípio e quando isso só complicaria desnecessariamente o código.
Se você está começando agora, não se preocupe em memorizar definições. Foque em entender o problema que cada princípio resolve. É exatamente isso que vamos fazer nos próximos tópicos, sempre com exemplos simples e, quando possível, usando NestJS com TypeScript, que é uma combinação muito comum no mercado.
S — Single Responsibility Principle (Princípio da Responsabilidade Única)
O Single Responsibility Principle, ou simplesmente SRP, diz o seguinte:
Uma classe deve ter apenas um motivo para mudar.
Essa frase é curta, mas costuma gerar muita confusão no início. Então vamos simplificar.
Ter uma única responsabilidade significa que uma classe deve fazer uma coisa principal, e fazer bem feita. Quando uma classe começa a assumir várias responsabilidades diferentes, ela se torna difícil de entender, testar e manter.
Um problema muito comum em projetos reais
Imagine uma classe que faz tudo ao mesmo tempo:
- Busca dados no banco
- Valida regras de negócio
- Envia e-mails
- Gera logs
Quando alguma regra muda, você precisa mexer nessa mesma classe. O resultado é um código frágil, onde uma mudança pode quebrar algo que não tem relação direta com o problema original.
Esse tipo de classe é conhecida como God Class (classe Deus), e o SRP existe exatamente para evitar isso.
Exemplo ruim (sem SRP)
Vamos imaginar um cenário simples em NestJS: um serviço de usuários.
@Injectable()
export class UserService {
createUser(data: CreateUserDto) {
// valida dados
// salva no banco
// envia email de boas-vindas
// gera log
}
}
À primeira vista, parece prático. Mas essa classe tem várias responsabilidades:
- Regra de negócio do usuário
- Comunicação com banco de dados
- Envio de e-mail
- Log da aplicação
Se amanhã você quiser trocar o serviço de e-mail, precisa mexer no UserService. Se a forma de log mudar, mexe de novo. Isso viola o SRP.
Exemplo aplicando o SRP em NestJS
Agora vamos separar as responsabilidades:
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly emailService: EmailService,
private readonly loggerService: LoggerService,
) {}
createUser(data: CreateUserDto) {
const user = this.userRepository.create(data);
this.emailService.sendWelcomeEmail(user.email);
this.loggerService.log('Usuário criado');
return user;
}
}
Agora cada classe tem um papel claro:
UserService: regra de negócio do usuárioUserRepository: acesso a dadosEmailService: envio de e-mailsLoggerService: logs
Isso torna o código:
- Mais fácil de testar
- Mais simples de entender
- Mais fácil de manter
E perceba como o NestJS facilita isso naturalmente com injeção de dependência.
Quando aplicar (e quando não exagerar)
Um erro comum de quem está aprendendo SOLID é dividir tudo demais. O objetivo do SRP não é criar centenas de arquivos, mas separar responsabilidades que mudam por motivos diferentes.
Se duas partes do código sempre mudam juntas, talvez ainda façam sentido na mesma classe.
Benefícios diretos do SRP
- Código mais organizado
- Menos efeitos colaterais
- Melhor reaproveitamento
- Testes mais simples
Se você aplicar apenas esse princípio de forma consistente, seu código já vai melhorar bastante.
O — Open/Closed Principle (Princípio do Aberto/Fechado)
O Open/Closed Principle, ou OCP, afirma que:
Entidades de software (classes, módulos, funções) devem estar abertas para extensão, mas fechadas para modificação.
Em um primeiro momento, isso parece contraditório. Como algo pode estar aberto e fechado ao mesmo tempo? A ideia aqui é simples: você deve conseguir adicionar novos comportamentos ao sistema sem precisar sair modificando código que já funciona.
Na prática, isso evita aquele medo clássico de “vou mexer nisso aqui e quebrar outra coisa”.
O problema que o OCP resolve
Imagine que você tem um sistema que calcula pagamentos e, no início, ele só aceita cartão de crédito. Depois de um tempo, surge a necessidade de adicionar Pix, boleto, criptomoeda, etc.
Se toda vez que um novo tipo de pagamento surge você precisa abrir a mesma classe e adicionar vários if ou switch, seu código vai crescer descontroladamente e ficar cada vez mais frágil.
Exemplo ruim (violando OCP)
@Injectable()
export class PaymentService {
process(type: string, amount: number) {
if (type === 'credit_card') {
// lógica cartão
} else if (type === 'pix') {
// lógica pix
} else if (type === 'boleto') {
// lógica boleto
}
}
}
Toda vez que surgir um novo meio de pagamento, essa classe precisa ser modificada. Isso viola o Open/Closed Principle.
Aplicando OCP com NestJS e TypeScript
A solução clássica é usar abstrações, como interfaces ou classes abstratas.
export interface PaymentMethod {
pay(amount: number): void;
}
Implementações:
@Injectable()
export class CreditCardPayment implements PaymentMethod {
pay(amount: number) {
// lógica cartão
}
}
@Injectable()
export class PixPayment implements PaymentMethod {
pay(amount: number) {
// lógica pix
}
}
Agora o serviço principal:
@Injectable()
export class PaymentService {
constructor(private readonly paymentMethod: PaymentMethod) {}
process(amount: number) {
this.paymentMethod.pay(amount);
}
}
Perceba a diferença:
- Para adicionar um novo pagamento, você cria uma nova classe
- O
PaymentServicenão precisa ser alterado
Isso é estar aberto para extensão e fechado para modificação.
O papel do NestJS aqui
O NestJS facilita muito o OCP com:
- Injeção de dependência
- Providers
- Tokens de interface
Você pode trocar implementações facilmente sem mexer na regra de negócio principal, apenas ajustando o provider.
Benefícios práticos do OCP
- Menos risco de bugs
- Código mais estável
- Evolução mais segura do sistema
- Facilidade para testes e manutenção
O OCP é essencial em sistemas que crescem com o tempo, especialmente APIs e aplicações corporativas.
L — Liskov Substitution Principle (Princípio da Substituição de Liskov)
O Princípio da Substituição de Liskov (LSP) diz o seguinte:
Se uma classe B é um subtipo de uma classe A, então objetos do tipo A podem ser substituídos por objetos do tipo B sem quebrar o comportamento do sistema.
Traduzindo para uma linguagem mais simples:
uma classe filha deve poder substituir a classe pai sem causar erros inesperados.
Se ao trocar uma implementação por outra o sistema quebra, algo está errado no design.
Onde as pessoas mais erram com LSP
O erro mais comum acontece quando usamos herança apenas para reaproveitar código, sem respeitar o comportamento esperado da classe base.
Muita gente pensa: “se é herança, está tudo certo”. Mas não é bem assim.
Exemplo clássico de problema com LSP
Imagine uma classe base Bird:
class Bird {
fly() {
console.log('Voando');
}
}
Agora criamos uma classe Penguin:
class Penguin extends Bird {
fly() {
throw new Error('Pinguins não voam');
}
}
Aqui temos um problema claro.
Se algum código espera um Bird e chama fly(), ao receber um Penguin o sistema quebra. Isso viola o LSP.
Mesmo que tecnicamente o código compile, o comportamento esperado foi quebrado.
Aplicando LSP da forma correta
A solução é modelar melhor as abstrações.
interface Bird {
move(): void;
}
interface FlyingBird extends Bird {
fly(): void;
}
Implementações:
class Sparrow implements FlyingBird {
move() {
console.log('Andando');
}
fly() {
console.log('Voando');
}
}
class Penguin implements Bird {
move() {
console.log('Andando');
}
}
Agora:
- Nenhuma classe é forçada a implementar algo que não faz sentido
- As substituições são seguras
- O comportamento esperado é respeitado
Exemplo prático em NestJS
No NestJS, esse problema aparece muito quando trabalhamos com services e interfaces.
Se você cria uma interface para um serviço, todas as implementações devem respeitar o contrato, sem mudar o comportamento esperado.
export interface NotificationService {
send(message: string): void;
}
Implementações corretas:
@Injectable()
export class EmailNotificationService implements NotificationService {
send(message: string) {
// envia email
}
}
@Injectable()
export class SmsNotificationService implements NotificationService {
send(message: string) {
// envia sms
}
}
Qualquer uma pode substituir a outra sem quebrar o sistema. Isso é LSP na prática.
Regra de ouro do LSP
Se ao trocar uma implementação por outra você precisa sair tratando exceções ou criando condições especiais, provavelmente você está violando o LSP.
Benefícios do LSP
- Código mais previsível
- Menos bugs inesperados
- Melhor uso de abstrações
- Mais confiança ao trocar implementações
I — Interface Segregation Principle (Princípio da Segregação de Interfaces)
O Interface Segregation Principle (ISP) diz o seguinte:
Nenhuma classe deve ser forçada a depender de métodos que ela não usa.
Em outras palavras, interfaces grandes demais são um problema. Quando você cria uma interface muito genérica e cheia de métodos, acaba obrigando as classes a implementarem comportamentos que não fazem sentido para elas.
O ISP existe para nos lembrar que é melhor ter várias interfaces pequenas e específicas do que uma interface gigante e genérica.
O problema das interfaces “faz tudo”
Imagine uma interface assim:
export interface UserOperations {
create(): void;
update(): void;
delete(): void;
export(): void;
}
Agora imagine um serviço que só precisa criar e atualizar usuários.
@Injectable()
export class CreateUserService implements UserOperations {
create() {}
update() {}
delete() {
throw new Error('Não usado');
}
export() {
throw new Error('Não usado');
}
}
Esse código é um claro exemplo de violação do ISP.
A classe está sendo forçada a implementar métodos que ela não usa.
Aplicando o ISP corretamente
Vamos quebrar essa interface em interfaces menores e mais específicas:
export interface CreateUser {
create(): void;
}
export interface UpdateUser {
update(): void;
}
export interface DeleteUser {
delete(): void;
}
export interface ExportUser {
export(): void;
}
Agora cada classe implementa apenas o que realmente precisa:
@Injectable()
export class CreateUserService implements CreateUser {
create() {
// lógica de criação
}
}
Simples, direto e muito mais limpo.
ISP na prática com NestJS
No NestJS, o ISP aparece naturalmente quando você:
- Cria services pequenos e focados
- Usa interfaces para contratos específicos
- Evita services gigantes que fazem tudo
Por exemplo, em vez de um UserService enorme, você pode ter:
CreateUserServiceUpdateUserServiceDeleteUserService
Cada um com sua responsabilidade e interfaces específicas.
Quando não exagerar no ISP
Assim como o SRP, o ISP pode ser mal interpretado.
Você não precisa criar uma interface para cada método do sistema.
A regra prática é:
se uma classe está sendo obrigada a implementar métodos inúteis, é hora de segregar a interface.
Benefícios do Interface Segregation Principle
- Código mais limpo
- Menos métodos “mortos”
- Classes mais fáceis de entender
- Menos acoplamento entre módulos
O ISP ajuda muito a manter o sistema organizado à medida que ele cresce.
D — Dependency Inversion Principle (Princípio da Inversão de Dependência)
O Dependency Inversion Principle (DIP) diz o seguinte:
Módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações.
Abstrações não devem depender de detalhes. Detalhes devem depender de abstrações.
Se isso parece confuso, fique tranquilo. Vamos simplificar bastante.
Na prática, o DIP diz que seu código principal não deve depender diretamente de implementações concretas, como classes específicas de banco de dados, APIs externas ou serviços de terceiros. Em vez disso, ele deve depender de interfaces ou abstrações.
O problema de depender diretamente de implementações
Imagine um serviço que depende diretamente de um repositório específico:
@Injectable()
export class OrderService {
constructor(private readonly orderRepository: MySqlOrderRepository) {}
createOrder() {
return this.orderRepository.save();
}
}
Esse código funciona, mas cria um problema:
o OrderService agora está fortemente acoplado ao MySQL.
Se amanhã você quiser trocar o banco para PostgreSQL ou MongoDB, precisa mexer no OrderService. Isso viola o DIP.
Aplicando o DIP corretamente
A solução é depender de uma abstração:
export interface OrderRepository {
save(): void;
}
Implementações:
@Injectable()
export class MySqlOrderRepository implements OrderRepository {
save() {
// salva no MySQL
}
}
@Injectable()
export class MongoOrderRepository implements OrderRepository {
save() {
// salva no MongoDB
}
}
Agora o serviço principal:
@Injectable()
export class OrderService {
constructor(private readonly orderRepository: OrderRepository) {}
createOrder() {
return this.orderRepository.save();
}
}
Agora o OrderService não sabe (e não precisa saber) qual banco está sendo usado. Ele só conhece o contrato.
O DIP e o NestJS
Aqui está um ponto importante: o NestJS foi criado para facilitar o DIP.
O sistema de injeção de dependência do NestJS permite:
- Trocar implementações facilmente
- Usar mocks em testes
- Manter o código desacoplado
Basta configurar o provider correto no módulo:
{
provide: OrderRepository,
useClass: MySqlOrderRepository,
}
Trocar a implementação não exige mudar a regra de negócio.
DIP não é só sobre interfaces
O DIP também é sobre direção de dependência.
As regras de negócio não devem conhecer detalhes técnicos.
Quem conhece detalhes técnicos é a camada externa do sistema.
Benefícios do Dependency Inversion Principle
- Código mais flexível
- Facilidade para testes
- Menos acoplamento
- Evolução mais segura do sistema
Se você entende bem o DIP, frameworks como NestJS começam a fazer muito mais sentido.
Conclusão
Os princípios SOLID não são regras mágicas nem exigências absolutas. Eles são guias práticos que ajudam você a escrever código mais organizado, legível e fácil de manter.
Você não precisa aplicar todos os princípios o tempo todo. Com o tempo, você começa a perceber quando o código está ficando difícil de evoluir, e é exatamente nesses momentos que o SOLID se torna um aliado poderoso.
No dia a dia, especialmente com NestJS, você já aplica SOLID mesmo sem perceber. Entender esses princípios só te dá mais consciência e controle sobre suas decisões técnicas.
Agora quero saber de você:
qual princípio do SOLID você achou mais difícil de entender ou aplicar?
Deixa seu comentário aqui embaixo e vamos trocar ideia.