← назад к разделу

Книге «Design Patterns» банды четырёх уже больше тридцати лет, но читать её как готовый каталог рецептов сегодня не нужно — половина паттернов давно растворилась в языке и фреймворках. Зачем тогда учить? Потому что стандартная библиотека Go, gRPC, любой HTTP-фреймворк разговаривают именно этим словарём. http.HandlerFunc, io.MultiWriter, httputil.ReverseProxy — это не случайные имена, это паттерны GoF в названиях типов. Не зная паттерн, трудно понять, почему тип устроен так, а не иначе.

Ниже — все 23 паттерна по классическим группам. Для каждого: суть в одном предложении, где он уже живёт в готовых инструментах и нужно ли его писать самому.

Порождающие паттерны

Эта группа отвечает на один вопрос: как правильно создавать объекты?

Singleton

Суть: один экземпляр на всё приложение.

Если создавать объект в каждом месте, где он нужен, получается несколько несвязанных копий с разным состоянием. Singleton решает это: объект существует в одном экземпляре, и все обращаются к одному и тому же.

В Go синглтон — это переменная уровня пакета или ленивая инициализация через sync.Once. Ещё лучше классического GoF-варианта со скрытым getInstance() — собрать объект один раз в main и передавать через конструктор: такая зависимость явная и легко подменяется в тестах.

Главное следствие: один экземпляр обслуживает все запросы параллельно, поэтому в синглтоне не должно быть изменяемого состояния без синхронизации — иначе возникает гонка горутин.

var (
	pricingOnce sync.Once
	pricing     *PricingService
)

func Pricing() *PricingService {
	pricingOnce.Do(func() {
		pricing = NewPricingService() // один экземпляр на всё приложение
	})
	return pricing
}

func TestCalculatesPrice(t *testing.T) {
	service := NewPricingService() // в тесте создаём напрямую — просто
}

Prototype

Суть: новый экземпляр при каждом запросе.

Противоположность Singleton: иногда нужен свежий объект для каждой операции, а не общий на всех. В Go это просто новое значение: конструктор вызывается на каждую операцию.

Ловушка: сохранить такой объект в поле долгоживущего сервиса — значит разделить его между всеми запросами, и «свежесть» потеряется вместе с потокобезопасностью. Решение — создавать экземпляр внутри операции:

type ReportController struct{}

func (c *ReportController) Create(req ReportRequest) (ReportDTO, error) {
	builder := NewReportBuilder() // каждый раз новый экземпляр
	return builder.With(req).Build()
}

Если создание дорогое, экземпляры переиспользуют через sync.Pool — но это уже оптимизация, а не дефолт.

Factory Method

Суть: создание объекта делегируется функции, скрывающей конкретный тип.

Вместо того чтобы везде создавать конкретную структуру, вызывающий код работает с интерфейсом, а фабричная функция решает, какую реализацию создать.

В Go фабричный метод — это функция-конструктор, возвращающая интерфейс: вызывающий код знает интерфейс, функция решает, какую реализацию сконфигурировать:

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{}
}

В прикладном коде функции-конструкторы — хороший способ создавать объекты с валидацией:

func NewOrder(customerID CustomerID, lines []OrderLine) (*Order, error) {
	if len(lines) == 0 {
		return nil, errors.New("заказ должен содержать хотя бы одну позицию")
	}
	return &Order{
		id:         GenerateOrderID(),
		customerID: customerID,
		lines:      lines,
		status:     StatusCreated,
	}, nil
}

Abstract Factory

Суть: фабрика, которая создаёт семейство связанных объектов.

Если Factory Method создаёт один объект, Abstract Factory создаёт целую группу объектов, которые должны «сочетаться» друг с другом.

В Go роль абстрактной фабрики играет database/sql/driver: зарегистрированный драйвер создаёт целое семейство согласованных объектов — соединения, стейтменты, транзакции. Какой будет реализация — Postgres или SQLite — решает строка подключения, не вызывающий код.

В прикладном коде Abstract Factory пишут редко: набор функций-конструкторов, выбранных в main по конфигурации, покрывает задачу проще.

Builder

Суть: пошаговая сборка сложного объекта с читаемым кодом.

Конструктор с восемью параметрами — источник ошибок: легко перепутать порядок, непонятно, что за что отвечает. Builder даёт именованные шаги для каждого поля.

В Go роль Builder чаще играют функциональные опции: grpc.NewClient(addr, grpc.WithTransportCredentials(...)) — каждая опция — именованный шаг сборки. А для объектов-параметров хватает литерала структуры: инициализация по именам полей даёт «именованные методы для каждого поля» бесплатно:

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

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

Структурные паттерны

Эта группа отвечает на вопрос: как правильно строить связи между объектами?

Adapter

