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
- Si el "árbol" solo tiene un nivel (una lista plana, nunca grupos anidados), Composite es
maquinaria innecesaria — una
List<Rule>simple y un bucle hacen el mismo trabajo con menos indirección. - Composite facilita agregar un componente nuevo que satisface la interfaz pero en realidad no se comporta como una parte bien formada del árbol (una hoja que intenta tener hijos, por ejemplo). Mantenga el contrato de la interfaz lo bastante simple para que cada implementador pueda cumplirlo de manera significativa.
- Si las hojas y los grupos realmente necesitan operaciones muy distintas (no solo "la misma operación, calculada de forma diferente"), forzarlos a una única interfaz crea métodos que no tienen sentido para un lado o el otro — no fuerce la forma si el dominio en realidad no la tiene.
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
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. — el Capítulo 4 formaliza Composite; el propio ejemplo del libro es exactamente el ejemplo clásico de este repositorio, un editor de gráficos/documentos que trata un grupo de figuras y una figura única de manera uniforme.
- Liskov, B., & Wing, J. (1994). "A Behavioral Notion of Subtyping." ACM Transactions on
Programming Languages and Systems, 16(6), 1811–1841. — el mismo principio de
sustituibilidad citado en los módulos Strategy y
Factory Method de este repositorio es lo que hace que
Composite funcione: un llamador que sostiene una
ApprovalRuledebe comportarse correctamente sin importar si en realidad sostiene una regla hoja o un árbol de reglas anidado completo.
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)");
}
}