← Back to the section

The Gang of Four's "Design Patterns" book is more than thirty years old, but you no longer need to read it as a ready-made catalog of recipes — half the patterns have long since dissolved into the language and frameworks. So why learn them? Because Go's standard library, gRPC, and every HTTP framework speak exactly this vocabulary. http.HandlerFunc, io.MultiWriter, httputil.ReverseProxy — these aren't random names, they're GoF patterns embedded in type names. Without knowing the pattern, it's hard to understand why a type is built the way it is and not some other way.

Below are all 23 patterns in their classic groups. For each one: the gist in a single sentence, where it already lives in off-the-shelf tools, and whether you need to write it yourself.

Creational Patterns

This group answers one question: how do you create objects the right way?

Singleton

Gist: one instance for the whole application.

If you create an object everywhere it's needed, you end up with several unrelated copies with different state. Singleton solves this: the object exists as a single instance, and everyone talks to the same one.

In Go, a singleton is a package-level variable or lazy initialization via sync.Once. Even better than the classic GoF variant with a hidden getInstance() — build the object once in main and pass it through constructors: such a dependency is explicit and easily swapped out in tests.

The key consequence: one instance serves all requests in parallel, so a singleton must not carry mutable state without synchronization — otherwise you get a goroutine race.

var (
	pricingOnce sync.Once
	pricing     *PricingService
)

func Pricing() *PricingService {
	pricingOnce.Do(func() {
		pricing = NewPricingService() // one instance for the entire application
	})
	return pricing
}

func TestCalculatesPrice(t *testing.T) {
	service := NewPricingService() // in a test we create it directly — simple
}

Prototype

Gist: a new instance on every request.

The opposite of Singleton: sometimes you need a fresh object for each operation, not a shared one. In Go this is simply a new value: the constructor is called for every operation.

The trap: storing such an object in a field of a long-lived service means sharing it across all requests — the "freshness" is lost along with thread safety. The solution is to create the instance inside the operation:

type ReportController struct{}

func (c *ReportController) Create(req ReportRequest) (ReportDTO, error) {
	builder := NewReportBuilder() // a new instance every time
	return builder.With(req).Build()
}

If creation is expensive, instances are reused via sync.Pool — but that's an optimization, not the default.

Factory Method

Gist: object creation is delegated to a function that hides the concrete type.

Instead of constructing a concrete struct everywhere, the calling code works with an interface, and the factory function decides which implementation to create.

In Go, a factory method is a constructor function returning an interface: the calling code knows the interface, the function decides which implementation to configure:

func NewClock(profile string) Clock {
	if profile == "integration-test" {
		return FixedClock{Time: time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC)}
	}
	return SystemClock{}
}

In application code, constructor functions are a good way to create objects with validation:

func NewOrder(customerID CustomerID, lines []OrderLine) (*Order, error) {
	if len(lines) == 0 {
		return nil, errors.New("an order must contain at least one line")
	}
	return &Order{
		id:         GenerateOrderID(),
		customerID: customerID,
		lines:      lines,
		status:     StatusCreated,
	}, nil
}

Abstract Factory

Gist: a factory that creates a family of related objects.

Where Factory Method creates a single object, Abstract Factory creates a whole group of objects that must "fit together."

In Go, the role of the abstract factory is played by database/sql/driver: a registered driver creates a whole family of consistent objects — connections, statements, transactions. Whether the implementation is Postgres or SQLite is decided by the connection string, not by the calling code.

In application code, Abstract Factory is rarely written by hand: a set of constructor functions selected in main by configuration covers the task more simply.

Builder

Gist: step-by-step assembly of a complex object with readable code.

A constructor with eight parameters is a source of bugs: it's easy to mix up the order, and it's unclear what does what. Builder gives named steps for each field.

In Go, the role of Builder is more often played by functional options: grpc.NewClient(addr, grpc.WithTransportCredentials(...)) — each option is a named assembly step. And for parameter objects a struct literal is enough: initialization by field names gives you "named methods for each field" for free:

type OrderSearchQuery struct {
	CustomerID *CustomerID
	Status     *OrderStatus
	Page       int
	Size       int
}