Суть: преобразует один интерфейс в другой, которого ожидает клиент.

Представьте две заглушки разной формы: код ожидает один интерфейс, а внешняя библиотека предлагает другой. Adapter — переходник между ними.

В стандартной библиотеке эталон — http.HandlerFunc: роутер не знает, чем написан обработчик — методом типа или свободной функцией. Переходник http.HandlerFunc приводит обычную функцию к контракту http.Handler.

В прикладном коде Adapter — основа работы с внешними зависимостями: адаптер переводит доменный интерфейс в язык конкретного 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

Суть: абстракция и реализация развиваются независимо.

Классический пример: fs.FS — одна абстракция «файловая система», независимые реализации под диск (os.DirFS), архив (zip.Reader), вшитые в бинарь файлы (embed.FS). Код, читающий файл, не меняется при смене источника.

В прикладном коде Bridge в чистом виде почти не встречается — его заменяет связка «интерфейс + внедрение зависимости».

Composite

Суть: группа объектов используется так же, как один объект.

Нужно отправить уведомление одновременно по email и SMS, но вызывающий код не должен знать о деталях. Composite позволяет «завернуть» несколько объектов в один, реализующий тот же интерфейс.

В стандартной библиотеке узнаётся по префиксу Multi: io.MultiWriter, io.MultiReader — несколько источников выглядят как один. errors.Join делает то же с ошибками.

type CompositeNotifier struct {
	channels []NotificationPort
}

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

Decorator

Суть: объект оборачивается в обёртку с тем же интерфейсом, добавляющую поведение.

Нужно добавить кеширование к репозиторию, но менять его тип нельзя (или не хочется). Decorator создаёт обёртку с тем же интерфейсом, которая перехватывает вызовы и добавляет нужное поведение.

В Go это HTTP-middleware: обёртка принимает http.Handler и возвращает http.Handler с добавленным поведением — логирование, сжатие, аутентификация в любом роутере устроены именно так. bufio.Reader поверх io.Reader — тоже Decorator по сути.

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

Суть: простой интерфейс над сложной подсистемой.

Работа с HTTP напрямую требует: создать клиент, собрать запрос, выставить заголовки, выполнить, прочитать и закрыть тело. http.Get(url) скрывает всю эту сложность за одним вызовом.

В стандартной библиотеке фасадов много: http.Get, sql.DB (пул соединений, prepared statements и драйвер за простыми методами), json.Marshal. В прикладном коде фасад над внешним SDK — нормальная форма адаптера: один метод может скрывать три вызова стороннего API, повторные попытки и преобразование ошибок.

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

Суть: разделяемые неизменяемые объекты вместо тысяч одинаковых копий.

Если создавать по объекту на каждое слово в тексте, память закончится быстро. Flyweight разделяет объекты с одинаковым содержимым — один экземпляр на значение.

В Go это пакет unique (Go 1.23+): канонизация одинаковых значений даёт один экземпляр на значение. Во фреймворках и рантайме — внутренние кеши метаданных типов.

В прикладном коде Flyweight почти никогда не пишут руками. Его идею несут неизменяемые value objects и константы: CurrencyRUB одна на всё приложение именно потому, что неизменяема.

Proxy

Суть: объект-заместитель контролирует доступ к реальному объекту.

Прокси перехватывает вызовы и делает что-то до или после: открывает транзакцию, проверяет права, кеширует результат.

В Go нет динамических прокси и аннотаций — их работу делают явные конструкции: httputil.ReverseProxy контролирует доступ к настоящему серверу, gRPC-interceptor'ы и HTTP-middleware перехватывают вызовы и делают что-то до и после.

Ловушка та же, что и у Java-прокси: перехват работает, только когда вызов идёт через обёртку — прямой вызов метода в обход interceptor'а ничего не перехватит (как это устроено в Java — в статье про AOP).

func AuthInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo,
	handler grpc.UnaryHandler) (any, error) {
	if err := authorize(ctx, info.FullMethod); err != nil { // до вызова — проверка прав
		return nil, err
	}
	return handler(ctx, req) // вызывающий код получает перехватчик, а не сам обработчик
}

Поведенческие паттерны

Эта группа отвечает на вопрос: как организовать взаимодействие объектов?

Chain of Responsibility

Суть: запрос идёт по цепочке обработчиков, пока кто-то его не обработает.

HTTP-запрос нужно сначала проверить на аутентификацию, потом на CSRF, потом на авторизацию, и каждый шаг может остановить обработку. Вместо одного огромного метода — цепочка независимых обработчиков.

В Go это цепочка HTTP-middleware — эталон паттерна. Каждый слой решает: обработать, передать дальше через next или остановить.

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)) // передаём дальше по цепочке
	})
}

Command

Суть: операция упакована в объект — её можно передать, отложить, поставить в очередь.

Обычно вызов метода мгновенный и безымянный. Command превращает операцию в объект с данными — его можно передать в другую горутину, отложить, залогировать, отменить.

В Go операция-объект — это структура с данными или замыкание func(), уходящее в канал воркер-пула: функция — значение первого класса.

В прикладном коде Command — структурная основа разделения «что делать» и «как делать»: один объект несёт данные операции, другой её исполняет.

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

Суть: язык с грамматикой и интерпретатор выражений на нём.

В Go это text/template и html/template: мини-язык с грамматикой ({{ .Name }}, {{ range }}) и интерпретатор выражений на нём. regexp — тоже Interpreter.

В собственном коде создавать мини-языки не стоит: строковые выражения не проверяются компилятором, ломаются при рефакторинге и усложняют отладку.

Iterator

Суть: последовательный доступ к элементам без раскрытия внутренней структуры.

Паттерн давно растворился в языке: range по слайсам, мапам и каналам, а с Go 1.23 — итераторы-функции iter.Seq для собственных коллекций.

Реализовывать Iterator руками не приходится. Единственное близкое решение — отдавать из коллекций агрегата копии слайсов или iter.Seq.

Mediator

Суть: объекты общаются через посредника, не зная друг о друге.

Если один сервис напрямую вызывает другой, они тесно связаны: изменение одного ломает другой. Mediator убирает прямую зависимость — объекты публикуют события, и кто хочет — подписывается.

В Go роль медиатора играет канал: издатель пишет событие, подписчики читают, и никто ни с кем не связан напрямую. http.ServeMux — тоже медиатор: диспетчеризует запросы обработчикам, которые не знают друг о друге.

func (h *CancelOrderHandler) Handle(ctx context.Context, cmd CancelOrderCommand) error {
	// ... логика отмены ...
	h.events <- OrderCancelled{OrderID: cmd.OrderID}
	return nil
}

// другой модуль, не знает об обработчике отмены
func RefundWorker(events <-chan OrderCancelled) {
	for event := range events {
		refund(event.OrderID)
	}
}

Memento

Суть: снимок состояния объекта для последующего отката.

Savepoint в транзакциях базы — чистый Memento: SAVEPOINT фиксирует точку, ROLLBACK TO SAVEPOINT откатывает к ней.

В прикладном коде почти никогда не нужен: откат состояния — работа транзакции базы данных, история изменений — отдельная таблица аудита или event sourcing.

Observer

Суть: подписчики получают уведомления об изменении состояния издателя.

Нужно отправить письмо при отмене заказа. Можно вызвать отправку прямо в бизнес-логике — но тогда бизнес-логика знает об email-сервисе. Observer разделяет их: бизнес-логика публикует событие, email-сервис подписывается.

В Go это колбэки и каналы: бизнес-логика пишет событие в канал, подписчик-горутина реагирует. Важный нюанс: если слушатель с внешними эффектами (письмо, SMS) срабатывает до коммита транзакции, уведомление может уйти по откаченной транзакции. Правильно — публиковать событие после успешного коммита (или через outbox-таблицу).

type OrderNotificationListener struct {
	notifications NotificationPort
}

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

State

Суть: поведение объекта меняется при смене его внутреннего состояния.

Заказ в статусе CREATED можно отменить. Заказ в статусе DELIVERED — нет. Логику переходов можно разложить в отдельные типы-состояния, но для большинства задач достаточно проверок в методах самого объекта:

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
}

Классический State с отдельным типом на каждое состояние оправдан при очень большой машине состояний. Для обычного объекта с несколькими статусами хватает констант-статусов и проверок переходов.

Strategy

Суть: семейство алгоритмов за общим интерфейсом, выбираемых в зависимости от ситуации.

Скидки для разных категорий покупателей: можно написать switch с условиями, а можно объявить функциональный тип DiscountPolicy и завести по функции на каждую категорию. Добавление новой категории — новая функция, а не правка switch.

В Go Strategy повсюду, и часто это просто функция: sort.Slice принимает стратегию сравнения, strings.FieldsFunc — стратегию разбиения, http.Client.CheckRedirect — стратегию редиректов.

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

Суть: скелет алгоритма постоянный, изменяемые шаги подставляются снаружи.

В Go наследования нет, поэтому от паттерна остался только современный вариант: скелет — функция, переменный шаг передаётся параметром-функцией. Транзакция всегда должна открыться, закоммититься при успехе и откатиться при ошибке — скелет withTx берёт это на себя, оставляя вызывающему только содержательную часть:

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 { // пишем только бизнес-логику, скелет гарантирует rollback
		tx.Rollback()
		return err
	}
	return tx.Commit()
}

