Category: Structural
El problema
Un objeto necesita responsabilidades extra agregadas, pero no toda instancia necesita la misma
combinación de extras, y la herencia no puede expresar eso con limpieza. Modelar cada
combinación como una subclase (EspressoWithMilk, EspressoWithMilkAndSugar,
EspressoWithSugarAndSugar, ...) explota combinatoriamente, y queda fijo en tiempo de
compilación — una subclase no se puede agregar ni quitar de un objeto una vez construido. Lo que
se necesita es una forma de envolver un objeto en capas de comportamiento, elegidas y apiladas
en tiempo de ejecución.
La solución
Darle al envoltorio la misma interfaz de lo que envuelve, para que pueda sustituirlo en cualquier lugar, y hacer que delegue al objeto envuelto además de agregar su propio comportamiento antes o después. Apile envoltorios para combinar responsabilidades; cada uno solo conoce la interfaz, nunca la clase concreta debajo.
classDiagram
class Component {
<<interface>>
}
class ConcreteComponent
class Decorator {
-component
}
class ConcreteDecoratorA
class ConcreteDecoratorB
Component <|.. ConcreteComponent
Component <|.. Decorator
Decorator o-- Component
Decorator <|-- ConcreteDecoratorA
Decorator <|-- ConcreteDecoratorB
Ejemplo clásico
classic/Beverage
es el ejemplo canónico de la cafetería: un Espresso
envuelto en Milk
y/o Sugar, cada
uno agregando su propio texto a description() y sus propios centavos a costCents() encima
de lo que envuelve. new Sugar(new Milk(new Espresso())) sigue siendo un Beverage — nada
distingue una bebida decorada de una simple a nivel de tipo, que es exactamente el punto.
BeverageDecoratorTest
cubre una bebida sin decorar, una pila de dos condimentos distintos, y el mismo condimento
aplicado dos veces (probando que los decoradores se componen, no solo alternan una bandera).
Ejemplo aplicado: pipeline de enriquecimiento de transacciones
applied/CoreTransactionProcessor
está envuelto por FraudCheckDecorator,
LgpdAuditDecorator
(la ley brasileña de protección de datos) y RateLimitDecorator
— cada uno una preocupación que un pipeline de pagos real necesita, y cada uno se puede agregar
o quitar sin tocar el procesador central ni los demás. RateLimitDecorator también muestra que
un decorador no tiene que solo agregar comportamiento después de delegar: una vez que un
pagador supera la cuota, devuelve su propio resultado y nunca llama al resto de la cadena — el
mismo corte que un limitador de tasa real necesita.
TransactionProcessorDecoratorTest
cubre la pila completa aprobando una transacción normal (verificando que el rastro de auditoría
esté en el orden exacto de envoltura), la verificación de fraude marcando una grande, y el
limitador de tasa tanto dejando pasar transacciones como cortando al superar la cuota.
Cuándo no usarlo
- Si solo existe una combinación fija de comportamiento extra, un decorador es indirección sin beneficio — basta con poner el comportamiento en la clase.
- Una cadena larga de decoradores puede dificultar la depuración: un stack trace pasa por cada capa, y "qué hace realmente este objeto" exige leer toda la cadena, no solo una clase. Mantenga las cadenas cortas y el trabajo de cada decorador acotado.
- Si el "comportamiento extra" necesita cambiar lo que el objeto es, no solo agregar a lo que hace (cambiando su identidad o tipo), un decorador es la herramienta equivocada — eso es trabajo de otro patrón (Strategy, State) o simplemente un diseño distinto.
Cobertura de pruebas
100% de cobertura de instrucciones, 100% de cobertura de ramas (JaCoCo). Reprodúzcalo usted mismo:
./gradlew :structural:decorator:jacocoTestReport
Informe en structural/decorator/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 Decorator.
- Bloch, J. (2018). Effective Java (3.ª ed.), Item 18: "Favor composition over inheritance." Addison-Wesley. — el principio general del que Decorator es una aplicación estructurada: la explosión de subclases del ejemplo del café es exactamente el modo de falla contra el que advierte este ítem.
Pruebas unitarias
src/test/java/com/designpatterns/structural/decorator/classic/BeverageDecoratorTest.java
package com.designpatterns.structural.decorator.classic;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class BeverageDecoratorTest {
@Test
void aPlainBeverageHasNoCondiments() {
Beverage order = new Espresso();
assertThat(order.description()).isEqualTo("Espresso");
assertThat(order.costCents()).isEqualTo(250L);
}
@Test
void stacksDescriptionAndCostForEachCondimentInWrappingOrder() {
Beverage order = new Sugar(new Milk(new Espresso()));
assertThat(order.description()).isEqualTo("Espresso + Milk + Sugar");
assertThat(order.costCents()).isEqualTo(320L);
}
@Test
void theSameCondimentCanBeAppliedMoreThanOnce() {
Beverage order = new Sugar(new Sugar(new Espresso()));
assertThat(order.description()).isEqualTo("Espresso + Sugar + Sugar");
assertThat(order.costCents()).isEqualTo(290L);
}
}
src/test/java/com/designpatterns/structural/decorator/applied/TransactionProcessorDecoratorTest.java
package com.designpatterns.structural.decorator.applied;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TransactionProcessorDecoratorTest {
@Test
void approvesANormalTransactionAndRecordsEachLayersNoteInWrappingOrder() {
TransactionProcessor pipeline = new LgpdAuditDecorator(new FraudCheckDecorator(new CoreTransactionProcessor()));
Transaction transaction = new Transaction("tx-1", 10_000_00L, "payer-1");
ProcessingResult result = pipeline.process(transaction);
assertThat(result.approved()).isTrue();
assertThat(result.auditTrail()).containsExactly(
"core: transaction accepted",
"fraud-check: amount within normal range",
"lgpd-audit: access to payer payer-1 logged for compliance"
);
}
@Test
void flagsATransactionAboveTheFraudThreshold() {
TransactionProcessor pipeline = new FraudCheckDecorator(new CoreTransactionProcessor());
Transaction transaction = new Transaction("tx-2", 60_000_00L, "payer-2");
ProcessingResult result = pipeline.process(transaction);
assertThat(result.approved()).isFalse();
assertThat(result.auditTrail()).anyMatch(note -> note.contains("fraud-check"));
}
@Test
void rateLimitDecoratorShortCircuitsWithoutCallingTheRestOfThePipelineOnceTheQuotaIsExceeded() {
TransactionProcessor pipeline = new RateLimitDecorator(new CoreTransactionProcessor(), 2);
Transaction transaction = new Transaction("tx-3", 1_00L, "payer-3");
pipeline.process(transaction);
pipeline.process(transaction);
ProcessingResult thirdCall = pipeline.process(transaction);
assertThat(thirdCall.approved()).isFalse();
assertThat(thirdCall.auditTrail()).containsExactly("rate-limit: payer exceeded 2 requests");
}
@Test
void rateLimitDecoratorPassesThroughAndAnnotatesCallsWithinQuota() {
TransactionProcessor pipeline = new RateLimitDecorator(new CoreTransactionProcessor(), 5);
Transaction transaction = new Transaction("tx-4", 1_00L, "payer-4");
ProcessingResult result = pipeline.process(transaction);
assertThat(result.approved()).isTrue();
assertThat(result.auditTrail()).containsExactly(
"core: transaction accepted",
"rate-limit: within quota (1/5)"
);
}
}