Robert C. Martin cunhou a sigla SOLID em Agile Software Development: Principles, Patterns, and Practices (Prentice Hall, 2002). Cada letra nomeia uma regra de design que responde a um tipo de dor de manutenção. Cada seção abaixo mostra o padrão de código que viola o princípio, a versão corrigida e o custo que isso muda.

S: responsabilidade única

Uma classe ou função muda por um motivo só. Quando ela concentra gravação de dado, envio de email e auditoria, qualquer alteração nessas três frentes passa a mexer no mesmo arquivo.

// viola: muda por três motivos
class UserService {
  async createUser(data: CreateUserDto) {
    const user = await this.db.user.create({ data })
    await this.sendWelcomeEmail(user.email)
    await this.auditLog('user.created', user.id)
    return user
  }
}

// corrigido: cada responsabilidade no seu lugar
class UserService {
  constructor(
    private readonly userRepo: UserRepository,
    private readonly emailService: EmailService,
    private readonly auditService: AuditService
  ) {}

  async createUser(data: CreateUserDto) {
    const user = await this.userRepo.create(data)
    await this.emailService.sendWelcome(user.email)
    await this.auditService.log('user.created', user.id)
    return user
  }
}

A versão corrigida muda quando o repositório, o template de email ou a política de auditoria mudam. Robert C. Martin chama esse número de motivos de "axis of change", e o Clean Code (Prentice Hall, 2008) repete a mesma ideia para função: função curta com um nome que descreve uma ação.

O: aberto para extensão, fechado para modificação

Adicionar meio de pagamento não deve alterar a classe que processa pagamento. O código abaixo exige edição do método toda vez que um tipo novo entra:

// viola: cada tipo novo obriga edição aqui
class PaymentProcessor {
  process(type: string, amount: number) {
    if (type === 'credit_card') { /* ... */ }
    else if (type === 'pix') { /* ... */ }
    // 'boleto' obriga mexer neste método
  }
}

// corrigido: tipo novo vira classe nova
interface PaymentMethod {
  process(amount: number): Promise<PaymentResult>
}

class CreditCardPayment implements PaymentMethod {
  async process(amount: number) { /* ... */ }
}

class PixPayment implements PaymentMethod {
  async process(amount: number) { /* ... */ }
}

BoletoPayment entra depois sem tocar em nenhuma das classes acima. A Strategy do Refactoring.Guru é a mesma estrutura com outro nome.

L: substituição de Liskov

Subtipo entra no lugar do tipo base e o programa continua correto. Quando o código pede instanceof para decidir comportamento, o contrato já quebrou.

// viola: Square muda a altura junto
class Rectangle {
  constructor(protected width: number, protected height: number) {}
  setWidth(w: number) { this.width = w }
  setHeight(h: number) { this.height = h }
  area() { return this.width * this.height }
}

class Square extends Rectangle {
  setWidth(w: number) { this.width = w; this.height = w }
  setHeight(h: number) { this.width = h; this.height = h }
}

function increaseWidth(rect: Rectangle) {
  rect.setWidth(rect.width + 1) // no Square a altura muda junto
}

// corrigido: contrato que os dois tipos honram
interface Shape {
  area(): number
  scale(factor: number): Shape
}

Rectangle e Square compartilham forma geométrica e não compartilham contrato de setter. Hierarquia separada, ou composição, resolve sem enganar o compilador.

I: segregação de interfaces

Interface grande obriga implementador a escrever método que não usa. O DataStore abaixo força quem só lê a assinar connect(), write() e subscribe().

// viola: cinco métodos para quem lê três
interface DataStore {
  read(id: string): Promise<any>
  write(id: string, data: any): Promise<void>
  delete(id: string): Promise<void>
  subscribe(event: string, cb: Function): void
  connect(): Promise<void>
}

// corrigido: uma interface por capacidade
interface Reader {
  read(id: string): Promise<any>
}

interface Writer {
  write(id: string, data: any): Promise<void>
}

interface EventSource {
  subscribe(event: string, cb: Function): void
}

TypeScript ajuda aqui: Pick<DataStore, 'read'> serve para ad hoc, mas interface nomeada comunica intenção a quem chega depois.

D: inversão de dependência

Serviço de alto nível declara de que precisa e recebe a implementação pronta. Quem instancia fica na borda da aplicação.

// viola: instância concreta dentro do serviço
class OrderService {
  constructor() {
    this.db = new PrismaClient()
  }
}

// corrigido: contrato no domínio, implementação fora
interface OrderRepository {
  save(order: Order): Promise<void>
  findById(id: string): Promise<Order | null>
}

class OrderService {
  constructor(private readonly repo: OrderRepository) {}
}

Em produção o container injeta PrismaOrderRepository; no teste entra InMemoryOrderRepository e o teste roda sem Postgres. Framework de injeção como o reflect-metadata do NestJS automatiza essa costura, mas a decisão de desacoplar continua sendo do código de domínio.

Onde aplicar e onde deixar quieto

SOLID entra onde o custo de mudança é alto: regra de negócio, camada de serviço, ponto de extensão que time vai tocar toda semana. Função utilitária de quatro linhas, script de migração e código de configuração não recebem interface nem herança.

Martin Fowler trata desse limite no ensaio Is Design Dead?: desenho evolutivo parte de estrutura simples e refatora quando a dor aparece. A pergunta prática antes de puxar um padrão: se a mudança X chegar amanhã, quantos arquivos quebram? Poucos, segue em frente. Muitos, extraia a abstração.

Referências

  • Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices (Prentice Hall, 2002)
  • Robert C. Martin, Clean Code (Prentice Hall, 2008)
  • Is Design Dead? (Martin Fowler, 2001)
  • Strategy Pattern (Refactoring.Guru)
  • SOLID (Wikipedia)