When our engineering team first adopted Go for our microservices stack, it felt like the perfect fit. Its clean syntax, lightweight concurrency model, and rich standard library allowed us to ship quickly without unnecessary complexity. For a while, everything clicked: services spun up with ease, deployments were fast, and onboarding new engineers was refreshingly simple.
But as our product scaled and the team grew, the simplicity that once felt like Go’s greatest strength started to show cracks. Each new microservice introduced not just fresh business logic, but also fresh inconsistencies.
Boilerplate code was copied and pasted across repositories. Interfaces were defined slightly differently from one service to another. Abstractions varied depending on who wrote them. Onboarding slowed down as no two services behaved the same, and debugging across services became a painstaking exercise.
The breaking point came during a major rollout where multiple services had to coordinate around pricing rules by region. Code became brittle, testing was a nightmare, and branching logic exploded. That was when we knew we needed architectural structure without sacrificing Go’s idiomatic clarity.
There is a belief in the Go community that design patterns are unnecessary overhead: that simplicity should reign and overengineering should be avoided. While minimalism is valuable, unstructured simplicity quickly becomes a liability as a codebase grows. Without intentional structure, duplicated logic creeps in, abstractions diverge, and debugging takes longer than writing the fix.
We did not adopt patterns because they were trendy. We adopted them where they solved recurring pain points:
Patterns were not about abstraction for its own sake. They were about predictability, reuse, and a shared mental model. In Go, they remain essential for several reasons:
Go avoids heavy OOP constructs like inheritance, but developers still face recurring problems: structuring applications, decoupling modules, and managing resources. Patterns like Factory, Strategy, and Middleware provide proven solutions to these challenges while respecting Go’s minimalism.
Patterns give teams a shared vocabulary. Saying “let’s use the Observer pattern here” communicates intent without walking through every line of code. This shared language makes systems more flexible, extensible, and organized, reducing technical debt and making projects easier to scale across teams.
Go shines in large-scale systems like cloud-native platforms, microservices, and networking tools. Patterns such as Adapter, Decorator, or Chain of Responsibility help structure complex interactions, balancing Go’s simplicity with the realities of distributed software.
Once we began adopting patterns, it became clear we were not short on talent. We were short on a shared playbook. Patterns stopped debates in code reviews and turned them into best practices. The three that had the biggest impact were:
The Factory pattern hides creation logic, letting client code request an instance without worrying about the details. In Go, this usually takes the form of simple factory functions rather than elaborate classes.
This approach helped us:
package main import "fmt" type Student struct { Name string Level string } // Factory function func NewStudent(name string, level string) Student { switch level { case "Undergraduate": return Student{Name: name, Level: "Undergraduate"} case "Graduate": return Student{Name: name, Level: "Graduate"} default: return Student{Name: name, Level: "Unknown"} } } func main() { s1 := NewStudent("Matthew", "Graduate") s2 := NewStudent("Alyssa", "Undergraduate") s3 := NewStudent("Peter", "PhD") // falls back to "Unknown" fmt.Println(s1) fmt.Println(s2) fmt.Println(s3) }
By centralizing object creation in NewStudent()
, we reduced onboarding friction and made it easy to evolve initialization logic without touching client code.
The Strategy pattern lets you swap algorithms at runtime without rewriting context code. Instead of hardcoding conditional logic, you define an interface that multiple strategies can implement.
This was crucial for our pricing engine, which had to handle different billing rules per country.
package main import "fmt" // Strategy interface type BillingStrategy interface { CalculatePrice(base float64) float64 } // Context type PricingEngine struct { Strategy BillingStrategy } func (pe PricingEngine) GetFinalPrice(base float64) float64 { return pe.Strategy.CalculatePrice(base) } // Concrete strategies type USBilling struct{} func (USBilling) CalculatePrice(base float64) float64 { return base * 1.08 } type UKBilling struct{} func (UKBilling) CalculatePrice(base float64) float64 { return base * 1.20 } type IndiaBilling struct{} func (IndiaBilling) CalculatePrice(base float64) float64 { return (base * 1.18) + 50 } func main() { base := 100.0 us := PricingEngine{Strategy: USBilling{}} fmt.Println("US Price:", us.GetFinalPrice(base)) uk := PricingEngine{Strategy: UKBilling{}} fmt.Println("UK Price:", uk.GetFinalPrice(base)) in := PricingEngine{Strategy: IndiaBilling{}} fmt.Println("India Price:", in.GetFinalPrice(base)) // Swap strategies at runtime engine := PricingEngine{Strategy: USBilling{}} fmt.Println("Initial (US):", engine.GetFinalPrice(base)) engine.Strategy = UKBilling{} fmt.Println("Swapped (UK):", engine.GetFinalPrice(base)) }
This pattern turned hardcoded billing rules into a modular, extensible system that is testable per country and flexible enough for A/B testing.
Middleware wraps HTTP handlers with additional functionality such as logging, authentication, rate limiting, or tracing without duplicating logic. By chaining middleware, we created composable pipelines with consistent behavior across services.
Benefits included:
Instead of scattering boilerplate across handlers, we chained middleware functions for clean, scalable composition.
We adopted patterns to solve immediate problems, but the broader impact surprised us:
With just three well-scoped patterns, our team’s velocity, confidence, and resilience improved without undermining Go’s simplicity.
Early on, we overdid it, wrapping everything in interfaces and injecting factories where a plain function would have worked. The lesson was clear: patterns are tools, not rules. Now we ask:
If the answer is yes, we use a pattern. Otherwise, we keep it simple.
Design patterns in Go are not about forcing object-oriented habits onto a language built for simplicity. They are about introducing consistency and clarity when minimalism alone begins to falter. With just a few well-chosen patterns, teams can reduce friction, avoid reinventing solutions, and focus more on solving real product challenges.
In our experience, patterns did not just clean up the codebase. They reshaped how we worked together. That is why design patterns still matter in Go.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowDiscover how the Chakra UI MCP server integrates AI into your editor, reducing context switching and accelerating development by fetching real-time documentation, component data, and code insights directly in-app.
fetch
callSkip the LangChain.js overhead: How to build a Retrieval-Augmented Generation (RAG) AI agent from scratch using just the native `fetch()` API.
Discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the October 8th issue.
Walk through building a data enrichment workflow that moves beyond simple lead gen to become a powerful internal tool for enterprises.
One Reply to "Why Go design patterns still matter"
Simple and clear!
Btw, seems images for strategy and middleware needs to be swaped.