←Back to Blog
Go Design Patterns: Start With the Problem, Not the Pattern

10/27/2026 • 33 min read

Go Design Patterns: Start With the Problem, Not the Pattern

A design pattern is useful when you can explain what gets easier after applying it. A payment provider becomes replaceable. HTTP handlers stop repeating logging code. A service becomes testable without a live network.

In Go, that usually means functions, small interfaces, and composition—not a hierarchy of classes. Some patterns below are classic design patterns; others, such as functional options and dependency injection, are API-design and dependency-management techniques. They solve different problems and can work together.

Each of the six sections shows the problem code, a pattern-based solution, and the concrete benefits of the change. The before and after examples are independent programs. Each Go block is a complete program: save it as main.go and run go run main.go. The examples use only the standard library. They are illustrative application code, not excerpts from an existing production service.

1. Strategy: choose how an operation behaves

Problem: Shipping prices vary by delivery method. Putting a delivery-method switch inside every checkout flow repeats the same decision and mixes pricing rules with orchestration.

Before: the problem in code

go
package main

import "fmt"

func checkout(subtotal, grams int, method string) int {
	switch method {
	case "flat":
		return subtotal + 500
	case "weight":
		return subtotal + ((grams+999)/1000)*200
	default:
		panic("unsupported shipping method")
	}
}

func quote(grams int, method string) int {
	// The same pricing rules have spread into a second flow.
	switch method {
	case "flat":
		return 500
	case "weight":
		return ((grams + 999) / 1000) * 200
	default:
		panic("unsupported shipping method")
	}
}

func main() {
	fmt.Println(checkout(3000, 1500, "flat"))
	fmt.Println(quote(1500, "weight"))
}

Output: 3500, then 400. Adding express shipping requires updating both switches. A forgotten branch can make the quote disagree with checkout.

After: apply the pattern

Move pricing rules into functions and supply the chosen behavior to the flow.

A strategy moves the varying behavior behind a common contract. If the behavior is one operation, a function type is often enough.

text
Checkout → selected shipping function → price in cents
                    ├─ flat rate
                    └─ weight based
go
package main

import "fmt"

type ShippingCost func(weightGrams int) int

func flatRate(_ int) int { return 500 }

func byWeight(weightGrams int) int {
	// Round up to the next kilogram; charge 200 cents per kilogram.
	return ((weightGrams + 999) / 1000) * 200
}

func checkout(subtotalCents, weightGrams int, shipping ShippingCost) int {
	return subtotalCents + shipping(weightGrams)
}

func main() {
	fmt.Println(checkout(3000, 1500, flatRate))
	fmt.Println(checkout(3000, 1500, byWeight))
}

Output: 3500, then 3400. The checkout flow stays the same; the caller selects the pricing behavior. Amounts use integer cents rather than floating-point currency. This example assumes nonnegative, realistically bounded weights and amounts so all intermediate calculations and results fit in int; an external-input boundary should validate those bounds.

Benefits after applying the pattern

  • One home for each pricing rule: Quotes can call the same ShippingCost function as checkout instead of copying a switch.
  • Stable orchestration: Adding express pricing introduces a new function without changing checkout.
  • Focused tests: Test each pricing function separately, then test checkout with a simple supplied function. Selection and input validation still need explicit policies.

When to use it: Several callers share an operation but choose different algorithms. Use a small interface instead of a function when a strategy has state or several related operations.

When not to: Two fixed cases in one place may be clearer as a switch. Moving the switch elsewhere is not automatically an improvement.

2. Adapter: translate an external contract

Problem: Your application wants to send a message to a recipient, but a vendor client takes a vendor-specific request object. Letting that shape spread through the application makes provider changes expensive.

Before: the problem in code

go
package main

import (
	"context"
	"fmt"
)

type VendorRequest struct{ Destination, Text string }
type VendorClient struct{}

func (*VendorClient) Publish(ctx context.Context, req VendorRequest) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	fmt.Printf("vendor: %s: %s\n", req.Destination, req.Text)
	return nil
}

func notify(ctx context.Context, client *VendorClient) error {
	// Application logic knows the vendor's payload and method name.
	return client.Publish(ctx, VendorRequest{
		Destination: "alice@example.com",
		Text:        "Order confirmed",
	})
}

func main() {
	if err := notify(context.Background(), &VendorClient{}); err != nil {
		panic(err)
	}
}

Output: vendor: alice@example.com: Order confirmed. This works, but every application flow using Publish is coupled to the vendor contract.

After: apply the pattern

Keep the vendor translation in one adapter and let application logic depend on its own small contract.

An adapter translates your application's contract into the dependency's contract.

text
Application → Sender.Send → vendor adapter → vendor client.Publish
go
package main

