MÓDULO 00 · CONCEITO 03 DE 5

Composição vs Herança

Por que linguagens modernas removeram herança de classe — e o que isso ensina sobre como reusar código bem.

Tempo de leitura ~20 min Pré-requisito Coesão & Acoplamento Próximo Testabilidade como design

Em 1994, quatro autores publicaram o livro que viria a definir o vocabulário do design orientado a objetos por décadas — Design Patterns, do Gang of Four. No meio dele, escondida sem destaque visual, está uma frase que se tornou uma das mais citadas da engenharia de software: "prefira composição de objetos sobre herança de classe". A frase é simples, mas carrega trinta anos de evidência empírica acumulada — e levou gerações inteiras de programadores a desaprenderem o que tinham aprendido na faculdade sobre orientação a objetos.

A história tem uma ironia útil. Nos anos 80 e início dos 90, herança era vendida como o grande diferencial de OO — a forma de reusar código, de modelar taxonomias, de capturar relações "é-um". Vinte anos depois, linguagens modernas como Go (2009) e Rust (2010) foram desenhadas sem herança de classe nenhuma e prosperaram. Java e C# adicionaram default methods em interfaces para reduzir a necessidade de herança. JavaScript modernos preferem composição via Object.assign e factory functions. Não foi consenso de comitê — foi observação prática, repetida em milhares de codebases, de que a herança como ferramenta de reuso entrega menos do que promete e cobra mais do que aparenta.

O que é herança, na prática

Herança de classe é o mecanismo pelo qual uma classe filha recebe automaticamente os campos e métodos da classe pai, podendo sobrescrever alguns e adicionar outros. Em linguagens estaticamente tipadas com OO clássica, ela faz três coisas ao mesmo tempo, e essa sobrecarga é onde mora boa parte dos problemas:

  1. Reuso de implementação: a classe filha herda código já escrito.
  2. Subtipagem: a classe filha pode ser usada onde a pai é esperada (substituibilidade).
  3. Compartilhamento de invariantes: a classe filha herda também as garantias internas da pai.

Os três aspectos costumam ser confundidos — e essa confusão é o ponto de partida para vários problemas. Quando alguém escreve class Cachorro extends Animal, está obtendo as três coisas ao mesmo tempo, queira ou não. E é raro precisar das três simultaneamente. Quando você quer apenas reuso (1), vai pagar acoplamento hierárquico que não pediu. Quando quer apenas subtipagem (2), vai herdar implementação que talvez não seja apropriada. Quando quer ambos (1) e (2), pode acabar violando invariantes (3) sem perceber, gerando bugs sutis.

O que é composição, na prática

Composição é a alternativa: em vez de a classe filha ser uma extensão da pai, uma classe contém instâncias de outras classes como campos, e delega para elas o que precisa. Carro não herda de Motor; ele tem um motor, e quando alguém chama carro.ligar(), o carro internamente faz this.motor.ligar(). A relação é horizontal, não vertical.

A diferença parece de gosto, mas é estrutural. Com composição, você consegue trocar o motor em tempo de execução (passa um motor diferente no construtor). Você pode ter dois motores. Você pode mockar o motor para testes. Você pode adicionar comportamento ao motor sem tocar no carro (injeta um motor decorado). Toda essa flexibilidade vem de graça — não há "redução do reuso" porque o motor continua sendo uma classe normal, usável por qualquer outro contexto.

Em linguagens com objetos imutáveis ou records, composição fica ainda mais natural: você descreve um Carro como uma estrutura que tem um Motor, sem nem precisar pensar em "OO" no sentido clássico. Isso é tão verdade que linguagens modernas passaram a chamar isso de "composição estrutural" — você descreve o sistema pelas peças que o compõem, e o comportamento emerge.

Por que herança falha como ferramenta de reuso

Há cinco problemas operacionais com herança para reuso, e vale conhecer cada um pelo nome — porque você os encontrará no código alheio (e no seu) o tempo todo.

1. O problema do retângulo-quadrado (LSP, revisitado)

Já vimos este no conceito 01, mas vale enfatizar aqui: tipos no código não são taxonomias da natureza. Quadrado é um retângulo geometricamente, mas Square extends Rectangle quebra contratos comportamentais. A lição é mais geral do que parece: "é-um" no mundo real raramente sobrevive à tradução para "é-um" no design de tipos. Hierarquias que parecem óbvias no domínio (Cachorro → Animal, Funcionário → Pessoa, Pedido → Documento) frequentemente se tornam armadilhas em produção.

2. O problema da classe base frágil

