Por que composição (tem-um) costuma ser melhor que herança (é-um)
Em Java OOP, composição é quando um objeto contém outros objetos para cumprir suas responsabilidades. Em vez de estender uma classe para “reaproveitar código”, você cria uma relação tem-um e delegação: a classe principal chama métodos do objeto interno para executar parte do trabalho.
Composição tende a trazer mais clareza porque modela melhor o domínio (ex.: Pedido tem itens; Conta tem cliente) e reduz acoplamento com hierarquias rígidas. Também facilita trocar implementações internas sem quebrar a API pública.
Heurísticas rápidas
- Use composição quando a relação é parte-de ou usa (tem-um).
- Evite herança quando o objetivo é apenas “reutilizar métodos”.
- Prefira delegar comportamento a colaboradores (objetos internos) em vez de concentrar tudo em uma classe “Deus”.
Modelando relações tem-um: exemplos de domínio
Exemplo 1: Pedido tem Itens (coleção interna)
Um Pedido normalmente possui uma lista de itens. Isso é composição: o pedido coordena o conjunto e expõe operações de alto nível (adicionar item, calcular total), enquanto cada item sabe seu preço e quantidade.
import java.math.BigDecimal;import java.util.ArrayList;import java.util.Collections;import java.util.List;import java.util.Objects;final class ItemPedido { private final String sku; private final int quantidade; private final BigDecimal precoUnitario; ItemPedido(String sku, int quantidade, BigDecimal precoUnitario) { this.sku = Objects.requireNonNull(sku); if (quantidade <= 0) throw new IllegalArgumentException("quantidade"); this.quantidade = quantidade; this.precoUnitario = Objects.requireNonNull(precoUnitario); if (precoUnitario.signum() < 0) throw new IllegalArgumentException("precoUnitario"); } BigDecimal subtotal() { return precoUnitario.multiply(BigDecimal.valueOf(quantidade)); } String sku() { return sku; } int quantidade() { return quantidade; }}final class Pedido { private final List<ItemPedido> itens = new ArrayList<>(); public void adicionarItem(ItemPedido item) { itens.add(Objects.requireNonNull(item)); } public BigDecimal total() { BigDecimal total = BigDecimal.ZERO; for (ItemPedido item : itens) { total = total.add(item.subtotal()); } return total; } public List<ItemPedido> itens() { return Collections.unmodifiableList(itens); }}Note a decisão importante: Pedido não expõe a lista mutável. Ele retorna uma visão imutável (unmodifiable) para preservar invariantes e impedir que código externo faça itens().clear().
Exemplo 2: Conta tem Cliente (referência direta)
Uma Conta pode “ter um” Cliente. Aqui, o relacionamento pode ser de composição ou agregação dependendo do ciclo de vida (ver seção de ownership). Em termos de modelagem, a conta delega dados do titular ao objeto cliente, sem duplicar campos.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
import java.util.Objects;final class Cliente { private final String nome; private final String documento; Cliente(String nome, String documento) { this.nome = Objects.requireNonNull(nome); this.documento = Objects.requireNonNull(documento); } String nome() { return nome; } String documento() { return documento; }}final class Conta { private final Cliente titular; private long saldoEmCentavos; Conta(Cliente titular) { this.titular = Objects.requireNonNull(titular); } public String nomeDoTitular() { return titular.nome(); } public void depositar(long centavos) { if (centavos <= 0) throw new IllegalArgumentException("centavos"); saldoEmCentavos += centavos; } public long saldo() { return saldoEmCentavos; }}Repare que Conta não “vira” um Cliente (não faz sentido herdar). Ela tem um cliente e delega o que precisa.
Ownership e ciclo de vida: composição vs agregação
Ao modelar tem-um, decida quem “possui” o objeto e como é o ciclo de vida:
- Composição (ownership forte): o objeto “pai” controla o ciclo de vida do “filho”. Ex.:
Pedidocria/gerencia seusItemPedidoe eles não fazem sentido fora do pedido. - Agregação (ownership fraco): o objeto “pai” referencia algo que pode existir independentemente. Ex.:
Contareferencia umClienteque existe no cadastro e pode ser compartilhado por outras contas.
Em Java, isso não é imposto pela linguagem; é uma decisão de design refletida em: quem cria, quem valida, quem pode substituir a referência, e se há compartilhamento.
Sinais práticos de composição
- A classe “pai” recebe dados e cria os objetos internos (ou controla sua criação).
- Objetos internos não são expostos de forma mutável.
- Remover o “pai” torna os “filhos” irrelevantes no domínio.
Sinais práticos de agregação
- O objeto interno vem de fora (repositório, cadastro, serviço) e pode ser compartilhado.
- A classe “pai” não deve alterar o estado interno do agregado sem regras claras.
- O ciclo de vida é independente.
Delegação de responsabilidades: composição para agregar comportamento
Composição não é só “guardar referências”; é agregar comportamento por delegação. Um bom desenho evita classes anêmicas (objetos que só têm getters/setters) e coloca regras onde fazem sentido.
Exemplo: Pedido delega cálculo e validações aos itens
O Pedido calcula total somando item.subtotal(). O item encapsula como subtotal é calculado (quantidade × preço). Se amanhã houver desconto por item, a mudança fica localizada.
Exemplo: Extrair colaborador para regra de frete
Se o cálculo de frete ficar complexo, em vez de inflar Pedido, componha com um serviço/estratégia.
import java.math.BigDecimal;import java.util.Objects;interface CalculadoraFrete { BigDecimal calcular(Pedido pedido);}final class FreteFixo implements CalculadoraFrete { private final BigDecimal valor; FreteFixo(BigDecimal valor) { this.valor = Objects.requireNonNull(valor); } public BigDecimal calcular(Pedido pedido) { return valor; }}final class PedidoComFrete { private final Pedido pedido; private final CalculadoraFrete calculadoraFrete; PedidoComFrete(Pedido pedido, CalculadoraFrete calculadoraFrete) { this.pedido = Objects.requireNonNull(pedido); this.calculadoraFrete = Objects.requireNonNull(calculadoraFrete); } public BigDecimal totalComFrete() { return pedido.total().add(calculadoraFrete.calcular(pedido)); }}Aqui, o comportamento “total com frete” surge da composição: PedidoComFrete coordena Pedido e CalculadoraFrete sem herdar de nenhum.
Encapsulamento de coleções internas: protegendo invariantes
Quando uma classe contém uma coleção, o maior risco é expor a estrutura interna e permitir mutações externas. Três técnicas comuns:
1) Expor apenas operações de alto nível
Em vez de retornar a lista, ofereça métodos como adicionarItem, removerItemPorSku, total. Isso reduz a superfície de erro.
2) Retornar visão imutável
Use Collections.unmodifiableList para impedir alterações externas. Atenção: se os elementos forem mutáveis, ainda podem ser alterados por referência.
public List<ItemPedido> itens() { return Collections.unmodifiableList(itens);}3) Cópia defensiva (snapshot)
Quando você precisa garantir que o chamador não observe mudanças futuras (ou não consiga reter referência), retorne uma cópia:
public List<ItemPedido> itensSnapshot() { return List.copyOf(itens);}List.copyOf cria uma lista imutável com os mesmos elementos. Se ItemPedido for imutável (como no exemplo), isso é uma proteção forte.
Tabela: quando usar cada abordagem
| Abordagem | Quando usar | Observação |
|---|---|---|
| Não expor coleção | Quando a coleção é detalhe interno | Melhor para invariantes e simplicidade |
| unmodifiableList | Quando você quer permitir leitura e refletir mudanças internas | Elementos mutáveis ainda são um risco |
| List.copyOf (snapshot) | Quando você quer isolamento e previsibilidade | Bom para APIs públicas e logs/auditoria |
Passo a passo prático: refatorando herança indevida para composição
Um erro comum é usar herança para “pegar métodos prontos”, criando relações é-um falsas. A seguir, um roteiro de refatoração com exemplo.
Cenário: RelatórioCSV herda de ArrayList (herança indevida)
Imagine uma classe que representa um relatório e herda de ArrayList<String> só para reutilizar operações de lista:
import java.util.ArrayList;class RelatorioCsv extends ArrayList<String> { public String gerar() { return String.join("\n", this); }}Problemas: o relatório “vira” uma lista, expondo dezenas de métodos que não fazem sentido no domínio (ex.: ensureCapacity, trimToSize). Qualquer código pode remover/alterar linhas sem regras.
Passo 1: Trocar herança por campo privado
import java.util.ArrayList;import java.util.Collections;import java.util.List;import java.util.Objects;final class RelatorioCsv { private final List<String> linhas = new ArrayList<>(); public void adicionarLinha(String linha) { linhas.add(Objects.requireNonNull(linha)); } public String gerar() { return String.join("\n", linhas); } public List<String> linhas() { return Collections.unmodifiableList(linhas); }}Passo 2: Reintroduzir regras de domínio (evitar classe anêmica)
Se houver regras (ex.: cabeçalho obrigatório, colunas fixas), coloque métodos que expressem isso, em vez de expor manipulação livre:
final class RelatorioVendasCsv { private final RelatorioCsv relatorio = new RelatorioCsv(); private boolean cabecalhoAdicionado = false; public void adicionarCabecalho() { if (cabecalhoAdicionado) return; relatorio.adicionarLinha("data;produto;quantidade;total"); cabecalhoAdicionado = true; } public void adicionarVenda(String data, String produto, int quantidade, long totalEmCentavos) { if (!cabecalhoAdicionado) adicionarCabecalho(); relatorio.adicionarLinha(data + ";" + produto + ";" + quantidade + ";" + totalEmCentavos); } public String gerar() { return relatorio.gerar(); }}Agora o comportamento está no lugar certo: o relatório de vendas sabe como estruturar linhas e garante o cabeçalho.
Passo 3: Ajustar a API pública e remover dependências
- Procure usos externos que chamavam métodos de
ArrayList(ex.:add,remove) e substitua por métodos do novo objeto (adicionarLinha, etc.). - Se necessário, ofereça métodos específicos (ex.:
removerUltimaLinha) com validações. - Garanta que a coleção interna não vaze (retorne unmodifiable/snapshot).
Exercícios de refatoração (herança → composição)
Exercício 1: Stack herda de Vector
Você recebeu uma classe Pilha que herda de ArrayList<T> para reutilizar add e remove. Refatore para composição:
- Crie
final class Pilha<T>com um campoList<T>privado. - Implemente
push(T),pop(),peek(),tamanho(). - Não exponha a lista interna; se precisar listar, retorne
List.copyOf.
Exercício 2: UsuarioAdmin extends Usuario
Há uma hierarquia Usuario e UsuarioAdmin criada para “adicionar permissões”. Refatore para composição:
- Crie um objeto
PerfilAcesso(ouPermissoes) e façaUsuarioter umPerfilAcesso. - Implemente métodos como
pode(String acao)delegando ao perfil. - Remova condicionais do tipo
instanceof UsuarioAdminsubstituindo por consulta ao perfil.
Exercício 3: PedidoOnline extends Pedido
PedidoOnline herda de Pedido apenas para adicionar cálculo de frete e rastreio. Refatore:
- Mantenha
Pedidocomo núcleo (itens, total). - Crie
CalculadoraFretee componha em um objeto coordenador (ex.:PedidoComEntrega). - Crie um objeto
Rastreioe faça o coordenador delegar operações de rastreio.
Checklist de composição bem aplicada
- A relação modela claramente tem-um e não “é-um” por conveniência.
- Responsabilidades estão distribuídas: cada classe tem comportamento relevante, evitando objetos só com dados.
- Coleções internas não vazam: API expõe operações de domínio e/ou listas imutáveis/snapshots.
- Ownership e ciclo de vida estão coerentes: quem cria/gerencia, quem compartilha, quem pode substituir referências.
- Delegação é explícita e legível: métodos chamam colaboradores com nomes que refletem o domínio.