Pular para o conteúdo

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.

Durval Pereira
Durval Pereira
9 min

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.

Tagssoliddesign-patternsclean-codesoftware-designtypescript