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:
- Reuso de implementação: a classe filha herda código já escrito.
- Subtipagem: a classe filha pode ser usada onde a pai é esperada (substituibilidade).
- 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.
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:
- Embedding de structs: você inclui um struct dentro de outro, e os métodos do interno ficam acessíveis no externo. Parece herança mas é composição com açúcar sintático — o interno permanece sendo um campo, sem relação de subtipo.
- Interfaces estruturais: qualquer tipo que tenha os métodos certos satisfaz a interface, sem declaração explícita. Polimorfismo sem hierarquia.
-
Funções como cidadãos de primeira classe: muitas vezes não
precisa de classe nenhuma — só passa uma função. Strategy pattern
em Go é uma
func(...).
Comparar como o mesmo problema — dar logging a um repositório, sem repetir código — fica em cada linguagem deixa a diferença clara:
// 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.
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.
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:
- É 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.
-
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. - 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.
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:
- 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.
- 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
- livro Design Patterns: Elements of Reusable Object-Oriented Software — Gamma, Helm, Johnson, Vlissides (1994).
- livro Effective Java (3rd edition) — Joshua Bloch (2017).
- livro Clean Code — Robert C. Martin (2008).
- livro Refactoring (2nd ed.) — Martin Fowler (2018).
- artigo Composition Over Inheritance — Brandon Rhodes.
- artigo Goodbye, Object Oriented Programming — Charles Scalfani (2016).
- artigo Why Extends Is Evil — Allen Holub (JavaWorld, 2003).
- docs C# Sealed Classes (Microsoft Docs).
- docs Effective Go — Embedding.
- docs PEP 487 — Simpler customisation of class creation.
- vídeo The Wet Codebase — Dan Abramov (Deconstruct 2019).
- vídeo Inheritance, Polymorphism & Testing — Sandi Metz.