Skip to content
aipatterns.dev

Design patternStructural Patterns

Composite

Treat single objects and nested groups of them through one interface, so code can walk a tree of any depth without checking node types.

Complexity
Medium
Seen in the wild
Medium
Also known as
Object Tree, Part-Whole Hierarchy

Intent

Represent part-whole hierarchies as a tree in which leaves and containers share one interface. A container implements each operation by delegating to its children and combining their results, so client code calls the same method on a single item or on a whole subtree.

Forces

  • Uniform client code vs two kinds of node: callers want node.monthlyCost(), not if (node is Group) ... else ... at every use.
  • Arbitrary depth vs fixed loops: nested loops work for exactly two levels; org charts, folders and cost centers have as many as they like.
  • Type safety vs one interface: putting add(child) on leaves keeps the interface uniform but lets callers do nonsense; leaving it off means callers need to know which node they hold.
  • Recomputing on every call vs caching aggregates: a deep tree recalculates its totals on every read unless you cache, and caching needs invalidation.

Therefore

Define one component interface for the operations that make sense on both a single item and a group; leaves implement them directly, and composites implement them by looping over their children.

Recursion does the work: a composite does not know whether its children are leaves or other composites. Keep child management (add, remove) on the composite type unless callers truly need to build trees through the component interface.

Structure

Diagram source (Mermaid)
classDiagram
  class CostNode {
    <<interface>>
    +name() String
    +monthlyCost() Money
    +lines(depth) List
  }
  class Resource {
    -Money cost
    +monthlyCost() Money
    +lines(depth) List
  }
  class Group {
    -CostNode[] children
    +add(node) Group
    +monthlyCost() Money
    +lines(depth) List
  }
  class BudgetAlert {
    +check(node, limit) List
  }
  CostNode <|.. Resource
  CostNode <|.. Group
  Group o-- CostNode : children
  BudgetAlert ..> CostNode

Participants

  • Component (CostNode): the operations shared by leaves and containers.
  • Leaf (Resource): a node with no children that does the real work (here, knows its own cost).
  • Composite (Group): holds child components, delegates to them, and combines the results.
  • Client (BudgetAlert): works through the component interface and never checks which kind it holds.

Use it when

  • Your data is a tree of unbounded depth: folders, org units, menu items, cost centers, UI widgets, bill-of-materials parts.
  • Clients repeat the same instanceof/type switch to tell single items from groups.
  • An operation on a group is the combination of the same operation on its members: totals, counts, rendering, validation, enable/disable.
  • You want to pass a single item wherever a group is accepted, and the reverse.

Do not use it when

  • The tree has a fixed, known shape, such as "teams contain resources" and nothing deeper. Two nested loops over plain data are clearer.
  • Leaves and groups share almost no operations. Forcing them into one interface fills leaves with methods that throw UnsupportedOperationException. Keep two types.
  • The node types are stable but the operations change often. A data-only tree (a TypeScript discriminated union, a Java sealed interface with records, Python dataclasses with match) plus one recursive function per operation is simpler; see also Visitor.
  • The structure is really a graph with shared children or cycles. Composite assumes each node has one parent; use a graph model and an explicit traversal with a visited set.
  • You only need to flatten nested lists once. A recursive generator or flatMap does it without new types.

Application grill

Inspect first: the tree shapes and the code that walks them; whether nesting is user-controlled (menus, org charts, comment threads) or fixed; existing recursion or iteration helpers.

Missing pieces: none beyond the language: one interface for leaf and group. Deep, user-controlled trees need an explicit stack instead of recursion, so hostile depth cannot exhaust the call stack.

Ask before you build:

  • Scale: how deep can a tree get, and is depth bounded by construction or by user input?
  • Performance: does a small change re-walk the whole tree, and is incremental update needed?
  • Reliability: can a cycle exist (a child pointing at its ancestor), and where is that rejected?
  • Budget: one node interface simplifies callers, but every node kind now implements it - is the type count owned?
  • Implementation: are node kinds a closed set (a switch is fine) or open (visitors and plugins must extend)?

