This section covers advanced design patterns commonly used in Go applications. These patterns help structure code for maintainability, testability, and scalability.
Dependency injection (DI) is a design pattern where dependencies are provided to a component rather than created within it.
Dependency injection matters because it improves testability (easy to mock dependencies in tests), provides flexibility (swap implementations without changing code), enables decoupling (components don't know about concrete implementations), and improves maintainability (clear dependencies make code easier to understand).
Go doesn't have built-in DI frameworks like some languages, but you can implement DI using interfaces and constructor functions.
Basic dependency injection
package main
import "fmt"
// Interface defines what we need
type Logger interface {
Log(msg string)
}
// Concrete implementation
type ConsoleLogger struct{}
func (l *ConsoleLogger) Log(msg string) {
fmt.Println(msg)
}
// Service depends on Logger interface
type Service struct {
logger Logger
}
// Constructor injects dependency
func NewService(logger Logger) *Service {
return &Service{logger: logger}
}
func (s *Service) DoWork() {
s.logger.Log("Doing work...")
}
func main() {
logger := &ConsoleLogger{}
service := NewService(logger)
service.DoWork()
}
Interfaces are key because they allow you to inject any implementation. The service doesn't know or care about the concrete type.
Testing with dependency injection
// Mock implementation for testing
type MockLogger struct {
messages []string
}
func (m *MockLogger) Log(msg string) {
m.messages = append(m.messages, msg)
}
func TestService(t *testing.T) {
mockLogger := &MockLogger{}
service := NewService(mockLogger)
service.DoWork()
if len(mockLogger.messages) != 1 {
t.Error("Expected one log message")
}
}
DI makes testing easier because you can inject mock implementations without modifying the service code. Tests are isolated and fast.
Multiple dependencies
type Database interface {
Query(query string) ([]Row, error)
}
type Cache interface {
Get(key string) (string, bool)
Set(key string, value string)
}
type Service struct {
db Database
cache Cache
logger Logger
}
func NewService(db Database, cache Cache, logger Logger) *Service {
return &Service{
db: db,
cache: cache,
logger: logger,
}
}
Explicit dependencies make all dependencies visible in the constructor. It's clear what the service needs to function.
Middleware is code that executes before and/or after the main handler. It's common in web frameworks and HTTP handlers.
Middleware is useful for cross-cutting concerns (logging, authentication, rate limiting), reusability (write once, apply to many handlers), composition (chain multiple middlewares), and separation of concerns (keep handlers focused).
Middleware wraps a handler, executing code before and after the handler runs.
Basic middleware pattern
package main
import (
"fmt"
"net/http"
"time"
)
type Middleware func(http.Handler) http.Handler
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Before handler
fmt.Printf("Request: %s %s\n", r.Method, r.URL.Path)
// Call next handler
next.ServeHTTP(w, r)
// After handler
fmt.Printf("Duration: %v\n", time.Since(start))
})
}
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Validate token...
next.ServeHTTP(w, r)
})
}
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, World!")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
// Chain middlewares
handler := LoggingMiddleware(AuthMiddleware(mux))
http.ListenAndServe(":8080", handler)
}
Each middleware is a function that takes a handler and returns a handler. This allows easy chaining through function composition.
Middleware chain helper
func Chain(middlewares ...Middleware) Middleware {
return func(next http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
next = middlewares[i](next)
}
return next
}
}
// Usage
handler := Chain(LoggingMiddleware, AuthMiddleware, RateLimitMiddleware)(mux)
The chain helper improves readability (clear order of execution), reusability (easy to apply same chain to multiple handlers), and flexibility (easy to add/remove middlewares).
Plugins allow extending applications at runtime without recompiling.
Plugins are useful because they provide extensibility (add features without modifying core), modularity (separate concerns into plugins), third-party extensions (allow others to extend your application), and dynamic loading (load plugins as needed).
Go's plugin package (Linux/macOS only) allows loading shared libraries at runtime.
Basic plugin pattern
// plugin.go
package main
import "fmt"
func Greet(name string) {
fmt.Printf("Hello, %s!\n", name)
}
// Build as plugin: go build -buildmode=plugin -o plugin.so plugin.go
// main.go
package main
import (
"plugin"
"fmt"
)
func main() {
p, err := plugin.Open("plugin.so")
if err != nil {
panic(err)
}
greet, err := p.Lookup("Greet")
if err != nil {
panic(err)
}
greetFunc := greet.(func(string))
greetFunc("World")
}
Go's plugin system has limitations: platform support (only Linux and macOS), version compatibility (plugins must be built with same Go version), and type safety (limited type checking at compile time).
Alternative: Interface-based plugins
// Define plugin interface
type Plugin interface {
Name() string
Execute(config map[string]interface{}) error
}
// Plugin registry
type PluginRegistry struct {
plugins map[string]Plugin
}
func NewRegistry() *PluginRegistry {
return &PluginRegistry{
plugins: make(map[string]Plugin),
}
}
func (r *PluginRegistry) Register(p Plugin) {
r.plugins[p.Name()] = p
}
func (r *PluginRegistry) Get(name string) (Plugin, bool) {
p, ok := r.plugins[name]
return p, ok
}
// Concrete plugin
type LoggingPlugin struct{}
func (p *LoggingPlugin) Name() string {
return "logging"
}
func (p *LoggingPlugin) Execute(config map[string]interface{}) error {
// Plugin logic
return nil
}
Interface-based plugins provide cross-platform support (works everywhere), type safety (compile-time type checking), flexibility (easy to implement and test), and simpler deployment (no runtime loading).
Idiomatic Go Note While Go allows complex abstractions and design patterns, the idiomatic approach is to prefer simplicity over abstraction. Over-engineered abstractions can make code harder to understand and maintain. Go's philosophy favors clear, straightforward code over clever abstractions. Use patterns when they genuinely solve a problem, not just because they're possible.
1. Builder Pattern
type QueryBuilder struct {
table string
fields []string
where []string
}
func NewQueryBuilder() *QueryBuilder {
return &QueryBuilder{}
}
func (b *QueryBuilder) Table(name string) *QueryBuilder {
b.table = name
return b
}
func (b *QueryBuilder) Select(fields ...string) *QueryBuilder {
b.fields = fields
return b
}
func (b *QueryBuilder) Where(condition string) *QueryBuilder {
b.where = append(b.where, condition)
return b
}
func (b *QueryBuilder) Build() string {
// Build SQL query
return "SELECT ..."
}
The builder pattern provides a fluent API (readable method chaining), handles many optional parameters, and allows validation before building.
2. Strategy Pattern
type PaymentStrategy interface {
Pay(amount float64) error
}
type CreditCardStrategy struct{}
func (c *CreditCardStrategy) Pay(amount float64) error {
// Credit card payment
return nil
}
type PayPalStrategy struct{}
func (p *PayPalStrategy) Pay(amount float64) error {
// PayPal payment
return nil
}
type PaymentProcessor struct {
strategy PaymentStrategy
}
func (p *PaymentProcessor) ProcessPayment(amount float64) error {
return p.strategy.Pay(amount)
}
The strategy pattern enables algorithm selection at runtime (choose algorithm at runtime), extensibility (easy to add new strategies), and testability (test each strategy independently).
3. Observer Pattern
type Event string
type Observer interface {
Notify(event Event)
}
type Subject struct {
observers []Observer
}
func (s *Subject) Subscribe(obs Observer) {
s.observers = append(s.observers, obs)
}
func (s *Subject) NotifyAll(event Event) {
for _, obs := range s.observers {
obs.Notify(event)
}
}
The observer pattern enables decoupling (subject doesn't know about concrete observers), dynamic subscriptions (add/remove observers at runtime), and event-driven design (react to state changes).
4. Factory Pattern
type Database interface {
Connect() error
}
type MySQL struct{}
func (m *MySQL) Connect() error { return nil }
type PostgreSQL struct{}
func (p *PostgreSQL) Connect() error { return nil }
func NewDatabase(dbType string) (Database, error) {
switch dbType {
case "mysql":
return &MySQL{}, nil
case "postgres":
return &PostgreSQL{}, nil
default:
return nil, fmt.Errorf("unknown database type")
}
}
The factory pattern provides encapsulation (hide object creation logic), flexibility (easy to add new types), and centralized creation (single place for object creation).
These patterns help structure Go code for maintainability and testability. Choose patterns that fit your specific needs rather than forcing patterns everywhere.