Decorator

The menu started with PlainDosa. Then CheeseDosa and ButterDosa, each a subclass. Then someone ordered both, so CheeseButterDosa. Then the kitchen added paneer.

Three add-ons that can be combined freely is 8 classes. Four is 16. Five is 32. Somebody on the team did write CheeseButterPaneerDosa, and when the price of cheese went up by 5 rupees, it had to be changed in the four classes with Cheese in the name. One was missed.

Your answer

A dosa can have any combination of cheese, butter and paneer, and each add-on changes the name and the price. More add-ons are coming. How would you model this so that a new add-on is one new piece of code, not a doubling of the class count?

The idea

The picture to keep
Add-ons on a dosa

Start with a plain dosa. Cheese goes on top and adds 30 rupees. Butter goes on top of that and adds 15. At every stage what you are holding is still a dosa: it has a name, it has a price, and you can put one more thing on it. Nobody needed a separate recipe for “cheese butter dosa”.

In one line: Wrap an object in another object with the same interface, and add a little behaviour in the wrapper.

The decorator

interface MenuItem {
  name(): string
  price(): number
}
 
class PlainDosa implements MenuItem {
  name(): string {
    return 'Dosa'
  }
 
  price(): number {
    return 60
  }
}
 
class WithCheese implements MenuItem {
  constructor(private readonly item: MenuItem) {}
 
  name(): string {
    return `${this.item.name()} + cheese`
  }
 
  price(): number {
    return this.item.price() + 30
  }
}
 
class WithButter implements MenuItem {
  constructor(private readonly item: MenuItem) {}
 
  name(): string {
    return `${this.item.name()} + butter`
  }
 
  price(): number {
    return this.item.price() + 15
  }
}
 
const order: MenuItem = new WithButter(new WithCheese(new PlainDosa()))
 
console.log(order.name()) // Dosa + cheese + butter
console.log(order.price()) // 105

WithCheese does two things at once, and both matter. It is a MenuItem, because it implements the interface. And it has a MenuItem, the one passed to its constructor. Each method asks the inner item first and then adds its own part.

Because a decorator is itself a MenuItem, you can wrap a decorator in another decorator. That is where the combinations come from: at runtime, by nesting, not at compile time, by subclassing.

Paneer arrives:

class WithPaneer implements MenuItem {
  constructor(private readonly item: MenuItem) {}
 
  name(): string {
    return `${this.item.name()} + paneer`
  }
 
  price(): number {
    return this.item.price() + 40
  }
}
 
const loaded = new WithPaneer(
  new WithButter(new WithCheese(new PlainDosa())),
)
 
console.log(loaded.name()) // Dosa + cheese + butter + paneer
console.log(loaded.price()) // 145

One class, and every combination with it now exists. The cheese price lives in one place.

Figure 1. A call to price() travels inward to the plain dosa and each layer adds its amount on the way back out: 60, then 90, then 105.

Where you will really use it

Dosas are how the pattern is taught. In a backend, the same shape adds logging, caching, retries or timing around a service, without editing the service or its callers.

interface PriceService {
  getPrice(dish: string): number
}
 
class MenuPriceService implements PriceService {
  getPrice(dish: string): number {
    console.log(`slow lookup for ${dish}`)
    return dish.length * 10
  }
}
 
class CachedPriceService implements PriceService {
  private readonly cache = new Map<string, number>()
 
  constructor(private readonly inner: PriceService) {}
 
  getPrice(dish: string): number {
    const cached = this.cache.get(dish)
    if (cached !== undefined) return cached
 
    const price = this.inner.getPrice(dish)
    this.cache.set(dish, price)
    return price
  }
}
 
const prices: PriceService = new CachedPriceService(new MenuPriceService())
 
console.log(prices.getPrice('dosa'))
// > slow lookup for dosa
// > 40
console.log(prices.getPrice('dosa')) // 40

The second call never reached the slow service. Code that uses prices cannot tell it is talking to a cache, because the type is still PriceService. And MenuPriceService stayed a class that only knows how to look up prices, which is the single responsibility principle paying off.

Caching has its own set of ways to hurt you, and they are covered on the caching patterns page in the HLD section.

What can go wrong

Order of wrapping changes behaviour

new Cached(new Logged(service)) logs only cache misses. new Logged(new Cached(service)) logs every call. Both compile. With retries it is worse: a retry wrapped outside a cache retries the cache, a retry inside it retries the real call. Decide the order on purpose and build the stack in one place.

The object is no longer what it was

order instanceof PlainDosa is false once the dosa is wrapped, because the outermost object is a WithButter. Code that checks concrete classes breaks under decoration. That is one more reason to depend on the interface and never ask what is underneath.

This is not the @ syntax

TypeScript also has a language feature called decorators, written as @Injectable() or @Get('/orders') above a class or a method. You will meet it in NestJS and Angular. It shares the name and the general idea of wrapping, but it is a syntax for annotating declarations, not this pattern. If an interviewer asks about the decorator pattern, they mean the wrapping objects on this page.

Decorator versus inheritance

The expected answer: inheritance adds behaviour at compile time, to a whole class, in a fixed combination. Decoration adds it at runtime, to one object, in any combination and any order. With n independent features, inheritance needs up to 2 to the power n classes and decoration needs n. Mention that count. It is the reason the pattern exists.

TypeScript you just picked up
class WithCheese implements MenuItem
The wrapper satisfies the same interface as the thing it wraps.
constructor(private readonly item: MenuItem) {}
It holds a MenuItem, typed by the interface, so it can wrap anything including another wrapper.
const order: MenuItem = new WithButter(...)
Annotating with the interface keeps callers from depending on the outermost wrapper.
new Map<string, number>()
A typed lookup table. get returns number | undefined.
if (cached !== undefined) return cached
Narrowing. After this line TypeScript knows the value was missing.
`${this.item.name()} + cheese`
A template literal. Any expression can go inside the braces.

Checkpoint

Checkpoint

1. What two relationships does a decorator class have with the interface?

2. There are 5 independent add-ons. Roughly how many classes does each approach need to cover every combination?

3. You wrap a service as new Logged(new Cached(real)). A request is served from the cache. Is it logged?

Say this in 60 seconds

A decorator adds behaviour to an object by wrapping it in another object that has the same interface. The wrapper implements the interface and also holds an instance of it, so each method calls the inner object and adds its own part before or after. Because the wrapper is the same type, wrappers can be stacked in any combination at runtime, which avoids a subclass for every combination: n features need n decorators where inheritance could need two to the n classes. In practice I use it to add caching, logging or retries around a service without editing the service or its callers. The things to watch are that the order of wrapping changes behaviour, and that callers must depend on the interface, since the outer object is no longer the original class.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.