DEV Community

Cover image for Stop Writing Rigid Code! Master the Decorator Pattern in Go for Ultimate Flexibility
Archit Agarwal
Archit Agarwal

Posted on • Originally published at Medium

Stop Writing Rigid Code! Master the Decorator Pattern in Go for Ultimate Flexibility

Imagine you're a software developer building an ice cream vending machine for Disneyland. The menu was initially fixed - customers could only order predefined flavors with no customizations (boring, right? 😒). You wrote simple code to calculate costs based on these fixed options.

But this is Disneyland! Nobody wants a plain old scoop. People want triple scoops, extra chocolate sauce, stacked wafer cones, sprinkles, and more. You realize you can't hardcode every possible combination, or your codebase will become a nightmare! 🫠

So, how do you handle dynamic customizations without rewriting everything? Enter the Decorator Pattern, a lifesaver for scenarios where you need to dynamically extend an object's functionality without modifying its structure.

Let's first explore the problem with a naive approach and then refactor it using the Decorator Pattern for a flexible and scalable solution. 🚀

The Traditional Approach: Hardcoding Ice Cream Variants

Imagine our first attempt at coding the vending machine. A simple function creates a Butterscotch Ice Cream:

func CreateButterscotchIcecream(scoops int) IceCream {
    ingredients := []IceCreamIngredient{
        BASE_PLAIN_CONE,
    }
    for i := 0; i < scoops; i++ {
        ingredients = append(ingredients, FLAVOUR_BUTTERSCOTCH)
    }
    ingredients = append(ingredients, TOPPING_SPRINKLES)
    return Create(CreateIceCreamRequest{Ingrediants: ingredients})
}
Enter fullscreen mode Exit fullscreen mode

Now, we add Vanilla Ice Cream:

func CreateVanillaIcecream(scoops int) IceCream {
    ingredients := []IceCreamIngredient{
        BASE_PLAIN_CONE,
    }
    for i := 0; i < scoops; i++ {
        ingredients = append(ingredients, FLAVOUR_VANILLA)
    }
    return Create(CreateIceCreamRequest{Ingrediants: ingredients})
}
Enter fullscreen mode Exit fullscreen mode

This works fine for fixed flavours, but what if customers want to mix and match? What if someone wants vanilla with chocolate chips or a chocolate cone with butterscotch scoops? We can't keep adding new functions for every combination. 😵‍💫

The Problem? 🚨

  1. Hardcoding every variation is not scalable.
  2. The code becomes repetitive and difficult to maintain.
  3. There is no flexibility to create custom orders dynamically.

Time to refactor using the Decorator Pattern!

Introducing the Decorator Pattern 🎩✨

The Decorator Pattern allows us to dynamically extend an object's functionality without modifying its structure. Instead of hardcoding different types of ice cream, we use composition over inheritance to wrap functionalities around each other.

Think of your manager adding their name to your presentation after changing the font size - that's the Decorator Pattern at work. 😂

Step 1: Define a Common Interface

We start by creating an interface that all ice cream components (base, flavours, and toppings) will implement:

