Design PatternPublished 12/5/2024

Decorator: Extending an Object's Behavior at Runtime Without Touching Its Class

Starting from the trap of subclass explosion, this piece breaks down how the Decorator Pattern stacks behavior onto an object layer by layer, and clears up how it differs from JavaScript's own @decorator syntax.

When you're designing a system meant to mix and match features freely, it's easy to fall into a trap: using inheritance to represent every possible combination. Take a cup of coffee that can take milk, sugar, and foam. If you give every combination its own subclass — CoffeeWithMilk, CoffeeWithMilkAndSugar, CoffeeWithMilkAndSugarAndFoam — the number of classes grows exponentially. That's what's known as subclass explosion.

The Decorator Pattern exists to solve exactly this: instead of using inheritance to enumerate every combination, it stacks behavior onto an object dynamically, one wrapper layer at a time.

First, see how inheritance backs you into a corner

Say you're building a pricing system for coffee. The most obvious approach might look like this:

js
class Coffee {
  cost() {
    return 30
  }
  description() {
    return '咖啡'
  }
}
 
class CoffeeWithMilk extends Coffee {
  cost() {
    return super.cost() + 10
  }
  description() {
    return super.description() + '+ 牛奶'
  }
}
 
class CoffeeWithMilkAndSugar extends CoffeeWithMilk {
  cost() {
    return super.cost() + 5
  }
  description() {
    return super.description() + '+ 糖'
  }
}

See the problem? If a customer wants sugar but no milk, you need a separate CoffeeWithSugar class. Add foam to the mix and the inheritance chain just keeps growing — longer and more rigid with every addition. Each new topping can double your class count, and which toppings can stack with which is baked directly into the inheritance structure, with zero flexibility.

Decorator flips the angle: wrap, don't inherit

The Decorator Pattern has every decorator implement the same interface as the original object, while holding a reference to the object it's wrapping internally. When you call a method, it runs the wrapped object's logic first, then layers its own piece on top:

js
class Coffee {
  cost() {
    return 30
  }
  description() {
    return '咖啡'
  }
}
 
class MilkDecorator {
  #beverage
 
  constructor(beverage) {
    this.#beverage = beverage
  }
 
  cost() {
    return this.#beverage.cost() + 10
  }
 
  description() {
    return this.#beverage.description() + ' + 牛奶'
  }
}
 
class SugarDecorator {
  #beverage
 
  constructor(beverage) {
    this.#beverage = beverage
  }
 
  cost() {
    return this.#beverage.cost() + 5
  }
 
  description() {
    return this.#beverage.description() + ' + 糖'
  }
}

Now combining toppings is just a matter of nesting constructor calls — no need to predefine a class for every combination:

js
const plainCoffee = new Coffee()
console.log(plainCoffee.description(), plainCoffee.cost())
// 咖啡 30
 
const coffeeWithMilk = new MilkDecorator(plainCoffee)
console.log(coffeeWithMilk.description(), coffeeWithMilk.cost())
// 咖啡 + 牛奶 40
 
const coffeeWithMilkAndSugar = new SugarDecorator(new MilkDecorator(plainCoffee))
console.log(coffeeWithMilkAndSugar.description(), coffeeWithMilkAndSugar.cost())
// 咖啡 + 牛奶 + 糖 45

Sugar-only doesn't need its own class either — just skip MilkDecorator entirely:

js
const coffeeWithSugarOnly = new SugarDecorator(plainCoffee)
console.log(coffeeWithSugarOnly.description(), coffeeWithSugarOnly.cost())
// 咖啡 + 糖 35

The key shift is that nesting like SugarDecorator(new MilkDecorator(plainCoffee)) turns topping combinations from a fixed set of hardcoded classes into a wrapping order you can freely arrange at runtime. Each decorator only owns the logic it's adding at its own layer — it doesn't need to know how many layers are wrapped underneath it.

Why the shared interface is non-negotiable

What makes the Decorator Pattern's stacking work is that the decorator and the object it wraps expose the exact same interface — here, cost() and description(). The caller never needs to know whether it's holding a plain Coffee or one wrapped in three layers of decorators, because they behave identically from the outside:

js
function printReceipt(beverage) {
  console.log(`${beverage.description()}:${beverage.cost()} 元`)
}
 
printReceipt(plainCoffee) // 咖啡:30 元
printReceipt(coffeeWithMilkAndSugar) // 咖啡 + 牛奶 + 糖:45 元

printReceipt never needs to change, which is why the Decorator Pattern is often described as "transparent to the caller" — no matter how many layers are stacked on, the outside always sees the same interface.

Is this the same thing as JavaScript's @decorator syntax

If you've written TypeScript, or used the Stage 3 Decorator proposal, seeing @decorator syntax makes it tempting to equate it with the Decorator Pattern discussed here. But they operate at different levels entirely.

js
function logged(target, context) {
  return function (...args) {
    console.log(`呼叫 ${context.name}`)
    return target.apply(this, args)
  }
}
 
class Order {
  @logged
  placeOrder() {
    console.log('訂單建立完成')
  }
}

@logged is syntactic sugar built into JavaScript/TypeScript itself, letting you inject a transformation at the point where a class, method, or property is declared. Conceptually, it's in the same spirit as the Decorator Pattern — adding extra behavior without touching the original code — but it's a language-level mechanism that operates at compile time / declaration time. The Decorator Pattern discussed in this article is a structural technique in object-oriented design, where the whole point is composing behavior onto objects at runtime. Same name, but one's a syntax tool and the other's a design pattern — don't conflate them.

How it stacks up against patterns covered earlier

The Decorator Pattern often gets discussed alongside other structural patterns. Worth untangling a couple of things people commonly mix up:

  • vs. Facade Pattern: Facade collapses multiple subsystems into one simplified entry point — the goal is reducing complexity. Decorator stacks new behavior onto a single object — the goal is extending functionality. They're solving entirely different problems.
  • vs. Mixin Pattern: A Mixin welds capability directly into a class at class-definition time, through the inheritance chain. A Decorator wraps an existing instance through composition at runtime, without touching the class definition at all. If what you need is "this specific object temporarily gets one more behavior," Decorator is the better fit. If it's "this entire category of object should inherently have this capability," Mixin — or plain inheritance — is more direct.

The trade-off: the deeper the stack, the harder it is to trace

The Decorator Pattern trades composition for flexibility, and the cost is similar to Mixin's: once you've stacked enough layers, figuring out exactly which layers produced an object's final behavior means unwinding the constructor calls one by one. If new A(new B(new C(original)))-style code shows up everywhere in your codebase, readability takes a real hit.

A more practical approach is to reach for Decorator only when you genuinely need dynamic, freely composable extension. If a feature combination is already known to be one of a handful of fixed options, writing a few named factory functions that bundle the common combinations is usually easier to maintain than making every caller hand-stack decorators themselves.

Wrap-up

The core value of the Decorator Pattern is replacing inheritance with composition, letting an object's behavior stack freely at runtime and sidestepping subclass explosion. It only works because the decorator and the object it wraps share the same interface — which means the caller never has to be aware of how many layers of wrapping it's dealing with.