query := OrderSearchQuery{
	Status: &paid,
	Page:   0,
	Size:   20,
}

Structural Patterns

This group answers the question: how do you build the connections between objects the right way?

Adapter

Gist: converts one interface into another that the client expects.

Imagine two plugs of different shapes: your code expects one interface, but an external library offers another. Adapter is the connector between them.

In the standard library the reference implementation is http.HandlerFunc: the router doesn't know how a handler is written — as a method on a type or as a free function. The http.HandlerFunc connector brings a plain function to the http.Handler contract.

In application code, Adapter is the foundation for working with external dependencies: the adapter translates a domain interface into the language of a specific SDK:

type S3DocumentStorageAdapter struct {
	client *s3.Client
}

func (a *S3DocumentStorageAdapter) Store(ctx context.Context, document Document) (DocumentRef, error) {
	key := document.ID.String()
	_, err := a.client.PutObject(ctx, &s3.PutObjectInput{
		Bucket: aws.String("documents"),
		Key:    aws.String(key),
		Body:   bytes.NewReader(document.Content),
	})
	if err != nil {
		return DocumentRef{}, err
	}
	return DocumentRef{Key: key}, nil
}

Bridge

Gist: the abstraction and the implementation evolve independently.

The classic example: fs.FS — one abstraction "file system," independent implementations for the disk (os.DirFS), an archive (zip.Reader), files baked into the binary (embed.FS). The code that reads a file doesn't change when the source changes.

In application code, Bridge in its pure form is almost never seen — it's replaced by the "interface + dependency injection" combination.

Composite

Gist: a group of objects is used the same way as a single object.

You need to send a notification over email and SMS at the same time, but the calling code shouldn't know the details. Composite lets you "wrap" several objects into one that implements the same interface.

In the standard library it's recognized by the Multi prefix: io.MultiWriter, io.MultiReader — several sources look like one. errors.Join does the same with errors.

type CompositeNotifier struct {
	channels []NotificationPort
}

func (c *CompositeNotifier) OrderCancelled(order *Order) {
	for _, channel := range c.channels {
		channel.OrderCancelled(order)
	}
}

Decorator

Gist: an object is wrapped in a wrapper with the same interface that adds behavior.

You need to add caching to a repository, but you can't (or don't want to) change its type. Decorator creates a wrapper with the same interface that intercepts calls and adds the desired behavior.

In Go, this is HTTP middleware: the wrapper takes an http.Handler and returns an http.Handler with added behavior — logging, compression, and authentication in any router are built exactly this way. bufio.Reader over an io.Reader is also a Decorator in essence.

type CachingProductRepository struct {
	delegate ProductRepository
	cache    Cache
}

func (r *CachingProductRepository) FindByID(ctx context.Context, id ProductID) (*Product, error) {
	if product, ok := r.cache.Get(id); ok {
		return product, nil
	}
	product, err := r.delegate.FindByID(ctx, id)
	if err != nil {
		return nil, err
	}
	r.cache.Put(id, product)
	return product, nil
}

Facade

Gist: a simple interface over a complex subsystem.

Working with HTTP directly requires: create a client, build a request, set the headers, execute it, read and close the body. http.Get(url) hides all this complexity behind a single call.

The standard library is full of facades: http.Get, sql.DB (a connection pool, prepared statements and the driver behind simple methods), json.Marshal. In application code, a facade over an external SDK is a normal form of adapter: a single method can hide three third-party API calls, retries, and error translation.

type PaymentGatewayAdapter struct {
	sdk *paymentsdk.Client
}

func (a *PaymentGatewayAdapter) Charge(ctx context.Context, order *Order, method PaymentMethod) (PaymentResult, error) {
	response, err := a.sdk.Submit(ctx, paymentsdk.Request{
		Amount:   order.Total().Amount(),
		Currency: order.Total().Currency().Code(),
		Method:   method.Token(),
	})
	if err != nil {
		return PaymentResult{}, err
	}
	return PaymentResult{TransactionID: response.TransactionID, Status: response.Status}, nil
}

Flyweight

Gist: shared immutable objects instead of thousands of identical copies.