type IIceCreamIngredient interface {
    GetPreparationSteps() []string
    GetCost() int
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Implement the Base Ingredient (Chocolate Cone)

Now, let's implement the Chocolate Cone decorator, which wraps around another ingredient:

type BaseChocolateCone struct {
 ExistingIngrediant IIceCreamIngrediant
}

func (ingrediant *BaseChocolateCone) GetPreperationSteps() []string {
 step := "Chocolate Cone"
 if ingrediant.ExistingIngrediant != nil {
  return append(ingrediant.ExistingIngrediant.GetPreperationSteps(), step)
 }
 return []string{step}
}
func (ingrediant *BaseChocolateCone) GetCost() int {
 oldCost := 0
 if ingrediant.ExistingIngrediant != nil {
  oldCost = ingrediant.ExistingIngrediant.GetCost()
 }
 return 12 + oldCost
}
Enter fullscreen mode Exit fullscreen mode

✅ Breakdown:

  1. If this ingredient is added on top of something else, it appends the steps to the existing preparation list.
  2. If it's the first ingredient, it starts the list.
  3. Cost calculation is handled dynamically by summing up all wrapped ingredients.

Step 3: Implement More Decorators

Let's implement a Butterscotch Flavor decorator:

type FlavourButterscotch struct {
 ExistingIngrediant IIceCreamIngrediant
}

func (ingrediant *FlavourButterscotch) GetPreperationSteps() []string {
 step := "1 scoop Butterscotch"
 if ingrediant.ExistingIngrediant != nil {
  return append(ingrediant.ExistingIngrediant.GetPreperationSteps(), step)
 }
 return []string{step}
}
func (ingrediant *FlavourButterscotch) GetCost() int {
 oldCost := 0
 if ingrediant.ExistingIngrediant != nil {
  oldCost = ingrediant.ExistingIngrediant.GetCost()
 }
 return 5 + oldCost
}
Enter fullscreen mode Exit fullscreen mode

With this setup, we can now chain ingredients dynamically! 🎉

Step 4: Dynamically Creating Ice Cream Orders 🍨

Now, let's take a user's order and create their dream ice cream dynamically:

func main() {
    iceCream := &BaseChocolateCone{
        ExistingIngredient: &FlavourButterscotch{
            ExistingIngredient: &FlavourVanilla{},
        },
    }

    fmt.Println("Steps to prepare your ice cream:")
    for index, step := range iceCream.GetPreparationSteps() {
        fmt.Printf("Step %d: %s\n", index+1, step)
    }
    fmt.Printf("Total Cost: $%d\n", iceCream.GetCost())
}
Enter fullscreen mode Exit fullscreen mode

Output:

Steps to prepare your ice cream:
Step 1: Chocolate Cone
Step 2: 1 scoop Butterscotch
Step 3: 1 scoop Vanilla
Total Cost: $20
Enter fullscreen mode Exit fullscreen mode

✅ Now, any combination is possible dynamically!

  • No hardcoding different combinations
  • Easily scalable for new flavours & toppings
  • Follows the Open-Closed Principle (extend behavior without modifying existing code)

The complete Code can be found on my GitHub https://github.com/architagr/design_patterns/tree/main/golang/structural/decorator

Real-World Use Cases of the Decorator Pattern

  1. Logging Middleware (Wrap API requests to log data)
  2. Authentication Middleware (Add security checks dynamically)
  3. Rate Limiting (Restrict API calls per user)
  4. Caching Mechanism (Cache responses dynamically)
  5. Monitoring & Metrics Collection (Track execution time)
  6. Encryption & Compression (Wrap data processing functions)
  7. Feature Flags (Enable/disable features at runtime)
  8. Payment Processing (Add discounts/taxes dynamically)
  9. Data Validation (Validate user input before processing)
  10. Web Scraping Pipelines (Apply transformation layers dynamically)

Wrapping Up 🎁

The Decorator Pattern is a powerful technique in Go that allows you to dynamically compose behaviours while keeping your codebase clean and scalable. Next time you face a scenario where functionalities need to be added dynamically without modifying the base code think Decorators!

Have you used the Decorator Pattern in your projects? Share your experience in the comments! 👇

Stay Connected!

💡 Follow me here on LinkedIn for more insights on software development and architecture.

🎥 Subscribe to my YouTube channel for in-depth tutorials.

📬 Sign up for my newsletter, The Weekly Golang Journal, for exclusive content.

✍️ Follow me on Medium for detailed articles

👨‍💻 Join the discussion on my subreddit, r/GolangJournal, and be part of the community!

Developer-first embedded dashboards

Developer-first embedded dashboards

Embed in minutes, load in milliseconds, extend infinitely. Import any chart, connect to any database, embed anywhere. Scale elegantly, monitor effortlessly, CI/CD & version control.

Get early access

Top comments (5)

Collapse
 
xwero profile image
david duymelinck • Edited

Who is the developer that wrote those "traditional" examples?

I'm also not seeing how the decorators improve the code. The code has to be aware of a previous instance to add another ingredient, which leads to a lot of duplicate code.

a builder pattern is a better option in this case. All you need is this struct to display the icecream and the price.

type IceCream struct {
    Cone     string
    Flavors  []string
    Toppings []string
    Price    float64
}
Enter fullscreen mode Exit fullscreen mode

The decorator examples also lost the possibility to specify the amount of scoops. That will make that icecream instance even more nested.

Collapse
 
architagr profile image
Archit Agarwal

this is definitely a good improvement on the "traditional" examples. I think the enhancement that you have suggested, still be bit complex to maintain compared to the final code using the decorator pattern.

Collapse
 
xwero profile image
david duymelinck • Edited

type IceCream struct {
    Vessel    Vessel
    Flavors    []Flavor
    Toppings  []Topping
    TotalCost float64 
}

func NewIceCreamBuilder() *IceCreamBuilder {
    return &IceCreamBuilder{}
}

func (b *IceCreamBuilder) SetVessel(vesselType string, price float64) *IceCreamBuilder {
    b.vessel = Vessel{Type: vesselType, Price: price}
    return b
}

func (b *IceCreamBuilder) SetFlavor(flavorName string, price float64) *IceCreamBuilder {
    b.flavors = append(b.flavors, Flavor{Name: flavorName, Price: price})
    return b
}

func (b *IceCreamBuilder) AddTopping(toppingName string, price float64) *IceCreamBuilder {
    b.toppings = append(b.toppings, Topping{Name: toppingName, Price: price})
    return b
}

func (b *IceCreamBuilder) Build() IceCream {
    totalCost := b.vessel.Price

   for _, flavor := range b.flavors {
        totalCost += flavor.Price
    }

    for _, topping := range b.toppings {
        totalCost += topping.Price
    }

    return IceCream{
        Vessel:    b.vessel,
        Flavors:    b.flavors,
        Toppings:  b.toppings,
        TotalCost: totalCost,
    }
}

func (i IceCream) getIceCreamIngredients() []string {
    options := []string{i.Vessel.Type}

     if len(i.Flovors) > 0 {
        for _, flavor := range i.Flavors {
            options = append(options, flavor.Name)
        }
    } 

    if len(i.Toppings) > 0 {
        for _, topping := range i.Toppings {
            options = append(options, topping.Name)
        }
    } 

    return options
}

// creating the order

func main() {
    iceCream := NewIceCreamBuilder().
        SetVessel("Cone", 1.50).          
        SetFlavor("Vanilla", 2.00).        
        AddTopping("Chocolate Chips", 0.75). 
        AddTopping("Sprinkles", 0.50).    
        Build()

    iceCreamOptions := iceCream.getIceCreamIngredients()


    for _, option := range iceCreamOptions {
        fmt.Println(option)
    }

   fmt.Println("Total Cost: $%.2f\n", iceCream.TotalCost)
}

Enter fullscreen mode Exit fullscreen mode

How is this less maintainable than a decorator?

Thread Thread
 
architagr profile image
Archit Agarwal

can you try extending this to also make a sandwich Vessel, where we have 2 layer of vessel having any flavour of ice cream or topping in between.

Thread Thread
 
xwero profile image
david duymelinck • Edited

The vessel can be anything that it why it has a type property.

If it is a sandwich it has two wafers,one at the top and one at te bottom. If people want it to make it look like two sandwiches they could add wafers as topping.

For the layering I would add aiceCreamOrder integer to the Flavor and the Topping and use that property in getIceCreamIngredients.

How would you make a sandwich with the decorators?

Embedded BI Dashboards are 💩 Building them yourself is 💩

Embedded BI Dashboards are 💩 Building them yourself is 💩

Use our developer toolkit to build fast-loading, native-feeling dashboards for your customers (without the sh*t).

Get early access

👋 Kindness is contagious

Discover fresh viewpoints in this insightful post, supported by our vibrant DEV Community. Every developer’s experience matters—add your thoughts and help us grow together.

A simple “thank you” can uplift the author and spark new discussions—leave yours below!

On DEV, knowledge-sharing connects us and drives innovation. Found this useful? A quick note of appreciation makes a real impact.

Okay