Composição em Java OOP: modelagem com 'tem-um' e agregação de comportamento

Capítulo 4

Tempo estimado de leitura: 9 minutos

+ Exercício

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.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

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.: Pedido cria/gerencia seus ItemPedido e eles não fazem sentido fora do pedido.
  • Agregação (ownership fraco): o objeto “pai” referencia algo que pode existir independentemente. Ex.: Conta referencia um Cliente que 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

AbordagemQuando usarObservação
Não expor coleçãoQuando a coleção é detalhe internoMelhor para invariantes e simplicidade
unmodifiableListQuando você quer permitir leitura e refletir mudanças internasElementos mutáveis ainda são um risco
List.copyOf (snapshot)Quando você quer isolamento e previsibilidadeBom 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 campo List<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 (ou Permissoes) e faça Usuario ter um PerfilAcesso.
  • Implemente métodos como pode(String acao) delegando ao perfil.
  • Remova condicionais do tipo instanceof UsuarioAdmin substituindo 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 Pedido como núcleo (itens, total).
  • Crie CalculadoraFrete e componha em um objeto coordenador (ex.: PedidoComEntrega).
  • Crie um objeto Rastreio e 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.

Agora responda o exercício sobre o conteúdo:

Ao refatorar uma classe que herdava de uma coleção apenas para reutilizar métodos, qual mudança caracteriza corretamente a troca de herança por composição com melhor encapsulamento?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

A composição evita uma relação “é-um” falsa e reduz o acoplamento com APIs de coleção. Com um campo privado, a classe controla invariantes e expõe apenas comportamentos do domínio, podendo retornar lista imutável ou snapshot.

Próximo capitúlo

Herança em Java OOP: especialização, polimorfismo e limites do 'é-um'

Arrow Right Icon
Capa do Ebook gratuito Java Orientado a Objetos: Do Conceito ao Código com Padrões de Projeto Básicos
24%

Java Orientado a Objetos: Do Conceito ao Código com Padrões de Projeto Básicos

Novo curso

17 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.