import (
	"context"
	"fmt"
)

// The consumer defines the behavior it needs.
type Sender interface {
	Send(ctx context.Context, to, body string) error
}

// These types stand in for an external SDK.
type VendorRequest struct {
	Destination string
	Text        string
}

type VendorClient struct{}

func (*VendorClient) Publish(ctx context.Context, req VendorRequest) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	fmt.Printf("vendor: %s: %s\n", req.Destination, req.Text)
	return nil
}

type VendorAdapter struct {
	client *VendorClient
}

func (a *VendorAdapter) Send(ctx context.Context, to, body string) error {
	return a.client.Publish(ctx, VendorRequest{
		Destination: to,
		Text:        body,
	})
}

func notify(ctx context.Context, sender Sender) error {
	return sender.Send(ctx, "alice@example.com", "Order confirmed")
}

func main() {
	adapter := &VendorAdapter{client: &VendorClient{}}
	if err := notify(context.Background(), adapter); err != nil {
		panic(err)
	}
}

Output: vendor: alice@example.com: Order confirmed. The vendor client here only prints a message; it makes no network call. A real adapter would also translate provider errors where the application needs stable error semantics.

Benefits after applying the pattern

  • Vendor details stay at the boundary: Request conversion lives in VendorAdapter, not in every notification flow.
  • Provider replacement is localized: A new provider can implement the same Sender contract without changing notify.
  • A smaller test surface: Test application behavior with a fake sender and test vendor mapping separately. An adapter does not remove the need for provider integration tests.

When to use it: Integrating an external SDK, legacy API, or data format that does not match your application's needs.

When not to: If you own a type, let it implement the interface directly. If an external type already has the right methods, pass it directly—Go's implicit interfaces mean no wrapper is needed.

3. Decorator / middleware: add behavior around an operation

Problem: Every handler repeats request logging. Updating that concern means touching unrelated business handlers.

Before: the problem in code

go
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func orders(w http.ResponseWriter, r *http.Request) {
	fmt.Println("before:", r.Method, r.URL.Path)
	w.WriteHeader(http.StatusNoContent)
	fmt.Println("after:", r.Method, r.URL.Path)
}

func products(w http.ResponseWriter, r *http.Request) {
	// Another handler duplicates the same logging policy.
	fmt.Println("before:", r.Method, r.URL.Path)
	w.WriteHeader(http.StatusNoContent)
	fmt.Println("after:", r.Method, r.URL.Path)
}

func main() {
	recorder := httptest.NewRecorder()
	orders(recorder, httptest.NewRequest(http.MethodGet, "/orders", nil))
	fmt.Println("status:", recorder.Code)
}

Output: before: GET /orders, then after: GET /orders, then status: 204. Updating the log format requires editing both handlers; a new handler can easily omit it.

After: apply the pattern

Wrap the handler once so business handlers no longer own the shared logging concern.

A decorator wraps an operation while keeping its contract. HTTP middleware is a familiar Go example: it accepts an http.Handler and returns another http.Handler.

text
Request → logging before → business handler → logging after → response
go
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func withLogging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Println("before:", r.Method, r.URL.Path)
		next.ServeHTTP(w, r)
		fmt.Println("after:", r.Method, r.URL.Path)
	})
}

func main() {
	handler := withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusNoContent)
	}))

	recorder := httptest.NewRecorder()
	request := httptest.NewRequest(http.MethodGet, "/orders", nil)
	handler.ServeHTTP(recorder, request)
	fmt.Println("status:", recorder.Code)
}

Output:

text
before: GET /orders
after: GET /orders
status: 204

The handler does not know it is wrapped. httptest exercises the request without starting a server. This wrapper demonstrates normal request flow; its after-log does not run if the handler panics. Production middleware should deliberately define its interaction with panic recovery.

Benefits after applying the pattern

  • One place to maintain logging: Change the wrapper rather than every business handler.
  • Independent business handlers: The same handler can run wrapped in production or unwrapped in a focused test.
  • Composable concerns: Logging can wrap authentication or metrics without merging them into handler code; ordering remains an explicit design decision.

When to use it: Shared logging, authentication, metrics, or tracing around compatible operations.

When not to: Business rules that belong inside the operation should not be hidden in a wrapper. Also check ordering: logging(auth(handler)) sees requests rejected by authentication; auth(logging(handler)) does not.

4. Constructor / factory function: establish valid initial state

Problem: Callers build a client with missing configuration, and the mistake is discovered only when the first request fails.

Before: the problem in code

go
package main

import "fmt"

type Client struct{ Endpoint, Token string }

func (c *Client) Send() error {
	// Configuration errors appear only when the client is used.
	if c.Endpoint == "" {
		return fmt.Errorf("endpoint is required")
	}
	if c.Token == "" {
		return fmt.Errorf("token is required")
	}
	return nil
}

