The decorator design pattern lets us provide additional behavior to a type. This design pattern is extremely powerful when we want to provide extra functionalities without requiring changes to an existing type, helping us achieve the Open Closed principle. In this post we’ll examine how the decorator design pattern handles a hypothetical problem. The code will be written in Golang, but the idea should be simple enough to port to other programming languages.
Hypothetical Problem
We work in a video game studio, and our next feature is the weapon system for our next big hit - an action RGP (something like Diablo). For the initial development phase, we’ll have to support 3 weapont types: Sword
, Bow
and Staff
.
type Sword struct {
Dmg int
}
func (s *Sword) Damage() int {
return s.Dmg
}
type Staff struct {
Dmg int
}
func (s *Staff) Damage() int {
return s.Dmg
}
type Bow struct {
Dmg int
}
func (b *Bow) Damage() int {
return b.Dmg
}
Each weapon will have its own base damage value, and can be further enhanced via different ways. Our character can increase weapon damage by inserting gemstone to a weapon, or putting skill points in different weapon masteries. For example, a warrior can insert a thunder gemstone into his sword, which increases its damage by the gemstone damage value; a rogue can put a skill point in bow mastery, which increases bow damage by 10 points.
Initial Solution
The most straight forward and naive solution is to add some properties to our structs to reflect the new damage categories.
// sample code to add ThunderDmg and MasteryDmg to Sword
// we need to duplicate these fields to Bow and Staff as well
type Sword struct {
Dmg int
ThunderDmg int
MasteryDmg int
}
func (s *Sword) Damage() int {
return s.Dmg + s.ThunderDmg + s.MasteryDmg
}
There are a few issues here. Firstly, some codes are duplicated in Sword
, Bow
and Staff
. While it’s generally ok to have these duplications at the initial stage of the project, it could pose a potential risk for maintenance and readability when the code base grows larger. Secondly, adding a new damage type means modifying all weapon types and their damage calculation. For instance, if we want fire damage, we will need to add FireDmg
to all of the 3 weapon structs, and modify their Damage()
implementation.
Decorator pattern
One solution is to leverage the decorator pattern. It is possible to split up the implementation of adding extra damage, and the implementation of our weapons. We can start with two decorators, one for elemental damage types like fire and lightning, the other for weapon mastery damage
// all our weapons and decorators will need to satisfy Damager interface
type Damager interface {
Damage() int
}
type elementalDamage struct {
baseDamager Damager
element string
dmg int
}
// this wraps an existing Damager with some elemental damage
func NewElementalDamage(baseDamager Damager, element string, dmg int) Damager {
return &elementalDamage{baseDamager: baseDamager, element: element, dmg: dmg}
}
func (d *elementalDamage) Damage() int {
return d.baseDamager.Damage() + d.dmg
}
type weaponMasteryDamage struct {
baseDamager Damager
}
// this wraps an existing Damager with some damage thanks to adding skill points to weapon mastery
func NewWeaponMasteryDamage(baseDamager Damager) Damager {
return &weaponMasteryDamage{baseDamager: baseDamager}
}
func (d *weaponMasteryDamage) Damage() int {
return d.baseDamager.Damage() + 10
}
We introduce the Damager
interface, which is the center point of the implemetation. As long as a type implements this interface, it can be decorated with NewElementalDamage
and NewWeaponMasteryDamage
to add extra damage without modifying the base damager. An example of how the client can use this code can be seen as below
// we start with a basic short sword
shortSword := &Sword{Dmg: 5}
// we insert a thunder gemstone into the sword
thunderSword := NewElementalDamage(shortSword, "thunder", 10)
// we put a skill point into sword mastery upon level up
masteredThunderSword := NewWeaponMasteryDamage(thunderSword)
// we insert a fire gemstone into the sword
masteredElementalSword := NewElementalDamage(masteredThunderSword, "fire", 20)
// we can calculate the total damage of our weapons by calling the following method
// masteredElementalSword.Damage()
With this implementation, we can compose different weapons, giving them extra capabilities without modifying their implementation. In addition, adding a new elemental damage type like fire only requires changes in elementalDamage
while Sword
, Bow
and Staff
are unaffected. Similarly, adding a new weapon type, Gun
for example, will not affect the damage calculation of elemental damage, and does not require us to put a number of extra fields to Gun
struct.
Caveats
The decorator pattern is not a silver bullet. It’s very easy to get lost within multiple layers of decorators especially when debugging. Having too many mini decorators results in more cognitive load for the readers. In our example, it may be very tempting to have a separate decorator for fire damage and thunder damage instead of having a single one. To use the decorator pattern effectively, it’s highly recommended to think more about the design upfront, and make the decision whether or not to have multiple layers of similar decorators.
What’s next
In this post, we examine a programming problem that can be solved by the decorator design pattern. In our next post, we’ll examine a close cousin of the decorator pattern and see how it helps us deal with a different type of problem.