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
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.
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
ShippingCostfunction 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
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.
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
Sendercontract without changingnotify. - 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
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.
Output:
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
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.
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
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.
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
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.
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:
- What is changing or being duplicated?
- Where should that variation live?
- Can a function or concrete struct solve it?
- Would an interface create a useful boundary?
- 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.
Related articles in this blog
- Go Interfaces: One Helper, Multiple Behaviors — explore the small-interface foundation behind Strategy and dependency injection, including implementing contracts directly on types you own.
- Layered caching in Go: local memory, Redis, and Badger — see how a common store contract lets different backends participate in an ordered cache composition, with explicit TTL and failure policies. The layered topology is an illustrative design, not a claim about an existing production deployment.
Further reading
Other posts that might interest you...
Go Interfaces: One Helper, Multiple Behaviors
Use Go interfaces to share helpers across different structs and swap behavior without type switches.
Layered caching in Go: local memory, Redis, and Badger
Compose L1, L2, and L3 cache stores through one interface, with explicit read, write, TTL, and failure policies.
Keeping Write Paths Fast with Batching and Aggregation in Go
How gbuffer moves repeated writes off hot request paths with bounded batching, keyed aggregation, and a shared priority worker pool.