func main() {
	client := &Client{Endpoint: "https://api.example.com"}
	fmt.Println("client created")
	fmt.Println(client.Send())
}

Output: client created, then token is required. The application can retain an invalid client and discover the missing token much later.

After: apply the pattern

Validate required configuration at creation and return an error before the client enters the application.

A constructor validates required inputs and creates a usable value. A Go NewClient function is not necessarily the classic GoF Factory Method pattern: there is no need to introduce subclass-based creation hooks.

text
Required configuration → validate → usable client or error
go
package main

import (
	"fmt"
	"net/url"
)

type Client struct {
	endpoint *url.URL
	token    string
}

func NewClient(endpoint, token string) (*Client, error) {
	u, err := url.Parse(endpoint)
	if err != nil {
		return nil, fmt.Errorf("parse endpoint: %w", err)
	}
	if (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
		return nil, fmt.Errorf("endpoint must be an absolute HTTP(S) URL")
	}
	if token == "" {
		return nil, fmt.Errorf("token is required")
	}
	return &Client{endpoint: u, token: token}, nil
}

func main() {
	client, err := NewClient("https://api.example.com", "example-token")
	if err != nil {
		panic(err)
	}
	fmt.Println(client.endpoint.Host)

	_, err = NewClient("https://api.example.com", "")
	fmt.Println(err)
}

Output: api.example.com, then token is required.

Benefits after applying the pattern

  • Earlier failure: Missing configuration is rejected during setup rather than on the first operation.
  • Consistent initialization: Callers use one validation path instead of repeating checks and defaults.
  • Clear error handling: Setup code can report a construction error before serving traffic. This does not make exported Go types impossible to zero-initialize.

When to use it: Creating a value requires validation, dependencies, or setup. Return a concrete type unless there is a specific reason to hide the implementation.

When not to: A struct literal or useful zero value already does the job. A constructor validates the creation path; it cannot stop someone declaring var client Client. Methods on types with an unusable zero value should handle that deliberately rather than assume construction is enforced by the language.

5. Functional options: keep optional configuration readable

Problem: A client constructor keeps growing: timeout, retries, logging, transport. Positional arguments become hard to read, especially booleans and repeated numeric types.

Before: the problem in code

go
package main

import (
	"fmt"
	"time"
)

type Client struct {
	endpoint string
	timeout  time.Duration
	retries  int
	logging  bool
}

func NewClient(endpoint string, timeout time.Duration, retries int, logging bool) *Client {
	return &Client{endpoint: endpoint, timeout: timeout, retries: retries, logging: logging}
}

func main() {
	// What do 0 and false mean? Every optional setting is mandatory here.
	client := NewClient("https://api.example.com", 2*time.Second, 0, false)
	fmt.Println(client.timeout)
}

Output: 2s. Callers must supply even settings they do not want to customize, and adding another positional parameter changes every call site.

After: apply the pattern

Use defaults and named options. The solution below keeps only timeout to show the mechanism; retry and logging options can follow the same shape when actually needed.

Functional options give optional settings names while leaving required dependencies visible.

text
Required dependency + defaults + named options → configured client
go
package main

import (
	"fmt"
	"time"
)

type Client struct {
	endpoint string
	timeout  time.Duration
}

type Option func(*Client) error

func WithTimeout(timeout time.Duration) Option {
	return func(c *Client) error {
		if timeout <= 0 {
			return fmt.Errorf("timeout must be positive")
		}
		c.timeout = timeout
		return nil
	}
}

func NewClient(endpoint string, options ...Option) (*Client, error) {
	if endpoint == "" {
		return nil, fmt.Errorf("endpoint is required")
	}
	client := &Client{endpoint: endpoint, timeout: 5 * time.Second}
	for _, option := range options {
		if option == nil {
			return nil, fmt.Errorf("option must not be nil")
		}
		if err := option(client); err != nil {
			return nil, fmt.Errorf("configure client: %w", err)
		}
	}
	return client, nil
}

func main() {
	client, err := NewClient("https://api.example.com", WithTimeout(2*time.Second))
	if err != nil {
		panic(err)
	}
	fmt.Println(client.timeout)

	_, err = NewClient("https://api.example.com", WithTimeout(0))
	fmt.Println(err)
}

Output: 2s, then configure client: timeout must be positive. This independent example focuses on option validation; a real HTTP client should also validate its endpoint as in the previous section.

Benefits after applying the pattern

  • Readable call sites: WithTimeout(2*time.Second) states the meaning of the value.
  • Less caller churn: New optional settings can be added without changing existing constructor calls.
  • Defaults and validation stay together: Omitted options use defaults, while invalid overrides return errors. The extra option functions are a trade-off, not free simplification.

