← Todos los patrones

Composite

Structural · ver código fuente en GitHub

Leer en: English · Português · Español

Category: Structural

El problema

Algunas cosas tienen forma de árbol de manera natural: un sistema de archivos, un organigrama, un conjunto de reglas de negocio que combinan otras reglas. Un código que tiene que tratar un elemento hoja único y un grupo entero de elementos de forma distinta — comprobando if (isGroup) { ... } else { ... } en todas partes — desarrolla un caso especial en cada nivel de anidamiento, y agregar un nivel más de agrupación significa tocar cada lugar que hacía esa comprobación.

La solución

Darle a las hojas y a los grupos la misma interfaz. Un grupo la implementa delegando a cada uno de sus hijos y combinando sus resultados; una hoja la implementa directamente. Los llamadores trabajan con la interfaz y nunca necesitan saber o comprobar si están sosteniendo un elemento único o un subárbol entero — un grupo puede contener otro grupo, a cualquier profundidad, sin código extra en ningún lado.

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

Ejemplo clásico

classic/FileSystemComponent es el ejemplo canónico: FileLeaf reporta su propio tamaño, y Directory reporta la suma de los tamaños de sus hijos — recursivamente, de modo que un directorio que contiene directorios que contienen archivos simplemente funciona, con exactamente la misma implementación de una línea de sizeBytes() sin importar cuán profundo sea realmente el árbol. DirectoryTest cubre una hoja única, un directorio plano, y un árbol anidado en tres niveles.

Ejemplo aplicado: motor de reglas de aprobación de crédito componible

applied/ApprovalRule está implementada por reglas hoja — MinimumIncomeRule, MaximumLoanToIncomeRatioRule, NoActiveDefaultsRule — y por dos grupos de reglas compuestos, AllOfRuleGroup y AnyOfRuleGroup, cualquiera de los cuales puede contener reglas hoja u otros grupos de reglas. Eso es lo que permite que una política de aprobación real exprese algo como "ingreso mínimo Y (razón préstamo-ingreso OK O sin incumplimientos activos)" como un único árbol compuesto de objetos ApprovalRule, evaluado con una sola llamada a isSatisfied(), en vez de una expresión booleana escrita a mano que hay que rederivar cada vez que cambia la política. ApprovalRuleTest cubre un grupo de reglas plano aprobando y rechazando solicitudes, un grupo de reglas anidado dentro de otro grupo de reglas, y que ambos tipos de grupo construyen una description() legible a partir de las descripciones de sus propios hijos.

Cuándo no usarlo

Cobertura de pruebas

100% de cobertura de instrucciones, 100% de cobertura de ramas (JaCoCo). Reprodúzcalo usted mismo:

./gradlew :structural:composite:jacocoTestReport

Informe en structural/composite/build/reports/jacoco/test/html/index.html.

Lecturas adicionales

Pruebas unitarias

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 informe completo de cobertura JaCoCo →