If you create one object per word in a text, memory runs out fast. Flyweight shares objects with identical content — one instance per value.

In Go, this is the unique package (Go 1.23+): canonicalizing identical values yields one instance per value. In frameworks and the runtime — internal caches of type metadata.

In application code, Flyweight is almost never written by hand. Its idea is carried by immutable value objects and constants: CurrencyRUB is one for the whole application precisely because it's immutable.

Proxy

Gist: a stand-in object controls access to the real object.

The proxy intercepts calls and does something before or after: opens a transaction, checks permissions, caches the result.

Go has no dynamic proxies or annotations — their job is done by explicit constructs: httputil.ReverseProxy controls access to the real server, gRPC interceptors and HTTP middleware intercept calls and do something before and after.

The trap is the same as with Java proxies: interception works only when the call goes through the wrapper — a direct method call that bypasses the interceptor intercepts nothing (how this works in Java — in the article on AOP).

func AuthInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo,
	handler grpc.UnaryHandler) (any, error) {
	if err := authorize(ctx, info.FullMethod); err != nil { // before the call — permission check
		return nil, err
	}
	return handler(ctx, req) // the calling code gets the interceptor, not the handler directly
}

Behavioral Patterns

This group answers the question: how do you organize the interaction between objects?

Chain of Responsibility

Gist: a request travels down a chain of handlers until someone handles it.

An HTTP request needs to be checked first for authentication, then for CSRF, then for authorization, and each step can stop processing. Instead of one huge method — a chain of independent handlers.

In Go, this is the HTTP middleware chain — the reference implementation of the pattern. Each layer decides: handle it, pass it on via next, or stop.

func TraceID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := context.WithValue(r.Context(), traceIDKey, resolveTraceID(r))
		next.ServeHTTP(w, r.WithContext(ctx)) // pass it further down the chain
	})
}

Command

Gist: an operation is packaged into an object — it can be passed around, deferred, queued.

Normally a method call is instant and anonymous. Command turns an operation into an object with data — it can be passed to another goroutine, deferred, logged, undone.

In Go, the operation-object is a struct with data or a func() closure going into a worker pool channel: a function is a first-class value.

In application code, Command is the structural foundation for separating "what to do" from "how to do it": one object carries the operation's data, another executes it.

type CancelOrderCommand struct {
	OrderID OrderID
	Reason  CancelReason
}

type CancelOrderHandler struct {
	orders OrderRepository
}

func (h *CancelOrderHandler) Handle(ctx context.Context, cmd CancelOrderCommand) error {
	order, err := h.orders.FindByID(ctx, cmd.OrderID)
	if err != nil {
		return err
	}
	if err := order.Cancel(cmd.Reason); err != nil {
		return err
	}
	return h.orders.Save(ctx, order)
}

Interpreter

Gist: a language with a grammar and an interpreter for expressions written in it.

In Go, this is text/template and html/template: a mini-language with a grammar ({{ .Name }}, {{ range }}) and an interpreter for it. regexp is also an Interpreter.

In your own code, creating mini-languages isn't worth it: string expressions aren't checked by the compiler, they break during refactoring, and they complicate debugging.

Iterator

Gist: sequential access to elements without exposing the internal structure.

The pattern has long since dissolved into the language: range over slices, maps and channels, and since Go 1.23 — function iterators iter.Seq for your own collections.

You never have to implement Iterator by hand. The one close case is returning slice copies or an iter.Seq from an aggregate's collections.

Mediator

Gist: objects communicate through a mediator, without knowing about each other.

If one service calls another directly, they become tightly coupled: changing one breaks the other. Mediator removes the direct dependency — objects publish events, and whoever wants to subscribes.

In Go, the role of the mediator is played by a channel: the publisher writes an event, subscribers read it, and nobody is directly coupled to anyone. http.ServeMux is also a mediator: it dispatches requests to handlers that don't know about each other.

func (h *CancelOrderHandler) Handle(ctx context.Context, cmd CancelOrderCommand) error {
	// ... cancellation logic ...
	h.events <- OrderCancelled{OrderID: cmd.OrderID}
	return nil
}

