Category: Structural
The problem
Some things are naturally tree-shaped: a file system, an org chart, a set of business rules
that combine other rules. Code that has to treat a single leaf item and a whole group of items
differently — checking if (isGroup) { ... } else { ... } everywhere — grows a special case at
every level of nesting, and adding one more level of grouping means touching every place that
made that check.
The solution
Give leaves and groups the same interface. A group implements it by delegating to each of its children and combining their results; a leaf implements it directly. Callers work with the interface and never need to know or check whether they're holding a single item or an entire subtree — a group can contain another group, to any depth, with no extra code anywhere.
classDiagram
class Component {
<<interface>>
+operation()
}
class Leaf {
+operation()
}
class Composite {
-children
+operation()
+add(c)
}
Component <|.. Leaf
Component <|.. Composite
Composite o-- Component
Classic example
classic/FileSystemComponent
is the canonical example: FileLeaf
reports its own size, and Directory
reports the sum of its children's sizes — recursively, so a directory containing directories
containing files still just works, with the exact same one-line sizeBytes() implementation
regardless of how deep the tree actually is.
DirectoryTest
covers a single leaf, a flat directory, and a tree nested three levels deep.
Applied example: composable credit approval rule engine
applied/ApprovalRule
is implemented by leaf rules — MinimumIncomeRule,
MaximumLoanToIncomeRatioRule,
NoActiveDefaultsRule
— and by two composite rule groups, AllOfRuleGroup
and AnyOfRuleGroup,
either of which can contain leaf rules or other rule groups. That's what lets a real approval
policy express something like "minimum income AND (loan-to-income ratio OK OR no active
defaults)" as one composed tree of ApprovalRule objects, evaluated with a single
isSatisfied() call, instead of a hand-written boolean expression that has to be re-derived
every time the policy changes.
ApprovalRuleTest
covers a flat rule group approving and rejecting applications, a rule group nested inside
another rule group, and that both group types build a readable description() out of their
children's own descriptions.
When not to use it
- If the "tree" only ever has one level (a flat list, never nested groups), Composite is
unnecessary machinery — a plain
List<Rule>and a loop does the same job with less indirection. - Composite makes it easy to add a new component that satisfies the interface but doesn't really behave like a well-formed part of the tree (a leaf that tries to have children, say). Keep the interface's contract simple enough that every implementer can honor it meaningfully.
- If leaves and groups genuinely need very different operations (not just "the same operation, computed differently"), forcing them into one interface creates methods that don't make sense for one side or the other — don't force the shape if the domain doesn't actually have it.
Test coverage
100% instruction coverage, 100% branch coverage (JaCoCo). Reproduce it yourself:
./gradlew :structural:composite:jacocoTestReport
Report at structural/composite/build/reports/jacoco/test/html/index.html.
Further reading
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. — Chapter 4 formalizes Composite; the book's own example is exactly this repo's classic one, a graphics/document editor treating a group of shapes and a single shape uniformly.
- Liskov, B., & Wing, J. (1994). "A Behavioral Notion of Subtyping." ACM Transactions on
Programming Languages and Systems, 16(6), 1811–1841. — the same substitutability principle
cited in this repo's Strategy and Factory Method
modules is what makes Composite work at all: a caller holding an
ApprovalRulemust behave correctly whether it's actually holding a leaf rule or an entire nested rule tree.
Unit tests
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)");
}
}