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
- Se a "árvore" só tem um nível (uma lista plana, nunca grupos aninhados), Composite é
maquinaria desnecessária — uma
List<Rule>simples e um loop fazem o mesmo trabalho com menos indireção. - Composite facilita adicionar um componente novo que satisfaz a interface mas não se comporta realmente como uma parte bem-formada da árvore (uma folha que tenta ter filhos, digamos). Mantenha o contrato da interface simples o suficiente pra que todo implementador consiga honrá-lo de forma significativa.
- Se folhas e grupos genuinamente precisam de operações muito diferentes (não só "a mesma operação, computada de forma diferente"), forçá-los numa única interface cria métodos que não fazem sentido pra um lado ou pro outro — não force a forma se o domínio não a tem de fato.
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
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. — o Capítulo 4 formaliza o Composite; o próprio exemplo do livro é exatamente o exemplo clássico deste repositório, um editor de gráficos/documentos tratando um grupo de formas e uma forma única de maneira uniforme.
- Liskov, B., & Wing, J. (1994). "A Behavioral Notion of Subtyping." ACM Transactions on
Programming Languages and Systems, 16(6), 1811–1841. — o mesmo princípio de
substituibilidade citado nos módulos Strategy e
Factory Method deste repositório é o que faz o Composite
funcionar: um chamador segurando uma
ApprovalRuleprecisa se comportar corretamente independente de estar segurando de fato uma regra folha ou uma árvore de regras inteira aninhada.
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)");
}
}