← All patterns

Composite

Structural · view source on GitHub

Read this in: English · Português · Español

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

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

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)");
    }
}

View full JaCoCo coverage report →