Quando uma classe pai tem várias filhas, mudar a pai pode quebrar qualquer uma das filhas — incluindo aquelas que você não conhece, que estão em outras bibliotecas, em projetos terceiros que herdaram. Adicionar um método novo, mudar um valor default, ajustar o comportamento de um método existente: cada uma dessas mudanças tem potencial de quebra silenciosa que o compilador não pega. Joshua Bloch dedica um capítulo inteiro a isso em Effective Java: "design and document for inheritance, or else prohibit it". O conselho dele para a maioria dos casos é simples — marque a classe como final.

3. O acoplamento vertical permanente

Uma classe filha está acoplada à pai pra sempre. Não há como "desherdar" sem reescrever tudo. Em sistemas que evoluem, isso significa que decisões tomadas no primeiro ano definem o que pode ser feito no quinto. Composição não tem esse problema: você troca o componente, mantém a interface. A própria ideia de "refactoring" de hierarquias é particularmente trabalhosa porque você está mexendo num grafo de dependências que o sistema de tipos amarrou pra você.

4. A explosão combinatória

Considere uma loja online com tipos de pagamento (Crédito, Débito, Pix) e tipos de entrega (Padrão, Express, Retirada). Com herança, modelar as nove combinações vira pesadelo: você precisa de uma hierarquia para pagamento e outra para entrega, e quando precisa de "pedido com débito e express", começa a criar OrderDebitoExpress, OrderCreditoExpress... ou cair em herança múltipla, que tem problemas próprios (diamond problem). Com composição, é trivial: Order tem um PaymentStrategy e um DeliveryStrategy. Nove combinações sem nove classes.

5. Testabilidade reduzida

Para testar uma classe filha em isolamento, você frequentemente precisa instanciar a pai junto, ou subir todo o contexto que a pai exige. Com composição, você passa mocks ou fakes dos componentes, isolando o que está sendo testado. Esse é o motivo pelo qual frameworks de teste em código orientado a herança costumam exigir ginástica — precisam fingir que existe uma pai inteira ali, mesmo que você só queira testar um pedaço.

armadilha clássica

Frameworks costumam estimular herança em pontos onde composição funcionaria melhor. ASP.NET tem ControllerBase para herdar; Django tem class-based views; SQLAlchemy tem modelos via herança. Esses casos têm justificativa histórica, mas o resultado é que o framework "decide" que você vai usar herança. Onde você tem escolha — código de domínio, regras de negócio, lógica de aplicação — prefira composição.

Quando herança é a ferramenta certa

Recusar herança categoricamente é tão dogmático quanto adotá-la categoricamente. Há três cenários onde ela é, de fato, a melhor ferramenta:

Hierarquias verdadeiramente substituíveis. Quando você precisa tratar várias formas de um tipo polimorficamente, e o critério de Liskov é respeitado de fato. Por exemplo, em uma API gráfica: Shape com filhas Circle, Square, Triangle, todas implementando area() e render(). Aqui a pai é praticamente uma interface com implementação default — e funciona bem.

Frameworks que precisam de extensibilidade controlada. Quando você está escrevendo uma biblioteca onde os usuários precisam injetar comportamento específico em pontos bem-definidos. Template Method, o padrão clássico, depende de herança: a pai define o fluxo, a filha sobrescreve passos individuais. Isso funciona porque o contrato é estreito e bem-documentado — a pai diz exatamente o que pode ser sobrescrito e o que não.