When to use it: A public API has several independent optional settings and sensible defaults. Apply options during construction, not concurrently after the client is in use. Here, repeated timeout options are applied in order: the last valid value wins.

When not to: A small configuration struct is often easier to inspect and serialize. Do not hide required dependencies inside options, and do not use options to make conflicting modes look independent.

6. Dependency injection: separate wiring from business logic

Problem: An order service constructs a network client inside its confirmation method. Tests must either contact the real provider or work around hidden dependencies.

Before: the problem in code

go
package main

import (
	"context"
	"fmt"
)

// A print-only stand-in for a production network client.
type EmailClient struct{}

func (*EmailClient) Send(ctx context.Context, to, body string) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	fmt.Println(to + ": " + body)
	return nil
}

type OrderService struct{}

func (*OrderService) Confirm(ctx context.Context, email string) error {
	sender := &EmailClient{} // Hidden dependency constructed inside the operation.
	if err := sender.Send(ctx, email, "Order confirmed"); err != nil {
		return fmt.Errorf("send order confirmation: %w", err)
	}
	return nil
}

func main() {
	service := &OrderService{}
	if err := service.Confirm(context.Background(), "alice@example.com"); err != nil {
		panic(err)
	}
}

Output: alice@example.com: Order confirmed. This stand-in only prints, but a real internally constructed provider would prevent the caller from substituting a recording or failing sender without changing service code.

After: apply the pattern

Move dependency creation to application setup and supply a sender to the service.

Dependency injection means passing the dependency in. It does not require a framework, a container, or an interface for every struct.

text
Application startup ── inject sender ──→ order service → sender.Send
Test setup          ── inject fake   ──→ same service → recorded message
go
package main

import (
	"context"
	"fmt"
)

// This interface belongs to the service that consumes it.
type Sender interface {
	Send(ctx context.Context, to, body string) error
}

type OrderService struct {
	sender Sender
}

func NewOrderService(sender Sender) *OrderService {
	return &OrderService{sender: sender}
}

func (s *OrderService) Confirm(ctx context.Context, email string) error {
	if err := s.sender.Send(ctx, email, "Order confirmed"); err != nil {
		return fmt.Errorf("send order confirmation: %w", err)
	}
	return nil
}

type RecordingSender struct {
	Messages []string
}

func (s *RecordingSender) Send(ctx context.Context, to, body string) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	s.Messages = append(s.Messages, to+": "+body)
	return nil
}

func main() {
	sender := &RecordingSender{}
	service := NewOrderService(sender)
	if err := service.Confirm(context.Background(), "alice@example.com"); err != nil {
		panic(err)
	}
	fmt.Println(sender.Messages[0])
}

Output: alice@example.com: Order confirmed. A test can assert the recorded message, inject a sender that returns an error, or pass a cancelled context. No live provider is needed. The example assumes a non-nil sender; its recording fake is intended for sequential use, not concurrent writes.

Benefits after applying the pattern

  • No hidden provider creation: Application setup chooses and owns the dependency.
  • Deterministic service tests: A recording sender exposes calls, and a failing sender exercises error handling without network access.
  • Independent wiring changes: Switching providers does not require editing Confirm. Injection makes substitution possible; the correctness of a real provider still needs testing.

When to use it: You need control over external I/O, time, randomness, or another dependency during tests and application wiring. A real vendor adapter from the earlier section could satisfy this same Sender contract.

When not to: Do not introduce a dependency-injection framework just to pass two values into a constructor. Inject concrete types when substitution is unnecessary; extract a consumer-owned interface when it gives you a useful boundary.

How these patterns differ

Several examples pass values through constructors or interfaces. The distinguishing feature is the problem being solved:

  • Strategy: Which algorithm should execute?
  • Adapter: How do I translate an incompatible contract?
  • Decorator: How do I add behavior around an existing operation?
  • Constructor: How do I establish valid initial state?
  • Functional options: How do I express optional configuration clearly?
  • Dependency injection: Who supplies this component's dependencies?

One notification service might use all six: select a routing strategy, adapt a vendor SDK, wrap sending with metrics, validate configuration in a constructor, configure a timeout with an option, and inject the sender into business logic. That is a possible composition—not a checklist every service needs to satisfy.

Start with the smallest useful change

Before introducing a pattern, ask:

  1. What is changing or being duplicated?
  2. Where should that variation live?
  3. Can a function or concrete struct solve it?
  4. Would an interface create a useful boundary?
  5. Is the result easier to read, test, and change?

If you cannot name the problem a pattern solves in your code, you probably do not need it yet.

Further reading

Other posts that might interest you...