Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 023 / 100

Composite pattern: a Design Patterns guide for developers

Treat individual objects and groups the same way.

Expona Admin8 min read

Composite pattern

The Composite pattern is a structural design pattern that lets you compose objects into tree structures to represent part-whole hierarchies. It allows clients to treat individual objects and groups the same way. I've found this pattern invaluable when building systems where you need to apply operations uniformly across nested structures without worrying about whether you're dealing with a single item or an entire branch.

Why trees matter in real systems

Most applications you'll work on will encounter tree-like data structures. File systems are trees. Organization hierarchies are trees. DOM elements in a browser are a tree. Permission groups nest within each other. Menu systems have submenus. Instead of writing separate logic for handling a single item versus a collection of items, the Composite pattern lets you write one set of operations that work recursively through the entire structure.

I worked on a travel platform where tour packages could contain other tour packages—a "Europe Grand Tour" package included "Paris Experience," "Rome Adventure," and "Barcelona Culture" sub-packages, each of which had their own itineraries. Without the Composite pattern, calculating total price, duration, or generating itineraries would have required checking whether each item was a single component or a container before applying logic. With Composite, a single recursive algorithm handled everything.

Leaf and container share an interface

The foundation of Composite is a shared interface or abstract class. Both leaf nodes (items with no children) and composite nodes (containers with children) implement the same contract. This is the key insight: from the client's perspective, there's no difference between adding a single permission rule and adding an entire group of rules.

Let me show you a practical example with a permission system:

typescript
interface PermissionNode {
  grant(userId: string): void;
  revoke(userId: string): void;
  hasPermission(userId: string): boolean;
  describe(): string;
}

class SinglePermission implements PermissionNode {
  constructor(
    private resourceId: string,
    private action: string,
    private allowedUsers: Set<string> = new Set()
  ) {}

  grant(userId: string): void {
    this.allowedUsers.add(userId);
  }

  revoke(userId: string): void {
    this.allowedUsers.delete(userId);
  }

  hasPermission(userId: string): boolean {
    return this.allowedUsers.has(userId);
  }

  describe(): string {
    return `${this.action} on ${this.resourceId} for [${Array.from(this.allowedUsers).join(", ")}]`;
  }
}

class PermissionGroup implements PermissionNode {
  private children: PermissionNode[] = [];

  constructor(private name: string) {}

  add(node: PermissionNode): void {
    this.children.push(node);
  }

  remove(node: PermissionNode): void {
    const idx = this.children.indexOf(node);
    if (idx > -1) this.children.splice(idx, 1);
  }

  grant(userId: string): void {
    this.children.forEach(child => child.grant(userId));
  }

  revoke(userId: string): void {
    this.children.forEach(child => child.revoke(userId));
  }

  hasPermission(userId: string): boolean {
    return this.children.some(child => child.hasPermission(userId));
  }

  describe(): string {
    const childDescriptions = this.children.map(child => child.describe()).join("\n  ");
    return `Group: ${this.name}\n  ${childDescriptions}`;
  }
}

Notice how both SinglePermission and PermissionGroup implement the same PermissionNode interface. The group doesn't care what it contains—it could hold single permissions, other groups, or a mix. This uniformity is what makes Composite powerful.

Operations recurse naturally

Because both leaf and composite nodes share an interface, recursion becomes natural. When you call an operation on a composite node, it delegates to its children, which may themselves be composites and delegate further down the tree. No special handling needed.

Here's how you'd use the permission system I just showed:

typescript
const adminGroup = new PermissionGroup("Admins");
const editUsers = new SinglePermission("users", "edit");
const deleteUsers = new SinglePermission("users", "delete");
const viewReports = new SinglePermission("reports", "view");

adminGroup.add(editUsers);
adminGroup.add(deleteUsers);
adminGroup.add(viewReports);

const superAdminGroup = new PermissionGroup("Super Admins");
const editSettings = new SinglePermission("settings", "edit");
superAdminGroup.add(editSettings);
superAdminGroup.add(adminGroup); // Composite can contain another composite

// Single call recursively grants across the entire tree
superAdminGroup.grant("alice@company.com");

// This grants both direct permissions (editSettings) and all admin permissions
console.log(superAdminGroup.describe());

When I call superAdminGroup.grant("alice@company.com"), it recursively walks the entire tree. The PermissionGroup delegates to its children, which include another PermissionGroup, which in turn delegates to its children. All without explicit recursion handling in the client code.

Great for nested rule builders

One of my favorite uses of Composite is building rule engines, especially when rules need to nest. Consider a ride-hailing platform with surge pricing rules that can be combined:

