Привет, Хабр!
В статье рассмотрим, как реализовать Template Method-паттерн в Go без наследования, зачем он вообще нужен.
Что делает Template Method и зачем он в бизнес-логике
Классическая формулировка: «Определяет скелет алгоритма в базовом классе, перекладывая реализацию отдельных шагов на наследников».
В CRUD-жизни разработчика это:
Жёсткий инвариант — шаги алгоритма должны идти именно в таком порядке: например, валидировать > рассчитать > сгенерировать PDF.
Гибкие детали — как конкретно валидировать или считать, зависит от домена: энергосбыт, телеком, маркетплейс.
В ООП-языках мы бы сделали abstract class InvoiceProcessor и наследников. Go мнит наследование злом и зовёт к композиции. И это плюс: мы получаем не «одну базу, много детей», а модульные кирпичи, которые можно свободно комбинировать между сервисами.
Переписываем OOP-паттерн через composition
Подход № 1: встроенные (embedded) типы
type InvoiceTemplate struct{} // Skeleton — не экспортируем, чтобы не вызвать напрямую извне. func (tpl InvoiceTemplate) run(i Invoice) error { if err := i.Validate(); err != nil { return fmt.Errorf("validation: %w", err) } if err := i.Calculate(); err != nil { return fmt.Errorf("calculation: %w", err) } return i.Generate() }
Клиентский процессор встраивает InvoiceTemplate и реализует переменные шаги через интерфейс:
type Invoice interface { Validate() error Calculate() error Generate() error } type PowerInvoice struct { InvoiceTemplate // embedded kWh float64 total money.Amount } func (p *PowerInvoice) Validate() error { /* … */ } func (p *PowerInvoice) Calculate() error { /* … */ } func (p *PowerInvoice) Generate() error { /* … */ }
Композицию видно невооружённым глазом. Однако, команды с линейкой кода на проде лихо копипастят InvoiceTemplate, забывают вызывать run.
Подход № 2: делегаты-функции
Go 1.22 всё ещё без дженериков-типа T any, F func(T) error, но банальные first-class-функции работают:
type Step func() error type Pipeline struct { Validate, Calculate, Generate Step } func (p Pipeline) Run() error { for _, step := range []Step{p.Validate, p.Calculate, p.Generate} { if err := step(); err != nil { return err } } return nil }
Такой Pipeline можно билдить на лету:
power := Pipeline{ Validate: validatePower, Calculate: calcPower, Generate: genPowerPDF, } if err := power.Run(); err != nil { log.Fatal(err) }
Flexibility level 9000, но появляется риск скрепить шаги в неправильный порядок. Лечится генератором или билд-функцией.
Интерфейсы как хуки для поведения
В Go интерфейс — проволока-крючок для DI. Задаём контракт «что нужно сделать», не размазывая «как именно».
type Validator interface { Validate() error } type Calculator interface { Calculate() error } type Generator interface { Generate() error } type InvoiceSteps interface { Validator Calculator Generator }
Пример внедрения:
type Processor struct { InvoiceSteps logger *zap.Logger env config.Env } func (p Processor) Run(ctx context.Context) error { // 1. логи, метрика, trace — общий инвариант p.logger.Info("invoice starting") if err := p.Validate(); err != nil { return err } // 2. расчёт можно отменить контекстом if err := ctx.Err(); err != nil { return err } if err := p.Calculate(); err != nil { return err } return p.Generate() }
Хуки здесь — интерфейсы. Хотите A/B-эксперимент новой формулы тарифа? Просто подмените Calculator в рантайме, не трогая остальной код.
Шаблон «валидация > расчёт > генерация»
Приведу кейс системы биллинга электроэнергии.
MeterReading — показания счётчика. Нужно: проверить данные, рассчитать итоговую сумму, сгенерировать счёт-фактуру (PDF + запись в БД).
package billing // шаги алгоритма type readingValidator interface { Validate(reading MeterReading) error } type tariffCalculator interface { Calculate(reading MeterReading) (money.Amount, error) } type billGenerator interface { Generate(reading MeterReading, sum money.Amount) (InvoiceID, error) } // конкретные имплементации type defaultValidator struct { maxDelta float64 } func (v defaultValidator) Validate(r MeterReading) error { if r.Value < 0 { return errors.New("negative reading") } if delta := r.Value - r.Prev; delta > v.maxDelta { return fmt.Errorf("suspicious leap: %v kWh", delta) } return nil } type peakHourCalculator struct { rates tariff.Table } func (c peakHourCalculator) Calculate(r MeterReading) (money.Amount, error) { var total money.Amount for _, slice := range c.rates.Applicable(r) { total = total.Add(slice.PriceFor(r)) } return total, nil } type pdfGenerator struct { storage storage.Blob tmpl render.Template } func (g pdfGenerator) Generate(r MeterReading, sum money.Amount) (InvoiceID, error) { doc, err := g.tmpl.Render(r, sum) if err != nil { return "", err } return g.storage.Save(doc) } // сам Template Method type InvoicePipeline struct { reader readingValidator calculator tariffCalculator generator billGenerator log *slog.Logger } func (p InvoicePipeline) Run(r MeterReading) (InvoiceID, error) { p.log.Debug("validate") if err := p.reader.Validate(r); err != nil { return "", fmt.Errorf("validation: %w", err) } p.log.Debug("calculate") sum, err := p.calculator.Calculate(r) if err != nil { return "", fmt.Errorf("calculation: %w", err) } p.log.Debug("generate") return p.generator.Generate(r, sum) }
Логи — structured, чтоб потом кормить в Loki. Конвертации валюты отдали отдельному сервису, иначе курс НБРБ ломал кеш.
Запускаем:
func NewPipeline(cfg config.Billing, s storage.Blob, log *slog.Logger) InvoicePipeline { return InvoicePipeline{ reader: defaultValidator{maxDelta: cfg.MaxDelta}, calculator: peakHourCalculator{rates: cfg.Tariffs}, generator: pdfGenerator{storage: s, tmpl: render.InvoiceTmpl}, log: log, } }
В main.go:
pipe := NewPipeline(cfg, blob, log) id, err := pipe.Run(reading) if err != nil { /* обработка */ }
Когда лучше Strategy или Chain of Responsibility
Когда бизнес-процесс состоит из фиксированной последовательности шагов — скажем, «валидируем > считаем > генерируем отчёт» — и эта линейка меняться не должна, удобнее всего брать Template Method: он задаёт скелет, а детали шагов оставляет на усмотрение внедряемых компонентов. В таких сценариях вы получаете прозрачный инвариант.
Strategy пригождается, когда сама формула алгоритма может радикально меняться от релиза к релизу: мы не просто меняем отдельный шаг, а подменяем всю вычислительную логику целиком. Здесь нужно отдавать разные реализации на ходу, не трогая остальную систему; шаблон даёт именно это, делегируя весь расчёт отдельной стратегии.
Chain of Responsibility вытаскивайте, когда шагов изначально неизвестно или их нужно включать/отключать динамически: каждый обработчик решает, брать ли запрос себе или передавать дальше. Логгеры, middleware, retry-политики, анти-фрод фильтры — классические примеры. Он не фиксирует порядок железобетонно, как Template, но и не требует менять весь алгоритм, как Strategy: вы просто наращиваете цепочку, не лезя в исходники существующих звеньев.
Вывод
Template Method в Go — жив, здоров и прекрасно обходится без наследования. Нужно лишь:
Держать скелет алгоритма рядом, чтобы не плодить хаос.
Использовать композицию вместо иерархий: embedded-типы или делегаты-функции.
Выставлять интерфейсы-хуки минимального размера.
Писать тесты на каждый шаг и end-to-end.
Если вам по душе идея Template Method без наследования — приходите на открытый урок «Создание микросервиса», который состоится 16 июня.
Следите за расписанием новых открытых уроков по Go и другим темам здесь.
