The ticket says “20% off the Outdoor department, including nested Hiking and the featured tent SKU sitting next to those categories.” You do not have a tree. You have a list of SKUs plus two named buckets, and DepartmentOff already special-cases “if Hiking, loop the boots, else if a SKU, take its price.” Adding Camping as a subcategory of Outdoor means another branch in the same walker — and a second walker in the admin “what’s in this promo?” screen.

Checkout will not grow that tree for you. OrderProcessor charges one BigDecimal. The tree lives in the catalog (or in a nested discount group treated as one policy). That is Composite’s entire complaint. From the Design Patterns Roadmap: a group and a leaf should share an interface so callers can treat the whole tree as one object.

This post uses a second tiny example on purpose. The shared Order / PaymentGateway / DiscountPolicy / OrderProcessor lab still charges at the end. We do not re-lecture the three families. We build a catalog tree because the promo walks a hierarchy — not because an order line count of two looked lonely.

The walker that knows every shape

Here is the pricing helper after Outdoor gained Hiking and one featured SKU. Nobody wrote this badly on purpose:

public final class OutdoorPromo {

    private final List<Sku> featured;
    private final List<Sku> hiking;
    private final BigDecimal percentOff;

    public BigDecimal discountableSubtotal() {
        BigDecimal sum = BigDecimal.ZERO;
        for (Sku sku : featured) {
            sum = sum.add(sku.price());
        }
        for (Sku sku : hiking) {
            sum = sum.add(sku.price());
        }
        return sum;
    }

    public BigDecimal payable(Order order) {
        BigDecimal off = discountableSubtotal()
                .multiply(percentOff.movePointLeft(2))
                .setScale(2, RoundingMode.HALF_UP);
        BigDecimal payable = order.total().subtract(off);
        return payable.signum() < 0 ? BigDecimal.ZERO : payable;
    }
}

Now price Camping — and the admin screen that must print the same tree:

Add Camping under Outdoor     -> third list, third loop, same percent math
Nest TrailFood under Hiking   -> OutdoorPromo does not have a slot for it
Print the promo tree          -> copy the loops into a second class
Test Hiking without Outdoor   -> you cannot; the walker owns every bucket

Four costs, and none of them are about charging a card. Every new level of the catalog has become a field on the promo class.

Note: The problem is not a for loop. Summing two SKUs in a flat List is fine. The problem is a hierarchy — categories that contain SKUs and other categories — encoded as parallel lists plus shape checks.

What Composite actually is

Three parts, one promise:

PartIn this labJob
ComponentCatalogNodeThe interface both leaves and groups implement.
LeafSkuNo children. Answers subtotal() from its own price.
CompositeCategoryHolds CatalogNode children. subtotal() sums them.

Callers ask the root for subtotal(). They do not ask whether this node is a SKU or a category. If payable starts with if (node instanceof Category), you did not compose; you rebuilt the walker.

You do not need a class named Composite. You need the verb the caller already understands — here, “what does this node cost” — implemented by both the product and the department:

public interface CatalogNode {

    String name();

    BigDecimal subtotal();
}

That is the seam. A SKU and a category are both nodes.

Leaves and groups answer the same verb

Each SKU is a leaf. It has a price. It does not know it lives under Hiking:

public final class Sku implements CatalogNode {

    private final String name;
    private final BigDecimal price;

    public Sku(String name, BigDecimal price) {
        this.name = name;
        this.price = price;
    }

    @Override
    public String name() {
        return name;
    }

    @Override
    public BigDecimal subtotal() {
        return price;
    }
}

A category holds nodes — SKUs, other categories, mixed. Summing is the same call it would make on a single product:

public final class Category implements CatalogNode {

    private final String name;
    private final List<CatalogNode> children = new ArrayList<>();

    public Category(String name) {
        this.name = name;
    }

    public Category add(CatalogNode child) {
        children.add(child);
        return this;
    }

    @Override
    public String name() {
        return name;
    }

    @Override
    public BigDecimal subtotal() {
        BigDecimal sum = BigDecimal.ZERO;
        for (CatalogNode child : children) {
            sum = sum.add(child.subtotal());
        }
        return sum;
    }
}

Camping is new Category("Camping").add(new Sku("Lantern", ...)). Hiking can contain TrailFood later without OutdoorPromo growing a field. The tree is data. The percent math does not know the shape:

public final class DepartmentOff implements DiscountPolicy {

    private final CatalogNode department;
    private final BigDecimal percentOff;

    public DepartmentOff(CatalogNode department, BigDecimal percentOff) {
        this.department = department;
        this.percentOff = percentOff;
    }

    @Override
    public BigDecimal payable(Order order) {
        BigDecimal off = department.subtotal()
                .multiply(percentOff.movePointLeft(2))
                .setScale(2, RoundingMode.HALF_UP);
        BigDecimal payable = order.total().subtract(off);
        return payable.signum() < 0 ? BigDecimal.ZERO : payable;
    }
}

OrderProcessor does not change. It still asks discount.payable(order) and charges that amount. Wiring the Outdoor tree happens at the edge — a catalog factory, a CMS mapper — the same place promo codes already become DiscountPolicy objects:

Category hiking = new Category("Hiking")
        .add(new Sku("Trail boots", new BigDecimal("120.00")))
        .add(new Sku("Pack", new BigDecimal("80.00")));
Category outdoor = new Category("Outdoor")
        .add(hiking)
        .add(new Sku("Featured tent", new BigDecimal("200.00")));
