← All patterns

Decorator

Structural · view source on GitHub

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

Category: Structural

The problem

An object needs extra responsibilities added to it, but not every instance needs the same combination of extras, and inheritance can't express that cleanly. Modeling every combination as a subclass (EspressoWithMilk, EspressoWithMilkAndSugar, EspressoWithSugarAndSugar, ...) explodes combinatorially, and it's fixed at compile time — a subclass can't be added or removed from an object once it's built. What's needed is a way to wrap an object in layers of behavior, chosen and stacked at runtime.

The solution

Give the wrapper the same interface as the thing it wraps, so it can stand in for it anywhere, and have it delegate to the wrapped object plus add its own behavior before or after. Stack wrappers to combine responsibilities; each one only knows about the interface, never the concrete class underneath.

classDiagram
    class Component {
        <<interface>>
    }
    class ConcreteComponent
    class Decorator {
        -component
    }
    class ConcreteDecoratorA
    class ConcreteDecoratorB
    Component <|.. ConcreteComponent
    Component <|.. Decorator
    Decorator o-- Component
    Decorator <|-- ConcreteDecoratorA
    Decorator <|-- ConcreteDecoratorB

Classic example

classic/Beverage is the canonical coffee-shop example: an Espresso wrapped in Milk and/or Sugar, each adding its own text to description() and its own cents to costCents() on top of whatever it wraps. new Sugar(new Milk(new Espresso())) is still a Beverage — nothing distinguishes a decorated beverage from a plain one at the type level, which is exactly the point. BeverageDecoratorTest covers an undecorated beverage, a stack of two different condiments, and the same condiment applied twice (proving decorators compose, not just toggle a flag).

Applied example: transaction enrichment pipeline

applied/CoreTransactionProcessor is wrapped by FraudCheckDecorator, LgpdAuditDecorator (Brazil's data-protection law), and RateLimitDecorator — each one a concern a real payments pipeline needs, and each one addable or removable without touching the core processor or the others. RateLimitDecorator also shows a decorator doesn't have to just add behavior after delegating: once a payer is over quota it returns its own result and never calls the rest of the chain at all, the same short-circuiting a real rate limiter needs. TransactionProcessorDecoratorTest covers the full stack approving a normal transaction (checking the audit trail is in the exact wrapping order), the fraud check flagging a large one, and the rate limiter both passing transactions through and short-circuiting once the quota is exceeded.

When not to use it

Test coverage

100% instruction coverage, 100% branch coverage (JaCoCo). Reproduce it yourself:

./gradlew :structural:decorator:jacocoTestReport

Report at structural/decorator/build/reports/jacoco/test/html/index.html.

Further reading

Unit tests

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

View full JaCoCo coverage report →