← Todos os padrões

Composite

Structural · ver código-fonte no GitHub

Leia em: English · Português · Español

Category: Structural

O problema

Algumas coisas têm forma de árvore naturalmente: um sistema de arquivos, um organograma, um conjunto de regras de negócio que combinam outras regras. Código que precisa tratar um item folha único e um grupo inteiro de itens de forma diferente — checando if (isGroup) { ... } else { ... } em todo lugar — cresce um caso especial em cada nível de aninhamento, e adicionar mais um nível de agrupamento significa tocar em todo lugar que fazia essa checagem.

A solução

Dar a folhas e grupos a mesma interface. Um grupo a implementa delegando pra cada um de seus filhos e combinando os resultados; uma folha a implementa diretamente. Chamadores trabalham com a interface e nunca precisam saber ou checar se estão segurando um item único ou uma subárvore inteira — um grupo pode conter outro grupo, até qualquer profundidade, sem código extra em lugar nenhum.

classDiagram
    class Component {
        <<interface>>
        +operation()
    }
    class Leaf {
        +operation()
    }
    class Composite {
        -children
        +operation()
        +add(c)
    }
    Component <|.. Leaf
    Component <|.. Composite
    Composite o-- Component

Exemplo clássico

classic/FileSystemComponent é o exemplo canônico: FileLeaf reporta seu próprio tamanho, e Directory reporta a soma dos tamanhos dos seus filhos — recursivamente, de modo que um diretório contendo diretórios contendo arquivos simplesmente funciona, com exatamente a mesma implementação de uma linha de sizeBytes() independente de quão fundo a árvore realmente é. DirectoryTest cobre uma folha única, um diretório plano, e uma árvore aninhada em três níveis.

Exemplo aplicado: motor de regras de aprovação de crédito componível

applied/ApprovalRule é implementada por regras folha — MinimumIncomeRule, MaximumLoanToIncomeRatioRule, NoActiveDefaultsRule — e por dois grupos de regras compostos, AllOfRuleGroup e AnyOfRuleGroup, qualquer um dos quais pode conter regras folha ou outros grupos de regras. É isso que permite que uma política de aprovação real expresse algo como "renda mínima E (razão empréstimo-renda OK OU nenhum default ativo)" como uma única árvore composta de objetos ApprovalRule, avaliada com uma única chamada isSatisfied(), em vez de uma expressão booleana escrita à mão que precisa ser re-derivada toda vez que a política muda. ApprovalRuleTest cobre um grupo de regras plano aprovando e rejeitando aplicações, um grupo de regras aninhado dentro de outro grupo de regras, e que os dois tipos de grupo constroem uma description() legível a partir das descrições dos próprios filhos.

Quando não usar

Cobertura de testes

100% de cobertura de instrução, 100% de cobertura de branch (JaCoCo). Reproduza você mesmo:

./gradlew :structural:composite:jacocoTestReport

Relatório em structural/composite/build/reports/jacoco/test/html/index.html.

Leitura complementar

Testes unitários

src/test/java/com/designpatterns/structural/composite/classic/DirectoryTest.java
package com.designpatterns.structural.composite.classic;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class DirectoryTest {

    @Test
    void aLeafFileReportsItsOwnNameAndSize() {
        FileLeaf file = new FileLeaf("readme.txt", 120);

        assertThat(file.name()).isEqualTo("readme.txt");
        assertThat(file.sizeBytes()).isEqualTo(120);
    }

    @Test
    void aFlatDirectoryReportsItsOwnNameAndSumsItsDirectChildren() {
        Directory root = new Directory("root");
        root.add(new FileLeaf("a.txt", 100));
        root.add(new FileLeaf("b.txt", 200));

        assertThat(root.name()).isEqualTo("root");
        assertThat(root.sizeBytes()).isEqualTo(300);
    }

    @Test
    void aNestedDirectoryTreeSumsRecursivelyThroughEveryLevel() {
        Directory root = new Directory("root");
        root.add(new FileLeaf("top.txt", 50));

        Directory subDir = new Directory("sub");
        subDir.add(new FileLeaf("nested1.txt", 30));
        subDir.add(new FileLeaf("nested2.txt", 20));

        Directory deeperDir = new Directory("deeper");
        deeperDir.add(new FileLeaf("deepest.txt", 10));
        subDir.add(deeperDir);

        root.add(subDir);

        assertThat(root.sizeBytes()).isEqualTo(50 + 30 + 20 + 10);
    }
}
src/test/java/com/designpatterns/structural/composite/applied/ApprovalRuleTest.java
package com.designpatterns.structural.composite.applied;

import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

class ApprovalRuleTest {

    private final ApprovalRule standardRules = new AllOfRuleGroup(List.of(
            new MinimumIncomeRule(5_000_00L),
            new MaximumLoanToIncomeRatioRule(5.0),
            new NoActiveDefaultsRule()
    ));

    @Test
    void anApplicationThatClearsEveryRuleIsApproved() {
        LoanApplication application = new LoanApplication(10_000_00L, 30_000_00L, false);

        assertThat(standardRules.isSatisfied(application)).isTrue();
    }

    @Test
    void anApplicationBelowTheMinimumIncomeIsRejected() {
        LoanApplication application = new LoanApplication(1_000_00L, 3_000_00L, false);

        assertThat(standardRules.isSatisfied(application)).isFalse();
    }

    @Test
    void anApplicationWithActiveDefaultsIsRejectedEvenIfEverythingElsePasses() {
        LoanApplication application = new LoanApplication(10_000_00L, 30_000_00L, true);

        assertThat(standardRules.isSatisfied(application)).isFalse();
    }

    @Test
    void ruleGroupsCanNestOtherRuleGroups() {
        ApprovalRule nestedRules = new AllOfRuleGroup(List.of(
                new MinimumIncomeRule(5_000_00L),
                new AnyOfRuleGroup(List.of(
                        new MaximumLoanToIncomeRatioRule(1.0),
                        new NoActiveDefaultsRule()
                ))
        ));
        // Ratio is too high (3.0 > 1.0) but the "no active defaults" alternative still holds,
        // so the nested AnyOf is satisfied, and so is the outer AllOf.
        LoanApplication application = new LoanApplication(10_000_00L, 30_000_00L, false);

        assertThat(nestedRules.isSatisfied(application)).isTrue();
    }

    @Test
    void aNestedAnyOfGroupFailsWhenNoAlternativeHolds() {
        ApprovalRule nestedRules = new AnyOfRuleGroup(List.of(
                new MaximumLoanToIncomeRatioRule(1.0),
                new NoActiveDefaultsRule()
        ));
        LoanApplication application = new LoanApplication(10_000_00L, 30_000_00L, true);

        assertThat(nestedRules.isSatisfied(application)).isFalse();
    }

    @Test
    void groupDescriptionsJoinEveryChildRulesDescription() {
        ApprovalRule group = new AllOfRuleGroup(List.of(
                new MinimumIncomeRule(5_000_00L),
                new NoActiveDefaultsRule()
        ));

        assertThat(group.description()).isEqualTo("ALL OF (monthly income >= 500000 cents, no active defaults)");
    }

    @Test
    void anyOfGroupDescriptionJoinsItsChildRulesDescriptionsToo() {
        ApprovalRule group = new AnyOfRuleGroup(List.of(
                new MaximumLoanToIncomeRatioRule(5.0),
                new NoActiveDefaultsRule()
        ));

        assertThat(group.description()).isEqualTo("ANY OF (loan-to-income ratio <= 5.0, no active defaults)");
    }
}

Ver relatório completo de cobertura JaCoCo →