Implementation steps

  1. Confirm the domain is a recursive tree and list the operations that apply to both single items and groups.
  2. Declare the component interface with only those operations.
  3. Implement leaf classes for the concrete items.
  4. Implement the composite: a child collection, add/remove, and each operation as a loop over children that combines results.
  5. Replace client type checks with calls on the component interface.
  6. If aggregates are expensive and the tree is large, add caching in the composite with explicit invalidation on add/remove, and measure first.

Example

A FinOps tool rolls up monthly cloud spend across nested groups (company, department, team) and flags any node over budget, whether it is one database or a whole department.

const usd = (cents: number) => `$${(cents / 100).toFixed(2)}`;

interface CostNode {
  readonly name: string;
  monthlyCost(): number; // cents
  lines(depth?: number): string[];
}

class Resource implements CostNode {
  constructor(readonly name: string, private readonly cents: number) {}
  monthlyCost(): number {
    return this.cents;
  }
  lines(depth = 0): string[] {
    return [`${"  ".repeat(depth)}${this.name}  ${usd(this.cents)}`];
  }
}

class Group implements CostNode {
  private readonly children: CostNode[] = [];
  constructor(readonly name: string) {}

  add(...nodes: CostNode[]): this {
    this.children.push(...nodes);
    return this;
  }
  monthlyCost(): number {
    return this.children.reduce((sum, c) => sum + c.monthlyCost(), 0);
  }
  lines(depth = 0): string[] {
    const head = `${"  ".repeat(depth)}${this.name}/  ${usd(this.monthlyCost())}`;
    return [head, ...this.children.flatMap((c) => c.lines(depth + 1))];
  }
}

// Client: one rule, any node.
function overBudget(node: CostNode, limitCents: number): string | null {
  const cost = node.monthlyCost();
  return cost > limitCents ? `${node.name} is ${usd(cost - limitCents)} over budget` : null;
}

const payments = new Group("payments").add(
  new Resource("postgres-primary", 84000),
  new Resource("api-cluster", 126000),
);
const search = new Group("search").add(new Resource("opensearch", 61000));
const company = new Group("acme").add(
  new Group("engineering").add(payments, search),
  new Resource("office-vpn", 9000),
);

console.log(company.lines().join("\n"));
for (const node of [company, payments, new Resource("ci-runners", 45000)]) {
  console.log(overBudget(node, 200000) ?? `${node.name} ok`);
}

overBudget receives a company, a team and a single resource and handles all three the same way. That uniformity is the whole point.

Trade-offs

Gains

  • Client code is type-check-free and works at any depth.
  • New leaf or composite types plug in without client changes.
  • Aggregation logic lives in one place, the composite, instead of in every caller.
  • Trees can be built from configuration or data at runtime.

Costs

  • The component interface tends to be either too wide (leaves carry meaningless child methods) or too narrow (clients cast to reach add).
  • It is harder to enforce constraints such as "a team may not contain a company" through the type system.
  • Naive aggregates are recomputed on every call; lines above recomputes subtotals at each level.
  • Deep recursion can overflow the stack on pathological inputs; untrusted trees may need an explicit stack.

Relations

  • Decorator has a similar recursive shape, but wraps exactly one component to add behavior. A composite holds many children and combines their results.
  • Iterator walks a composite tree without exposing its internal structure.
  • Visitor adds new operations over a composite tree without editing every node class.
  • Chain of Responsibility often runs along a composite's parent links, passing a request from a leaf up to its ancestors.
  • Flyweight can share identical leaves to save memory in very large trees, as long as they hold no parent reference.

Agent checklist

  • The structure is a real tree of variable depth, not a fixed two-level grouping.
  • The component interface contains only operations meaningful for both leaves and composites.
  • No client code checks whether a node is a leaf or a composite after the change.
  • Each node has at most one parent, and cycles are impossible or rejected on add.
  • Tests cover an empty composite, a single leaf, and a nested tree at least three levels deep.

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.