Princípios SOLID Não São Regras — São Trade-offs
SOLID é o conjunto de princípios mais citado e menos compreendido na engenharia de software. A maioria das explicações para no acrônimo. Veja o que cada princípio realmente significa para sua base de código — e quando quebrá-los.
O problema com a forma como SOLID é ensinado
Todo blog de engenharia tem um artigo sobre SOLID. A maioria deles se parece com isto: "S significa Princípio da Responsabilidade Única. Uma classe deve ter apenas uma razão para mudar." Em seguida, um exemplo trivial com uma classe User, e você deveria se sentir iluminado.
A questão não é que a definição esteja errada. É que ela é inútil sem contexto. "Uma razão para mudar" não é uma medida objetiva — depende do eixo de mudança para o qual você está projetando. E isso depende do seu sistema, da sua equipe e do seu domínio.
Os princípios SOLID não são leis da física. São heurísticas de design que descrevem trade-offs. Compreender o trade-off é a habilidade. Recitar o acrônimo não é.
S — Princípio da Responsabilidade Única
A definição comum: Uma classe deve ter apenas uma razão para mudar.
A definição útil: Um módulo deve ser responsável por um, e apenas um, ator.
A diferença importa. "Uma razão para mudar" é vago. "Um ator" é concreto. Um ator é um grupo de usuários ou stakeholders que solicitariam mudanças no módulo.
// This violates SRP — not because it "does two things,"
// but because two different actors care about it
class Employee {
calculatePay(): number {
// CFO's team cares about this
return this.hoursWorked * this.hourlyRate
}
generateReport(): string {
// COO's team cares about this
return `${this.name}: ${this.hoursWorked}h`
}
save(): void {
// DBA / infrastructure team cares about this
db.employees.update(this.id, this.toJSON())
}
}
O problema não é que a classe seja "muito grande". É que uma mudança solicitada pela equipe financeira (modificar o cálculo de pagamento) poderia quebrar a lógica de relatórios da qual a equipe de operações depende. O raio de impacto de uma mudança se estende além do ator que a solicitou.
A solução é simples — separar pelas fronteiras dos atores:
class PayCalculator {
calculate(employee: Employee): number {
return employee.hoursWorked * employee.hourlyRate
}
}
class EmployeeReporter {
generate(employee: Employee): string {
return `${employee.name}: ${employee.hoursWorked}h`
}
}
class EmployeeRepository {
save(employee: Employee): void {
db.employees.update(employee.id, employee.toJSON())
}
}
Quando quebrá-lo: No início de um projeto, quando os atores são incertos ou se sobrepõem. Dividir demais um módulo antes de entender o domínio cria indireção sem benefício. Se você não tem certeza se dois comportamentos irão divergir, mantenha-os juntos e separe quando a tensão se tornar real.
O — Princípio Aberto/Fechado
A definição comum: Entidades de software devem ser abertas para extensão, fechadas para modificação.
A definição útil: Você deve ser capaz de adicionar novo comportamento a um sistema sem editar código existente e testado.
Este é o princípio mais abusado. Levado ao pé da letra, ele leva a hierarquias de AbstractStrategyFactoryProvider que ninguém consegue navegar. Visto pragmaticamente, significa projetar seu sistema para que o eixo de mudança mais provável possa ser tratado adicionando código em vez de modificando código.
// Closed for modification: the processor doesn't change
// Open for extension: add new handlers without touching existing code
interface PaymentHandler {
type: string
process(amount: number, details: Record<string, unknown>): Promise<PaymentResult>
}
class PaymentProcessor {
private handlers: Map<string, PaymentHandler> = new Map()
register(handler: PaymentHandler): void {
this.handlers.set(handler.type, handler)
}
async process(type: string, amount: number, details: Record<string, unknown>): Promise<PaymentResult> {
const handler = this.handlers.get(type)
if (!handler) throw new Error(`No handler for payment type: ${type}`)
return handler.process(amount, details)
}
}
// Adding Stripe support = adding code, not editing PaymentProcessor
class StripeHandler implements PaymentHandler {
type = "stripe"
async process(amount: number, details: Record<string, unknown>): Promise<PaymentResult> {
// Stripe-specific logic
}
}
O padrão de registro de handlers é o motor do OCP em sistemas reais. Arquiteturas de plugins, cadeias de middleware, sistemas de eventos — são todas variações de "registrar um novo comportamento sem modificar o orquestrador."
Quando quebrá-lo: Quando o limite de abstração está errado. Se cada nova "extensão" exige a alteração da interface, você previu o eixo de mudança errado. É melhor refatorar em direção ao eixo real do que empilhar abstrações em um limite falho. Além disso, para códigos que genuinamente mudam raramente, adicionar pontos de extensão é complexidade prematura.
L — Princípio da Substituição de Liskov
A definição comum: Subtipos devem ser substituíveis por seus tipos base.
A definição útil: Se seu código aceita um tipo T, ele deve funcionar corretamente com qualquer subtipo de T sem saber com qual subtipo está lidando.
A violação clássica é o problema do quadrado-retângulo, que faz todo mundo concordar e depois prontamente esquecer. Aqui está um exemplo mais realista:
interface ReadOnlyStore<T> {
get(id: string): Promise<T | null>
list(): Promise<T[]>
}
interface Store<T> extends ReadOnlyStore<T> {
save(item: T): Promise<void>
delete(id: string): Promise<void>
}
// This violates LSP
class CachedStore<T> implements Store<T> {
async save(item: T): Promise<void> {
// Saves to cache only — doesn't persist
this.cache.set(item.id, item)
}
async delete(id: string): Promise<void> {
throw new Error("Cannot delete from cache")
}
// ... get and list work fine
}
Qualquer código que espera um Store pode chamar delete(). Se você usar CachedStore no lugar, esse código falha em tempo de execução. O subtipo não é substituível — ele enfraquece o contrato ao lançar uma exceção em uma operação que o tipo base promete suportar.
A solução: CachedStore deve implementar ReadOnlyStore, não Store. A hierarquia de tipos deve refletir o contrato comportamental real.
class CachedStore<T> implements ReadOnlyStore<T> {
async get(id: string): Promise<T | null> {
return this.cache.get(id) ?? null
}
async list(): Promise<T[]> {
return Array.from(this.cache.values())
}
}
Quando quebrá-lo: Quase nunca. Violações do LSP são bugs disfarçados. Elas criam sistemas onde a substituição de uma implementação causa falhas em tempo de execução que o sistema de tipos não consegue capturar. Se você se encontra lançando NotImplementedError em um subtipo, a hierarquia de tipos está errada.
I — Princípio da Segregação de Interfaces
A definição comum: Nenhum cliente deve ser forçado a depender de métodos que não usa.
A definição útil: Mantenha as interfaces pequenas e específicas para a função, para que os consumidores vejam apenas o que precisam.
Este é o SRP aplicado a interfaces. Interfaces "gordas" criam acoplamento — se um consumidor precisa apenas ler dados, mas a interface também expõe métodos de escrita, o consumidor está acoplado ao contrato de escrita, mesmo que nunca o use.
// Fat interface — forces every consumer to know about everything
interface UserService {
getUser(id: string): Promise<User>
updateUser(id: string, data: Partial<User>): Promise<User>
deleteUser(id: string): Promise<void>
listUsers(filter: UserFilter): Promise<User[]>
exportUsersToCsv(): Promise<Buffer>
sendPasswordResetEmail(userId: string): Promise<void>
}
Um componente que apenas exibe um perfil de usuário agora está acoplado a exportações CSV e redefinições de senha. Quando a lógica de e-mail muda, a dependência do componente de perfil também muda — mesmo que ele não se importe.
// Segregated interfaces — each consumer depends only on what it uses
interface UserReader {
getUser(id: string): Promise<User>
listUsers(filter: UserFilter): Promise<User[]>
}
interface UserWriter {
updateUser(id: string, data: Partial<User>): Promise<User>
deleteUser(id: string): Promise<void>
}
interface UserExporter {
exportToCsv(): Promise<Buffer>
}
interface PasswordResetter {
sendResetEmail(userId: string): Promise<void>
}
// The implementation can satisfy all interfaces
class UserServiceImpl implements UserReader, UserWriter, UserExporter, PasswordResetter {
// ... full implementation
}
Agora o componente de perfil depende de UserReader. O painel de administração depende de UserReader e UserWriter. O trabalho de exportação depende de UserExporter. Cada consumidor é protegido de mudanças em preocupações não relacionadas.
Quando quebrá-lo: Quando a sobrecarga de muitas interfaces pequenas excede o custo de acoplamento. Para módulos internos com um único consumidor, uma interface ligeiramente maior é mais simples do que um punhado de interfaces de método único. A segregação compensa nas fronteiras — entre serviços, entre camadas, entre equipes.
D — Princípio da Inversão de Dependência
A definição comum: Módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações.
A definição útil: O módulo que contém a lógica de negócios deve definir a interface de que precisa. O módulo de infraestrutura deve implementar essa interface.
Este é o princípio SOLID mais arquitetonicamente significativo. Sem ele, sua lógica de negócios fica refém de suas escolhas de infraestrutura. Mudar o banco de dados significa reescrever a camada de domínio. Trocar o provedor de e-mail significa editar o código de processamento de pedidos.
// WITHOUT dependency inversion:
// Business logic depends directly on infrastructure
import { PrismaClient } from '@prisma/client'
import { SendGrid } from '@sendgrid/mail'
class OrderService {
private db = new PrismaClient()
private mailer = new SendGrid()
async placeOrder(order: Order): Promise<void> {
await this.db.order.create({ data: order })
await this.mailer.send({ to: order.customerEmail, subject: 'Order confirmed' })
}
}
O OrderService está soldado ao Prisma e ao SendGrid. Testar requer um banco de dados real e uma API de e-mail real. Trocar para um ORM diferente significa reescrever a lógica de pedidos.
// WITH dependency inversion:
// Business logic defines what it needs; infrastructure adapts
interface OrderRepository {
save(order: Order): Promise<void>
}
interface NotificationService {
sendOrderConfirmation(email: string, order: Order): Promise<void>
}
class OrderService {
constructor(
private orders: OrderRepository,
private notifications: NotificationService,
) {}
async placeOrder(order: Order): Promise<void> {
await this.orders.save(order)
await this.notifications.sendOrderConfirmation(order.customerEmail, order)
}
}
// Infrastructure implements the business-defined interfaces
class PrismaOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
await prisma.order.create({ data: order })
}
}
class SendGridNotificationService implements NotificationService {
async sendOrderConfirmation(email: string, order: Order): Promise<void> {
await sendgrid.send({ to: email, subject: 'Order confirmed' })
}
}
Agora o OrderService pode ser testado com fakes em memória. O banco de dados pode ser trocado de Postgres para MongoDB sem tocar na lógica de pedidos. O provedor de e-mail pode mudar de SendGrid para Resend com impacto zero na camada de domínio.
Quando quebrá-lo: Para módulos folha que são improváveis de mudar e não têm custo de teste. Se sua aplicação usará apenas um banco de dados e você sabe disso, injetar uma interface de repositório para cada tabela adiciona cerimônia sem benefício. Aplique o DIP nas fronteiras que importam — aquelas onde você precisa de testabilidade, capacidade de troca ou independência de equipe.
O meta-princípio
Os princípios SOLID compartilham um tema comum: gerenciar o custo da mudança. Cada princípio, de diferentes ângulos, faz a mesma pergunta: "Quando este sistema mudar (e ele mudará), quanto código você precisará tocar e quanto irá quebrar?"
- SRP limita o raio de impacto de uma mudança
- OCP permite adicionar comportamento sem arriscar o comportamento existente
- LSP garante que as substituições não criem surpresas em tempo de execução
- ISP reduz a área de superfície de acoplamento entre módulos
- DIP faz com que a direção da dependência corresponda à direção da importância
Nenhum desses é absoluto. Todos eles têm um custo — indireção, abstração, tipos adicionais. O julgamento de engenharia é saber quando o custo do princípio é menor do que o custo de violá-lo. Em uma startup com três engenheiros e um prazo de seis meses, a adesão rígida ao SOLID é um passivo. Em uma plataforma com cinquenta engenheiros e um horizonte de cinco anos, ignorar o SOLID é um tipo diferente de passivo.
A resposta certa, como sempre, é: depende. Mas agora você sabe do que depende.
Este artigo faz parte de uma série sobre fundamentos de design de software.
Artigos Relacionados

Construindo para Escala: Padrões de Arquitetura Que Realmente Funcionam
A maioria dos conselhos sobre escalabilidade é genérica. Aqui estão os padrões que funcionaram consistentemente em sistemas reais que lidam com milhões de requisições — e aqueles que parecem bons, mas falham na prática.

Por Que Sua Busca Não Retorna Nada — E Como o MongoDB Vector Search Resolve Isso
A busca por palavras-chave só encontra o que está literalmente lá. Quando usuários buscam por 'laptop bag' e seus documentos dizem 'notebook carrying case', regex não vai ajudar. A busca vetorial entende o significado — e o MongoDB Atlas a suporta nativamente.

A Mentalidade da Engenharia de IA: O Que Muda ao Construir com LLMs
A engenharia de IA não é apenas engenharia de software com um modelo acoplado. Os ciclos de feedback, modos de falha e sinais de qualidade são fundamentalmente diferentes. Veja como pensar sobre isso.