typescript
interface PricingRule {
  appliesTo(context: PricingContext): boolean;
  calculateMultiplier(context: PricingContext): number;
}

interface PricingContext {
  hour: number;
  dayOfWeek: string;
  demandLevel: number;
  distance: number;
}

class TimeBasedRule implements PricingRule {
  constructor(
    private startHour: number,
    private endHour: number,
    private multiplier: number
  ) {}

  appliesTo(context: PricingContext): boolean {
    return context.hour >= this.startHour && context.hour < this.endHour;
  }

  calculateMultiplier(context: PricingContext): number {
    return this.appliesTo(context) ? this.multiplier : 1;
  }
}

class DemandBasedRule implements PricingRule {
  constructor(private threshold: number, private multiplier: number) {}

  appliesTo(context: PricingContext): boolean {
    return context.demandLevel > this.threshold;
  }

  calculateMultiplier(context: PricingContext): number {
    return this.appliesTo(context) ? this.multiplier : 1;
  }
}

class CompositeRule implements PricingRule {
  private rules: PricingRule[] = [];
  private strategy: "all" | "any" | "max" = "max";

  constructor(strategy: "all" | "any" | "max" = "max") {
    this.strategy = strategy;
  }

  addRule(rule: PricingRule): void {
    this.rules.push(rule);
  }

  appliesTo(context: PricingContext): boolean {
    if (this.strategy === "all") {
      return this.rules.every(r => r.appliesTo(context));
    }
    if (this.strategy === "any") {
      return this.rules.some(r => r.appliesTo(context));
    }
    return true; // "max" strategy always applies, we take the highest multiplier
  }

  calculateMultiplier(context: PricingContext): number {
    if (!this.appliesTo(context)) return 1;

    const multipliers = this.rules.map(r => r.calculateMultiplier(context));

    if (this.strategy === "all") {
      return multipliers.reduce((a, b) => a * b, 1); // Multiply all
    }
    if (this.strategy === "any") {
      return Math.max(...multipliers, 1);
    }
    // "max" strategy
    return Math.max(...multipliers, 1);
  }
}

// Build complex pricing rules by composing simpler ones
const peakHourRule = new TimeBasedRule(7, 10, 1.5);
const eveningRule = new TimeBasedRule(17, 20, 1.4);
const highDemandRule = new DemandBasedRule(80, 2.0);

const weekdayRules = new CompositeRule("max");
weekdayRules.addRule(peakHourRule);
weekdayRules.addRule(eveningRule);
weekdayRules.addRule(highDemandRule);

const context: PricingContext = {
  hour: 8,
  dayOfWeek: "Monday",
  demandLevel: 75,
  distance: 5,
};

const multiplier = weekdayRules.calculateMultiplier(context); // 1.5 (peak hour wins)

This is where Composite shines. You can build arbitrarily complex rule hierarchies by composing simple rules. Add a new rule type? It implements the same interface. Combine rules in new ways? Just create a new CompositeRule and add children. No special logic, no cascading if-statements.

Common mistakes

Over-engineering for imaginary trees. Don't use Composite everywhere you might someday need nesting. If your data doesn't naturally form hierarchies, you're adding complexity without benefit. I once saw a developer try to use Composite for API response handlers when a simple strategy pattern would have been cleaner.

Forgetting immutability in shared references. If multiple parents can reference the same child (a DAG, not a tree), be careful about modifications. Most Composite implementations assume each node has exactly one parent. If you're sharing nodes, changes propagate in unexpected ways.

Making leaf nodes do all the work. Sometimes developers put all the real logic in the leaf implementations and have the composite just delegate. This is fine, but if you need different behavior at the composite level—like aggregations, filtering, or transformations—implement those in the composite, not as post-processing in the client.

Ignoring performance with deep trees. If you're recursing through thousands of levels, you'll hit stack limits. For deeply nested structures, consider iterative approaches using a queue or stack instead of recursion.

Key takeaways

  • Composite pattern unifies single objects and collections under one interface, eliminating the need for type checking in client code.
  • Trees and hierarchies are everywhere in real systems—file systems, org charts, DOM, permissions, rules. Composite makes them tractable.
  • Recursion emerges naturally; composite nodes delegate to children, which may themselves be composites. The magic is in the shared interface.
  • Perfect for rule engines and builders where complexity comes from composition rather than the rules themselves.
  • Start with a clean interface contract that both leaf and composite can implement honestly. Don't force a square peg into a round hole.
  • Watch out for deep recursion, unexpected node sharing, and the temptation to add special cases to the interface.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles