Книге «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 Factory | database/sql/driver | Не нужен — конфигурацию собирают конструкторы в main |
| Builder | Функциональные опции (grpc.WithX), strings.Builder | Да — функциональные опции или литерал структуры |
| Adapter | http.HandlerFunc | Да — адаптеры к внешним зависимостям |
| Bridge | fs.FS: os.DirFS, embed.FS | Почти никогда — «интерфейс + DI» покрывает |
| Composite | io.MultiWriter, io.MultiReader, errors.Join | Да — когда нужно несколько получателей за одним интерфейсом |
| Decorator | HTTP-middleware, bufio.Reader поверх io.Reader | Да — обёртки над репозиториями; сначала проверьте готовые middleware |
| Facade | http.Get, sql.DB, json.Marshal | Да — адаптер-фасад над чужим SDK |
| Flyweight | Пакет unique (Go 1.23+) | Почти никогда — идею несут неизменяемые value objects |
| Proxy | httputil.ReverseProxy, gRPC-interceptor'ы | Явные обёртки и interceptor'ы вместо динамической магии |
| Chain of Responsibility | Цепочки HTTP-middleware | Редко — хватает готовых цепочек роутера |
| Command | func() + канал воркер-пула | Да — разделение «что» и «как» в обработчиках операций |
| Interpreter | text/template, regexp | Не изобретать собственных языков выражений |
| Iterator | range, iter.Seq (Go 1.23+) | Растворился в языке |
| Mediator | Каналы, http.ServeMux | Да — события вместо прямых вызовов между модулями |
| Memento | Savepoint в транзакциях | Почти никогда — откат делает транзакция базы данных |
| Observer | Каналы, колбэки | Да — доменные события, постоянно |
| State | Константы-статусы + проверки переходов | Да, в облегчённой форме — статусы и проверки переходов |
| Strategy | sort.Slice, strings.FieldsFunc | Да — функциональные типы вместо разрастающихся switch |
| Template Method | filepath.WalkDir, sync.OnceFunc | Колбэк-вариант — единственный в Go, наследования нет |
| Visitor | type 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 в работе.