ThiagoSoares
Voltar para o blog
Arquitetura12 min

LSP - Liskov Substitution Principle

Como criar implementações substituíveis em TypeScript respeitando comportamento, erros e garantias do contrato.

Compartilhar artigo

TypeScript confirma que métodos possuem nomes e tipos compatíveis. Ele não consegue garantir que uma implementação preserve o significado do contrato.

O Liskov Substitution Principle, ou LSP, trata dessa parte comportamental. Um consumidor deve poder trocar uma implementação por outra sem receber resultados inesperados ou precisar conhecer exceções escondidas.

O problema que o princípio resolve

Considere um armazenamento de arquivos usado por um processo de auditoria. Uma implementação grava em disco. Outra foi criada para um ambiente somente leitura, mas mantém a mesma interface lançando erro ao salvar.

O compilador aceita as duas. O processo, porém, não pode substituí-las com segurança.

Substituição é comportamento

Uma implementação respeita LSP quando mantém as expectativas observáveis do contrato. Isso inclui retorno, efeitos, erros e estado.

Na prática, um subtipo não deve exigir pré-condições mais fortes, entregar garantias mais fracas nem introduzir efeitos incompatíveis. Se o chamador precisa perguntar qual implementação recebeu, a abstração perdeu valor.

Exemplo ruim

code
interface AuditStorage {
  save(fileName: string, content: string): Promise<void>;
  read(fileName: string): Promise<string>;
}
 
class ReadOnlyAuditStorage implements AuditStorage {
  constructor(private readonly files: Map<string, string>) {}
 
  async save(): Promise<void> {
    throw new Error("Este armazenamento não permite escrita");
  }
 
  async read(fileName: string): Promise<string> {
    const content = this.files.get(fileName);
 
    if (!content) {
      throw new Error("Arquivo não encontrado");
    }
 
    return content;
  }
}
 
class GenerateAuditReport {
  constructor(private readonly storage: AuditStorage) {}
 
  async execute(reportId: string, content: string): Promise<void> {
    await this.storage.save(`${reportId}.log`, content);
  }
}

Por que o exemplo é ruim?

GenerateAuditReport recebe um contrato que promete save. A implementação somente leitura aceita a chamada pelo tipo, mas a rejeita por definição.

Não é uma falha transitória, como disco indisponível. É uma incapacidade permanente. O contrato afirma uma capacidade que o objeto não possui.

Adicionar instanceof no caso de uso apenas revelaria a quebra e acoplaria o consumidor aos detalhes concretos.

Refatoração

As capacidades podem ser expressas separadamente:

code
interface AuditReader {
  read(fileName: string): Promise<string>;
}
 
interface AuditWriter {
  save(fileName: string, content: string): Promise<void>;
}
 
class MemoryAuditArchive implements AuditReader, AuditWriter {
  private readonly files = new Map<string, string>();
 
  async save(fileName: string, content: string): Promise<void> {
    this.files.set(fileName, content);
  }
 
  async read(fileName: string): Promise<string> {
    const content = this.files.get(fileName);
 
    if (content === undefined) {
      throw new Error(`Auditoria não encontrada: ${fileName}`);
    }
 
    return content;
  }
}
 
class ReadOnlyAuditArchive implements AuditReader {
  constructor(private readonly files: ReadonlyMap<string, string>) {}
 
  async read(fileName: string): Promise<string> {
    const content = this.files.get(fileName);
 
    if (content === undefined) {
      throw new Error(`Auditoria não encontrada: ${fileName}`);
    }
 
    return content;
  }
}

Exemplo bom

Cada consumidor pede somente a garantia que utiliza:

code
class GenerateAuditReport {
  constructor(private readonly writer: AuditWriter) {}
 
  async execute(reportId: string, content: string): Promise<string> {
    const fileName = `${reportId}.log`;
    await this.writer.save(fileName, content);
 
    return fileName;
  }
}
 
class ViewAuditReport {
  constructor(private readonly reader: AuditReader) {}
 
  execute(fileName: string): Promise<string> {
    return this.reader.read(fileName);
  }
}

Qualquer AuditWriter deve persistir o conteúdo ou comunicar uma falha operacional prevista. Uma implementação incapaz de escrever simplesmente não declara esse contrato.

Contratos precisam dizer mais que os tipos

Duas implementações podem devolver Promise<string> e ainda discordar. Uma pode retornar conteúdo vazio para arquivo ausente enquanto outra lança erro. Se isso afeta o consumidor, o contrato precisa estabelecer uma regra única.

Nomes, documentação, tipos de resultado e testes de contrato tornam a expectativa visível. Um resultado explícito pode ser útil:

code
type ReadResult =
  | { found: true; content: string }
  | { found: false };
 
interface SafeAuditReader {
  read(fileName: string): Promise<ReadResult>;
}

Agora ausência não depende de uma convenção escondida.

Testes de contrato

Uma mesma suíte pode ser executada contra implementações diferentes. Ela verifica garantias comuns, como salvar e ler o mesmo conteúdo, sobrescrever de forma previsível e representar ausência da mesma maneira.

Esses testes não substituem testes específicos de infraestrutura. Eles protegem exatamente a propriedade que LSP exige: o consumidor observa o mesmo contrato.

Benefícios

  • consumidores não verificam tipos concretos;
  • testes usam substitutos confiáveis;
  • erros e garantias ficam previsíveis;
  • novas implementações entram sem alterar casos de uso;
  • contratos representam capacidades reais.

Quando aplicar?

Sempre pergunte: "Posso substituir a classe base por uma classe derivada sem alterar o comportamento esperado pelo consumidor?" Se a resposta for "não", é hora de aplicar o LSP.

Fechamento

Liskov Substitution Principle exige honestidade nas abstrações. Compartilhar uma assinatura não basta; as implementações precisam preservar as garantias que permitem ao consumidor trabalhar sem surpresas.

Contratos menores, semântica explícita e testes compartilhados transformam substituição de uma promessa teórica em uma propriedade verificável.

Série SOLID

Este artigo faz parte de uma série de artigos: