Introduction
The decorator pattern is a software design pattern that lets you add more functionality on top of an existing logic. The first thing that comes to people's minds to tackle this is using inheritance - which completely makes sense. However, the nature of inheritance is static. If you have multiple variations of additional functionality or worse, various combinations of them, you would then have to create all the possible combinations into separate classes that extends the base class. In these cases, your codebase quickly increases in size and, in my opinion, reduces its maintainability.
The Decorator Pattern
The implementation of a decorator pattern requires the base logic that you are trying to extend to implement a base interface - one contract that defines what methods it has and what they produce. You can then create classes that implements the base interface while containing another internal object that also implements the interface. The logic inside the interface methods you're overriding can reuse the implementation of the internal object while adding additional logic to it - or even ignore the base logic completely, up to you. Moreover, it's entirely possible to even place any class that already has extended logic and in turn the decorator will add even more logic on top of them.
package main
import "fmt"
type VanillaIceCream struct{}
func (v *VanillaIceCream) GetIceCream() string {
return "Vanilla IceCream"
}
Let's say there are two people who wants ice cream, Ben and Mike. Each wants custom configuration of their ice cream, Ben prefers Vanilla ice cream with Chocolate Frostings on top of them while Mike prefers Vanilla ice cream with Chocolate Frostings & Caramel Sauce (not even sure if that's a thing). The inheritance way of solving this would be to create an entirely new class that satisfies the combination required.
Hence, the additional classes:
type VanillaIceCreamWithChocolateFrostings struct{
VanillaIc VanillaIceCream
}
func (v *VanillaIceCreamWithChocolateFrostings) GetIceCream() string {
return v.VanillaIc.GetIceCream() + " with Chocolate Frostings"
}
type VanillaIceCreamWithChocolateFrostingsWithCaramelSauce struct{
VanillaIcWithChoco VanillaIceCreamWithChocolateFrostings
}
func (v *VanillaIceCreamWithChocolateFrostingsWithCaramelSauce) GetIceCream() string {
return v.VanillaIcWithChoco.GetIceCream() + " with Chocolate Frostings and Caramel Sauce"
}
Then, another person, John, joins them and wants a Vanilla ice cream with only Caramel Sauce.
type VanillaIceCreamWithCaramelSauce struct{
VanillaIc VanillaIceCream
}
func (v *VanillaIceCreamWithCaramelSauce) GetIceCream() string {
return v.VanillaIcWithChoco.GetIceCream() + " with caramel sauce"
}
Now, consider more toppings you can think of and follow what we've done so far. You can already project how big the code is going to be if this continues. With decorator pattern, you simply state an interface, one that returns the actual flavors of the ice cream class implementation, and let your decorator classes follow them.
package main
import "fmt"
type IIceCream interface {
GetIceCream() string
}
type VanillaIceCream struct{}
func (v *VanillaIceCream) GetIceCream() string {
return "Vanilla IceCream"
}
type ChocolateFrostingDecorator struct {
IceCream IIceCream
}
func (c *ChocolateFrostingDecorator) GetIceCream() string {
return c.IceCream.GetIceCream() + " with Chocolate Frosting"
}
type CaramelSauceDecorator struct {
IceCream IIceCream
}
func (c *CaramelSauceDecorator) GetIceCream() string {
return c.IceCream.GetIceCream() + " with Caramel Sauce"
}
func main() {
iceCreamOne := &VanillaIceCream{}
iceCreamOneWithChocolateFrosting := &ChocolateFrostingDecorator{IceCream: iceCreamOne}
iceCreamOneWithChocolateFrostingWithCaramelSauce := &CaramelSauceDecorator{IceCream: iceCreamOneWithChocolateFrosting}
fmt.Println("iceCream1: " + iceCreamOneWithChocolateFrostingWithCaramelSauce.GetIceCream())
iceCreamTwo := &VanillaIceCream{}
iceCreamTwoWithChocoFrosting := &ChocolateFrostingDecorator{IceCream: iceCreamTwo}
iceCreamTwoWithChocoFrWithCaramelSauce := &CaramelSauceDecorator{IceCream: iceCreamTwoWithChocoFrosting}
fmt.Println("iceCream2: " + iceCreamTwoWithChocoFrWithCaramelSauce.GetIceCream())
iceCreamThree := &VanillaIceCream{}
iceCreamThreeWithCaramelFrostingOnly := &CaramelSauceDecorator{IceCream: iceCreamThree}
fmt.Println("iceCream3: " + iceCreamThreeWithCaramelFrostingOnly.GetIceCream())
}
In the event that someone else wants the same customizations but on a banana ice cream instead, as long as the Banana IceCream class implements the base interface, the existing decorators will work on them just fine.
// ...
type BananaIceCream struct{}
func (b *BananaIceCream) GetIceCream() string {
return "Banana IceCream"
}
// ...
func main() {
// ...
iceCreamFour := &BananaIceCream{}
iceCreamFourWithCaramelSauceOnly := &CaramelSauceDecorator{IceCream: iceCreamFour}
fmt.Println("iceCream4: " + iceCreamFourWithCaramelSauceOnly.GetIceCream())
}
Let's increase the complexity of the problem and consider a case where the existing flavors are needed to be stacked with a different order. Through inheritance, you would then double or maybe triple the amount of existing code. The decorator pattern unlocks possible solutions like accepting a stack of flavors and looping through each of them maybe with a recursive and applying the appropriate decorator with a switch case statement in every turn.
Cons of Decorator Pattern
You are enforced from the start to define the order of the decorators to apply - creating new ones are far easier than changing their order half-way or worse.
There's definitely more weaknesses to it but the one mentioned in this article is one I found most noticeable.
Conclusions
- Exploring design patterns can be a good thing to avoid common pitfalls of writing large scale projects.
- Decorator patterns help you stack additional logic on top of existing code in a more maintainable way.
Top comments (2)
I love the decorator pattern, but I've yet to find a good use case for it in Go. But it's been absolutely clutch in other languages.
Hi @tnypxl , one use I've done in the past are middlewares that you can stack before/after an API request truly gets processed by your backend logic. e.g. authentication token validation, request body validation, telemetry, ratelimiting, a/b testing enablement, etc.