Привет, Хабр!
В статье рассмотрим, как реализовать 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 и другим темам здесь.
Комментарии (4)
vbelogrudov
30.05.2025 07:14Template method на пару с abstract factory method - прерогатива языков с наследованием имплементации. Лучше эти паттерны там и оставить.
pin2t
Это все Java головного мозга. Java программисты вынуждены изобретать подобные "классы" потому что у них нельзя передать функцию как параметр.
В Go можно передать функцию как параметр, поэтому в Pipeline должны быть функции validate, calculate, generate. И не надо изобретать лишних ненужных типов
trepix
Почему нельзя, можно у нас передать функцию как параметр, используя функциональные интерфейсы
pin2t
интерфейс будет называться Validator, я про него и говорю, в Go он совершенно ненужен