Template Method в Go без наследования

от автора

Привет, Хабр!

В статье рассмотрим, как реализовать 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 и другим темам здесь.


ссылка на оригинал статьи https://habr.com/ru/articles/912638/