Так же устроены filepath.WalkDir (скелет — обход дерева, переменный шаг — колбэк на каждый файл) и sync.OnceFunc.

Visitor

Суть: новая операция над структурой объектов без изменения их типов.

Есть иерархия типов PaymentMethod: Card, SBP, Cash. Нужно считать комиссию по-разному для каждого типа, не добавляя метод Fee() в каждый тип. Visitor добавляет операцию снаружи.

В Go его вытеснил type switch по закрытому набору типов:

// закрытый набор типов: неэкспортируемый метод-маркер
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("неизвестный способ оплаты")
	}
}

Все 23 паттерна: быстрая сводка

ПаттернГде встречается в готовых инструментахНужен ли в своём коде
SingletonПеременная уровня пакета, sync.OnceНе изобретать getInstance — сборка в main и передача через конструктор
PrototypeНовое значение структуры, sync.PoolРедко; чаще достаточно локальной переменной
Factory MethodФункции-конструкторы NewX, возвращающие интерфейсДа — конструкторы для создания объектов с валидацией
Abstract Factorydatabase/sql/driverНе нужен — конфигурацию собирают конструкторы в main
BuilderФункциональные опции (grpc.WithX), strings.BuilderДа — функциональные опции или литерал структуры
Adapterhttp.HandlerFuncДа — адаптеры к внешним зависимостям
Bridgefs.FS: os.DirFS, embed.FSПочти никогда — «интерфейс + DI» покрывает
Compositeio.MultiWriter, io.MultiReader, errors.JoinДа — когда нужно несколько получателей за одним интерфейсом
DecoratorHTTP-middleware, bufio.Reader поверх io.ReaderДа — обёртки над репозиториями; сначала проверьте готовые middleware
Facadehttp.Get, sql.DB, json.MarshalДа — адаптер-фасад над чужим SDK
FlyweightПакет unique (Go 1.23+)Почти никогда — идею несут неизменяемые value objects
Proxyhttputil.ReverseProxy, gRPC-interceptor'ыЯвные обёртки и interceptor'ы вместо динамической магии
Chain of ResponsibilityЦепочки HTTP-middlewareРедко — хватает готовых цепочек роутера
Commandfunc() + канал воркер-пулаДа — разделение «что» и «как» в обработчиках операций
Interpretertext/template, regexpНе изобретать собственных языков выражений
Iteratorrange, iter.Seq (Go 1.23+)Растворился в языке
MediatorКаналы, http.ServeMuxДа — события вместо прямых вызовов между модулями
MementoSavepoint в транзакцияхПочти никогда — откат делает транзакция базы данных
ObserverКаналы, колбэкиДа — доменные события, постоянно
StateКонстанты-статусы + проверки переходовДа, в облегчённой форме — статусы и проверки переходов
Strategysort.Slice, strings.FieldsFuncДа — функциональные типы вместо разрастающихся switch
Template Methodfilepath.WalkDir, sync.OnceFuncКолбэк-вариант — единственный в Go, наследования нет
Visitortype switchВытеснен type switch по закрытому набору типов

Из 23 паттернов в прикладном коде регулярно пишут семь–восемь: Adapter, Strategy, Observer, Command, Decorator, Composite, Factory Method и State. Ещё столько же используют каждый день в готовом виде, не замечая: Proxy, Singleton, Builder, Facade, Template Method, Chain of Responsibility. Остальные — словарь для чтения чужого кода.

Коротко

  • Паттерны GoF — не рецепты для копирования, а словарь: именно им разговаривают библиотеки в названиях типов.
  • Proxy в Go — явные обёртки: httputil.ReverseProxy, middleware, gRPC-interceptor'ы вместо динамических прокси.
  • Strategy — главный инструмент против разрастающихся switch; в Go это часто просто функциональный тип.
  • Observer — стандартный способ отделить побочные действия (письмо, метрика) от бизнес-логики; в Go — каналы и колбэки.
  • Adapter — основа работы с внешними зависимостями: домен знает интерфейс, адаптер знает конкретный SDK.
  • Singleton не пишут со скрытым getInstance() — переменная уровня пакета или sync.Once, а лучше сборка в main и передача через конструктор.
  • Decorator добавляет поведение без наследования — в Go это middleware и обёртки с тем же интерфейсом.
  • Из 23 паттернов регулярно пишут руками ~7; остальные живут в готовых инструментах.

Что почитать дальше

  • SOLID на примерах — принципы, ради которых эти паттерны существуют.
  • GRASP на примерах — какому классу отдать ответственность, прежде чем выбирать паттерн.
  • Spring AOP — как устроен Proxy, паттерн номер один Spring.
  • DI/IoC, bean scopes — Singleton и Prototype как scope контейнера.
  • Spring Events — Observer и Mediator в работе.