// a different module, unaware of the cancellation handler
func RefundWorker(events <-chan OrderCancelled) {
	for event := range events {
		refund(event.OrderID)
	}
}

Memento

Gist: a snapshot of an object's state for a later rollback.

A savepoint in database transactions is a pure Memento: SAVEPOINT records a point, ROLLBACK TO SAVEPOINT rolls back to it.

In application code it's almost never needed: rolling back state is the job of a database transaction, and change history is a separate audit table or event sourcing.

Observer

Gist: subscribers get notified when the publisher's state changes.

You need to send an email when an order is cancelled. You could trigger the send right inside the business logic — but then the business logic knows about the email service. Observer separates them: the business logic publishes an event, the email service subscribes.

In Go, this is callbacks and channels: the business logic writes an event to a channel, a subscriber goroutine reacts. An important nuance: if a listener with external effects (an email, an SMS) fires before the transaction commits, the notification may go out for a rolled-back transaction. The right way is to publish the event after a successful commit (or through an outbox table).

type OrderNotificationListener struct {
	notifications NotificationPort
}

func (l *OrderNotificationListener) Run(events <-chan OrderCancelled) {
	for event := range events {
		l.notifications.OrderCancelled(event.OrderID)
	}
}

State

Gist: an object's behavior changes as its internal state changes.

An order in the CREATED status can be cancelled. An order in the DELIVERED status can't. The transition logic can be split into separate state types, but for most tasks, checks inside the object's own methods are enough:

func (o *Order) Cancel(reason CancelReason) error {
	if o.status != StatusPaid && o.status != StatusCreated {
		return NewIllegalOrderStateError(o.id, o.status, "cancel")
	}
	o.status = StatusCancelled
	o.registerEvent(OrderCancelled{OrderID: o.id, Reason: reason})
	return nil
}

The classic State with a separate type per state is justified for a very large state machine. For an ordinary object with a few statuses, status constants plus transition checks are enough.

Strategy

Gist: a family of algorithms behind a common interface, chosen depending on the situation.

Discounts for different customer categories: you can write a switch with conditions, or you can declare a DiscountPolicy function type and create one function per category. Adding a new category means a new function, not editing a switch.

In Go, Strategy is everywhere, and often it's just a function: sort.Slice takes a comparison strategy, strings.FieldsFunc a splitting strategy, http.Client.CheckRedirect a redirect strategy.

type DiscountPolicy func(order *Order) (Money, bool)

func vipDiscount(order *Order) (Money, bool) {
	if !order.Customer().IsVIP() {
		return Money{}, false
	}
	return order.Total().Multiply(0.10), true
}

type DiscountService struct {
	policies []DiscountPolicy
}

func (s *DiscountService) CalculateDiscount(order *Order) Money {
	total := MoneyZero
	for _, policy := range s.policies {
		if discount, ok := policy(order); ok {
			total = total.Add(discount)
		}
	}
	return total
}

Template Method

Gist: the skeleton of the algorithm is constant, the changeable steps are supplied from outside.

Go has no inheritance, so only the modern variant of the pattern remains: the skeleton is a function, and the variable step is passed as a function parameter. A transaction must always open, commit on success and roll back on error — the withTx skeleton takes care of this, leaving the caller only the meaningful part:

func withTx(db *sql.DB, fn func(tx *sql.Tx) error) error {
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	if err := fn(tx); err != nil { // we write only the business logic, the skeleton guarantees rollback
		tx.Rollback()
		return err
	}
	return tx.Commit()
}

filepath.WalkDir (the skeleton is the tree walk, the variable step is the callback per file) and sync.OnceFunc are built the same way.

Visitor

Gist: a new operation over a structure of objects without changing their types.

There's a hierarchy of PaymentMethod types: Card, SBP, Cash. You need to compute the fee differently for each type without adding a Fee() method to every type. Visitor adds the operation from the outside.

In Go, it has been displaced by a type switch over a closed set of types:

// a closed set of types: an unexported marker method
type PaymentMethod interface{ isPaymentMethod() }

func (Card) isPaymentMethod() {}
func (SBP) isPaymentMethod()  {}
func (Cash) isPaymentMethod() {}