Modelagem de dados algebraicos (sealed hierarchies). Quando você tem um conjunto fechado e finito de variantes — algo como PaymentMethod = Credit | Debit | Pix. Em linguagens com sealed classes (Kotlin) ou abstract base com filhas conhecidas (C# sealed, Scala case class), isso vira uma forma elegante de modelar dados com tipo. Não é "OO clássica" — é mais perto de tipos algébricos de linguagens funcionais. E funciona bem.

Como Go ensina o caminho — sem herança nenhuma

Talvez o argumento mais convincente contra herança como ferramenta de reuso seja empírico: Go foi projetada deliberadamente sem herança de classe e produziu ecossistemas inteiros (Kubernetes, Docker, Terraform, gRPC) sem essa carência se tornar um problema. O que Go oferece em troca:

Comparar como o mesmo problema — dar logging a um repositório, sem repetir código — fica em cada linguagem deixa a diferença clara:

C#
// Composição via decorator (não herança)
public interface IUserRepository {
    Task<User?> FindById(Guid id);
}

public class PostgresUserRepository : IUserRepository {
    public async Task<User?> FindById(Guid id) {
        // ... query real
        return null;
    }
}

public class LoggingUserRepository : IUserRepository {
    private readonly IUserRepository _inner;
    private readonly ILogger _log;

    public LoggingUserRepository(IUserRepository inner, ILogger log) {
        _inner = inner;
        _log = log;
    }

    public async Task<User?> FindById(Guid id) {
        _log.LogDebug("FindById({Id})", id);
        var u = await _inner.FindById(id);
        _log.LogDebug("→ {Found}", u != null);
        return u;
    }
}

Decorator clássico. Sem herança — compõe via interface. Pode empilhar: metrics, retry, cache, todos como decorators.

Python
from typing import Protocol
from uuid import UUID

class UserRepository(Protocol):
    def find_by_id(self, id: UUID) -> User | None: ...

class PostgresUserRepository:
    def find_by_id(self, id: UUID) -> User | None:
        # ... query real
        return None

class LoggingUserRepository:
    def __init__(self, inner: UserRepository, log):
        self._inner = inner
        self._log = log

    def find_by_id(self, id: UUID) -> User | None:
        self._log.debug(f"find_by_id({id})")
        u = self._inner.find_by_id(id)
        self._log.debug(f"→ found={u is not None}")
        return u

Mesmo padrão. Sem herança — Protocol dá o contrato sem amarração nominal. Decorators de função (`@log_calls`) também são opção idiomática.

Go
type UserRepository interface {
    FindByID(id uuid.UUID) (*User, error)
}

type PostgresUserRepository struct {
    db *sql.DB
}

func (r *PostgresUserRepository) FindByID(id uuid.UUID) (*User, error) {
    // ... query real
    return nil, nil
}

type loggingUserRepository struct {
    inner UserRepository
    log   *slog.Logger
}

func (r *loggingUserRepository) FindByID(id uuid.UUID) (*User, error) {
    r.log.Debug("FindByID", "id", id)
    u, err := r.inner.FindByID(id)
    r.log.Debug("result", "found", u != nil, "err", err)
    return u, err
}

func WithLogging(inner UserRepository, log *slog.Logger) UserRepository {
    return &loggingUserRepository{inner: inner, log: log}
}

Sem herança no idioma. Composição é a única ferramenta — e funciona perfeitamente. Note como `WithLogging` é um construtor que devolve a interface.

O que o decorator faz nesse exemplo é exatamente o que a herança costuma ser usada para fazer — adicionar comportamento a algo existente. Mas faz com baixo acoplamento (cada camada conhece só a camada de baixo via interface) e ortogonalidade (você pode empilhar logging, metrics, retry, cache em qualquer ordem, ou nenhum). Tente fazer isso com herança e o resultado é uma cascata de classes que se entrelaçam com regras frágeis sobre quem chama super.method() e quando.

O conselho prático

Quando estiver decidindo entre composição e herança, três perguntas resolvem a maioria dos casos:

  1. É uma relação "é-um" verdadeira ou estou querendo reuso? Se a resposta tem qualquer dúvida ou se você está pensando "quero reaproveitar esse código", composição vence.
  2. Os subtipos respeitam totalmente o contrato do supertipo? (LSP). Se algum subtipo precisa lançar NotSupportedException, ignorar parâmetros, ou mudar pré-condições, está violando substituibilidade. Refatore para hierarquia onde isso não acontece, ou troque por composição.
  3. O conjunto de subtipos é fechado e conhecido? Se sim (e respeita LSP), herança ou sealed hierarchy funciona. Se for aberto — qualquer um pode criar uma filha — composição reduz fragilidade.
heurística do sênior

Quando estiver duvidando, escreva o código com composição primeiro. É quase sempre mais flexível, sempre mais testável, e raramente é mais verboso o suficiente para incomodar. Se em algum momento perceber que está re-implementando o mesmo método-delegação em três classes, aí sim considere extrair a hierarquia. Refatorar de composição para herança quando a evidência aparece é fácil; o caminho inverso é caro.

Pitfalls específicos por linguagem

Cada linguagem tem armadilhas próprias quando o tema é herança. Conhecer as suas ajuda a evitar erros idiomáticos.

C# — sealed por padrão

C# permite herança aberta por default — qualquer classe pode ser herdada. Isso é o oposto da recomendação prática. O conselho de Bloch (e de Eric Lippert, que escreveu sobre isso muito no contexto C#) é: marque suas classes como sealed a menos que haja razão explícita para abrir. Você pode sempre desbloquear depois; bloquear depois quebra contrato. Regra adicional: se uma classe não foi desenhada para herança (com métodos virtuais documentados, ordem de chamadas explicada, invariantes claras), não permita herança.

Python — MRO e a ilusão de simplicidade

Herança múltipla em Python é tecnicamente permitida e amplamente usada via mixins (especialmente em Django). O resultado é o Method Resolution Order (MRO), que segue C3 linearization — algoritmo elegante mas que produz comportamento surpreendente em hierarquias profundas. Se você tem cinco classes no MRO de um objeto e está tentando descobrir qual __init__ roda primeiro, está em território onde o custo cognitivo já passou do que herança paga em volta. Composição em Python é geralmente mais clara, e Protocols cobrem subtipagem sem precisar de classe pai compartilhada.

Go — embedding não é herança (mas parece)

Go tem o conceito de struct embedding: você pode incluir um struct dentro de outro sem nome de campo, e os métodos do interno ficam "promovidos" para o externo. Isso engana muita gente vinda de Java/C# para achar que é herança disfarçada — não é. O struct interno continua sendo um campo (acessável como outer.Inner); não há subtipagem entre eles; não há sobrescrita de método. É composição com açúcar sintático para reduzir verbosidade de delegação. Uma vez que você internaliza isso, o idioma fica natural.

Como praticar

Dois exercícios curtos ajudam a internalizar:

  1. Refatore uma hierarquia existente para composição. Pegue algum projeto seu (ou open-source) que use herança em código de domínio. Escolha uma hierarquia de 3-4 classes e reescreva como composição. Note onde ficou mais simples (testabilidade, flexibilidade) e onde ficou mais verboso (delegação repetida). A próxima vez que estiver desenhando algo, vai ter intuição calibrada.
  2. Escreva o mesmo Strategy pattern nas três linguagens. Pegue algo simples — um sistema de descontos com três regras (cliente VIP, cupom, primeiro pedido) e implemente em C#, Python e Go usando composição. Note como cada linguagem expressa o mesmo padrão de forma diferente: interfaces nominais em C#, Protocols em Python, interfaces estruturais em Go. Esse é exatamente o tipo de exercício que vai aparecer no projeto deste módulo.

No URL Shortener (projeto deste módulo), o ponto onde isso aparece é na escolha do storage. InMemoryRepository e PostgresRepository podem ser irmãs herdando de uma classe abstrata, ou podem implementar a mesma interface independentemente, sem hierarquia. Escolha a segunda opção. Quando você adicionar logging, métricas e retry depois, vai entender o porquê.

Referências para aprofundar

  1. livro Design Patterns: Elements of Reusable Object-Oriented Software — Gamma, Helm, Johnson, Vlissides (1994). A fonte da máxima "favor composition over inheritance". Capítulo 1, seção sobre reuso. Datado em alguns padrões, atual nos princípios.
  2. livro Effective Java (3rd edition) — Joshua Bloch (2017). Itens 18, 19 e 20 são os mais importantes do livro inteiro: "Favor composition over inheritance", "Design and document for inheritance or else prohibit it", "Prefer interfaces to abstract classes".
  3. livro Clean Code — Robert C. Martin (2008). Capítulo 10 (Classes) traz exemplos práticos de quando substituir herança por composição.
  4. livro Refactoring (2nd ed.) — Martin Fowler (2018). "Replace Inheritance with Delegation" e "Replace Subclass with Fields" são os refactorings nomeados que te tiram de herança ruim. Catálogo essencial.
  5. artigo Composition Over Inheritance — Brandon Rhodes. python-patterns.guide/gang-of-four/composition-over-inheritance/ — caso prático em Python, claríssimo. Mostra explosão combinatória vs strategy pattern.
  6. artigo Goodbye, Object Oriented Programming — Charles Scalfani (2016). medium.com/@cscalfani — provocação sobre os três grandes problemas de OO. O autor exagera, mas a parte sobre herança é precisa.
  7. artigo Why Extends Is Evil — Allen Holub (JavaWorld, 2003). Texto antigo, conhecimento clássico. A polêmica vale pela densidade de exemplos concretos.
  8. docs C# Sealed Classes (Microsoft Docs). learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/sealed — explicação oficial e quando aplicar. Padrão recomendado em código novo.
  9. docs Effective Go — Embedding. go.dev/doc/effective_go#embedding — explica por que embedding NÃO é herança e como usar idiomaticamente.
  10. docs PEP 487 — Simpler customisation of class creation. peps.python.org/pep-0487/ — para casos onde você de fato precisa de hierarquia em Python, como fazer com cuidado.
  11. vídeo The Wet Codebase — Dan Abramov (Deconstruct 2019). YouTube. Discute por que "DRY" via herança causa mais problemas do que resolve. Importante para entender quando reuso é uma armadilha.
  12. vídeo Inheritance, Polymorphism & Testing — Sandi Metz. YouTube. Metz, autora de "Practical Object-Oriented Design", mostra como herança bem usada parece e como herança ruim parece, com refactoring ao vivo.