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

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


  1. pin2t
    30.05.2025 07:14

    Validator, Calculator, Generator, Processor

    Это все Java головного мозга. Java программисты вынуждены изобретать подобные "классы" потому что у них нельзя передать функцию как параметр.
    В Go можно передать функцию как параметр, поэтому в Pipeline должны быть функции validate, calculate, generate. И не надо изобретать лишних ненужных типов


    1. trepix
      30.05.2025 07:14

      Почему нельзя, можно у нас передать функцию как параметр, используя функциональные интерфейсы


      1. pin2t
        30.05.2025 07:14

        интерфейс будет называться Validator, я про него и говорю, в Go он совершенно ненужен


  1. vbelogrudov
    30.05.2025 07:14

    Template method на пару с abstract factory method - прерогатива языков с наследованием имплементации. Лучше эти паттерны там и оставить.