DiscountPolicy discount = new DepartmentOff(outdoor, new BigDecimal("20"));
OrderProcessor processor = new OrderProcessor(gateway, discount, orders);

Note: Keep subtotal() dumb. The moment Category.subtotal starts charging cards, reading “is this SKU on the current order,” or skipping nodes based on a gold-member flag, the walker has moved into the tree. Membership and “is this line on the order” belong in the policy or the catalog mapper — not in every node.

A nested DiscountPolicy is the same pattern with a different verb. DiscountGroup implements DiscountPolicy, holds child policies (leaves like PercentOff, or other groups), and payable treats the group as one algorithm — min of children, or stacked, depending on the product. Use that when the rules nest. Use CatalogNode when the merchandise nests. Do not build both for one campaign.

What the diff looks like now

Same Camping ticket, both designs:

Before — third list on OutdoorPromo
  M OutdoorPromo.java        new field, new loop, admin copy still stale
  M OutdoorPromoTest.java    every assertion knows featured vs hiking

After — Camping is another node
  A Category.java / Sku.java already existed; add a child at the edge
  M CatalogFactory.java      outdoor.add(camping)
  A CategoryTest.java        subtotal of mixed children, no gateway

A new department is an add, not a new loop. DepartmentOff is closed against “marketing nested another category” and still fully open when the percent changes. OrderProcessor never walked the tree and still does not.

Proving the tree without charging a card

The seam pays a second dividend: you can test a leaf, a group, and the policy without a PaymentGateway.

@Test
void categorySubtotalSumsNestedSku() {
    CatalogNode hiking = new Category("Hiking")
            .add(new Sku("Boots", new BigDecimal("100.00")));
    CatalogNode outdoor = new Category("Outdoor")
            .add(hiking)
            .add(new Sku("Tent", new BigDecimal("50.00")));

    assertEquals(new BigDecimal("150.00"), outdoor.subtotal());
}

@Test
void departmentOffUsesTreeNotProcessor() {
    CatalogNode outdoor = new Category("Outdoor")
            .add(new Sku("Tent", new BigDecimal("50.00")));
    DiscountPolicy policy = new DepartmentOff(outdoor, new BigDecimal("20"));
    Order order = new Order("o-1", "a@b.com", List.of(), new BigDecimal("80.00"));

    assertEquals(new BigDecimal("70.00"), policy.payable(order));
}

A processor test still injects new FixedDiscount(...) from the Strategy post. It should not construct an Outdoor tree unless you are testing catalog wiring. If a declined-charge test needs Hiking’s boots, you have mixed two reasons to change.

When Composite is the wrong move

Skip the tree when:

  • The structure is a flat list of two items. List<Sku> plus a stream sum is the model. A Category with two children, one interface, and add, is ceremony. The trigger is a node that can contain another node of the same type, not a list length of two.
  • Callers must know the shape anyway. If every client does if (node instanceof Sku) to print a SKU code vs a department header, you still need a second method (String skuCode() vs List<CatalogNode> children()). Composite does not erase UI. It erases the same recursive walk in pricing, tax, and “what’s in the promo.”
  • You are faking a tree to look designed. LineGroup around a single LineItem so “everything is a composite” is an extra type that never nests. Wait for the second level.
  • The children are different verbs. A PaymentGateway plus a DiscountPolicy in a list is not Composite. They do not share subtotal(). That is a service locator wearing a pattern name.

The healthy trigger is a ticket that adds a nested group — a category of SKUs, a menu of line-item groups, a DiscountGroup of policies — and a caller that should not care which it received.

Composite also is not Decorator. Decorator wraps one collaborator to add behavior. Composite holds many children and forwards a uniform operation into the tree. A LoggingGateway is one inner gateway. A Category is a list of nodes.

Cheat sheet

Component   CatalogNode.subtotal     leaf and group both answer
Leaf        Sku                      own price, no children
Composite   Category                 children: Sku or Category; sum
Caller      DepartmentOff / Order    asks the root; never instanceof

Trigger to apply: a node can contain another node of the same type
Trigger to stop:  a flat list of two SKUs, or callers always branch on shape
Scoreboard:       new subcategory = add(); DepartmentOff and process() stay put
Not Decorator:    many children, uniform walk vs one wrap, extra behavior

Do:

  • Name the interface after the verb (CatalogNode, PayableLine, DiscountPolicy), not Composite.
  • Let subtotal() / payable() recurse. Do not leak List<CatalogNode> to OrderProcessor.
  • Test a leaf and a mixed tree without a gateway; test process with a fake policy.
  • Use a nested DiscountGroup only when policies nest; use Category when merchandise nests.

Don’t:

  • Invent a Category for two SKUs that will never nest.
  • Put instanceof Category back into DepartmentOff after introducing the interface.
  • Walk the tree inside OrderProcessor.process because the promo is “kind of checkout.”
  • Mix gateways, policies, and SKUs in one “composite” list.

Wrap-up

Composite is one interface for a leaf and a group, so a caller can treat a tree as a single object. Checkout still charges one payable; it never needed to become a tree. Sku and Category both answer subtotal(), DepartmentOff asks the Outdoor root, and Camping is another child — not a third loop next to Hiking. Two items in a list stay a list.

If the next pain is Order methods that switch on DRAFT / PAID / REFUNDED / VOID, that is State — behavior that changes because this object’s status changed.

Next optional step in the series Put legal transitions in status objects instead of a growing switch. State: Change Behavior When Internal State Changes