func fee(method PaymentMethod) decimal.Decimal {
	switch m := method.(type) {
	case Card:
		return m.Amount.Mul(decimal.NewFromFloat(0.02))
	case SBP:
		return decimal.Zero
	case Cash:
		return decimal.NewFromInt(50)
	default:
		panic("unknown payment method")
	}
}

All 23 Patterns: A Quick Summary

PatternWhere it shows up in off-the-shelf toolsDo you need it in your own code
SingletonPackage-level variable, sync.OnceDon't invent getInstance — build in main and pass through constructors
PrototypeA new struct value, sync.PoolRarely; a local variable is usually enough
Factory MethodNewX constructor functions returning an interfaceYes — constructors to create objects with validation
Abstract Factorydatabase/sql/driverNot needed — constructors in main assemble the configuration
BuilderFunctional options (grpc.WithX), strings.BuilderYes — functional options or a struct literal
Adapterhttp.HandlerFuncYes — adapters to external dependencies
Bridgefs.FS: os.DirFS, embed.FSAlmost never — "interface + DI" covers it
Compositeio.MultiWriter, io.MultiReader, errors.JoinYes — when you need several recipients behind one interface
DecoratorHTTP middleware, bufio.Reader over io.ReaderYes — wrappers over repositories; check ready-made middleware first
Facadehttp.Get, sql.DB, json.MarshalYes — an adapter-facade over someone else's SDK
FlyweightThe unique package (Go 1.23+)Almost never — the idea is carried by immutable value objects
Proxyhttputil.ReverseProxy, gRPC interceptorsExplicit wrappers and interceptors instead of dynamic magic
Chain of ResponsibilityHTTP middleware chainsRarely — the router's ready-made chains are enough
Commandfunc() + worker pool channelYes — separating "what" from "how" in operation handlers
Interpretertext/template, regexpDon't invent your own expression languages
Iteratorrange, iter.Seq (Go 1.23+)Dissolved into the language
MediatorChannels, http.ServeMuxYes — events instead of direct calls between modules
MementoSavepoint in transactionsAlmost never — the database transaction does the rollback
ObserverChannels, callbacksYes — domain events, constantly
StateStatus constants + transition checksYes, in a lightweight form — statuses and transition checks
Strategysort.Slice, strings.FieldsFuncYes — function types instead of sprawling switch statements
Template Methodfilepath.WalkDir, sync.OnceFuncThe callback variant is the only one in Go — there's no inheritance
Visitortype switchDisplaced by a type switch over a closed set of types

Of the 23 patterns, you'll regularly write seven or eight in application code: Adapter, Strategy, Observer, Command, Decorator, Composite, Factory Method, and State. Another handful you use ready-made every day without noticing: Proxy, Singleton, Builder, Facade, Template Method, Chain of Responsibility. The rest are vocabulary for reading other people's code.

In Short

  • GoF patterns aren't recipes to copy, they're a vocabulary: this is exactly what libraries speak in their type names.
  • Proxy in Go means explicit wrappers: httputil.ReverseProxy, middleware, gRPC interceptors instead of dynamic proxies.
  • Strategy is the main tool against sprawling switch statements; in Go it's often just a function type.
  • Observer is the standard way to separate side effects (an email, a metric) from the business logic; in Go — channels and callbacks.
  • Adapter is the foundation for working with external dependencies: the domain knows the interface, the adapter knows the concrete SDK.
  • Singleton isn't written with a hidden getInstance() — a package-level variable or sync.Once, or better yet, build in main and pass through constructors.
  • Decorator adds behavior without inheritance — in Go this is middleware and wrappers with the same interface.
  • Of the 23 patterns, you regularly write ~7 by hand; the rest live in off-the-shelf tools.
  • SOLID by Example — the principles these patterns exist for.
  • GRASP by Example — which class to give responsibility to before choosing a pattern.
  • Spring AOP — how Proxy, Spring's number-one pattern, is built.
  • DI/IoC, bean scopes — Singleton and Prototype as container scopes.
  • Spring Events — Observer and Mediator in action.