Factory: создаём объекты без if-адских условий

Factory: создаём объекты без if‑адских условий

Когда new начинает плодиться по коду с одинаковыми условиями - пора звать Factory. Разберём, как и зачем, без академической путаницы вариантов.

Проблема

Представь: в проекте есть 3 способа отправить уведомление - email, SMS, push. Создание нужного отправителя размазано по 10 файлам:

// handler.go
if cfg.Channel == "email" {
    sender = &EmailSender{host: cfg.SMTPHost, port: cfg.SMTPPort}
} else if cfg.Channel == "sms" {
    sender = &SmsSender{apiKey: cfg.SmsKey}
} else {
    sender = &PushSender{token: cfg.PushToken}
}
<?php
declare(strict_types=1);

// handler.php
if ($cfg->channel === 'email') {
    $sender = new EmailSender($cfg->smtpHost, $cfg->smtpPort);
} elseif ($cfg->channel === 'sms') {
    $sender = new SmsSender($cfg->smsKey);
} else {
    $sender = new PushSender($cfg->pushToken);
}
// handler.js
let sender;
if (cfg.channel === 'email') {
  sender = new EmailSender(cfg.smtpHost, cfg.smtpPort);
} else if (cfg.channel === 'sms') {
  sender = new SmsSender(cfg.smsKey);
} else {
  sender = new PushSender(cfg.pushToken);
}

Тот же if-else в другом сервисе, в тестах, в CLI. Добавил четвёртый канал - правишь 10 мест. Забыл одно - баг.

Решение: фабрика

Factory - функция (или структура), которая инкапсулирует создание объектов. Вызывающий код не знает, как именно создаётся реализация - он получает интерфейс.

Каждый caller (handler, worker, cli) сам решает, какую реализацию создавать. Знание о всех типах протекает в каждое место создания: цепочки `if/else`, прямые `new EmailSender(...)`. Любая правка - синхронно во всех местах. Caller вызывает один метод фабрики и получает обратно интерфейс `Sender`. Какая именно реализация - `Email`, `SMS` или `Push` - спрятано внутри. Новый канал добавляется одной правкой в фабрике, остальной код не трогается.

Выбор платёжного провайдера

type Payment interface {
    Charge(amount int) error
}

type StripePayment struct{ apiKey string }
func (s StripePayment) Charge(amount int) error {
    // вызов Stripe API
    return nil
}

type YooKassaPayment struct{ shopID string }
func (y YooKassaPayment) Charge(amount int) error {
    // вызов YooKassa API
    return nil
}

func NewPayment(provider string, cfg Config) (Payment, error) {
    switch provider {
    case "stripe":
        return StripePayment{apiKey: cfg.StripeKey}, nil
    case "yookassa":
        return YooKassaPayment{shopID: cfg.YooKassaShop}, nil
    default:
        return nil, fmt.Errorf("unknown payment provider: %s", provider)
    }
}

Теперь добавление нового провайдера - одна правка в фабрике.

interface Payment
{
    public function charge(int $amount): void;
}

final class StripePayment implements Payment
{
    public function __construct(
        private readonly string $apiKey,
    ) {}

    public function charge(int $amount): void
    {
        // Stripe API
    }
}

final class YooKassaPayment implements Payment
{
    public function __construct(
        private readonly string $shopId,
    ) {}

    public function charge(int $amount): void
    {
        // YooKassa API
    }
}

final class PaymentFactory
{
    public static function make(string $provider, Config $cfg): Payment
    {
        return match ($provider) {
            'stripe' => new StripePayment($cfg->stripeKey),
            'yookassa' => new YooKassaPayment($cfg->yooKassaShop),
            default => throw new InvalidArgumentException("unknown: $provider"),
        };
    }
}

Добавление нового провайдера - один кейс в match.

class StripePayment {
  #apiKey;
  constructor(apiKey) { this.#apiKey = apiKey; }
  charge(amount) { /* вызов Stripe API */ }
}

class YooKassaPayment {
  #shopId;
  constructor(shopId) { this.#shopId = shopId; }
  charge(amount) { /* вызов YooKassa API */ }
}

class PaymentFactory {
  static make(provider, cfg) {
    switch (provider) {
      case 'stripe':   return new StripePayment(cfg.stripeKey);
      case 'yookassa': return new YooKassaPayment(cfg.yooKassaShop);
      default:
        throw new Error(`unknown payment provider: ${provider}`);
    }
  }
}

const payment = PaymentFactory.make('stripe', { stripeKey: 'sk_test_...' });
payment.charge(1000);

В JS фабрика - это просто статический метод или обычная функция. Можно вообще без класса: const makePayment = (provider, cfg) => ({ stripe: ..., yookassa: ... })[provider]. Класс полезен, если фабрика хранит общие зависимости (логгер, конфиг).

Factory Method vs Abstract Factory

Это два разных паттерна, хотя название похоже:

Factory Method vs Abstract Factory: один объект против семейства

В Go чаще всего используется простой Factory Method - функция New...().

Factory в Go stdlib

Фабрики в Go встречаются повсюду - каждая функция New... это Factory Method:

http.NewRequest(method, url, body) - создаёт *Request
bufio.NewReader(r) - создаёт *Reader с буфером
sql.Open(driver, dsn) - создаёт *DB по имени драйвера
errors.New(text) - создаёт error

sql.Open - классическая фабрика: ты передаёшь "postgres" или "mysql", а внутри switch по зарегистрированным драйверам.

Фабрика живёт рядом с конфигом или композицией приложения (пакет `app`, `cmd`, `internal/config`). Бизнес-логика работает с [интерфейсом](../go/09-interfaces.md) и не знает, какая реализация создана.

Когда НЕ использовать

Один тип - если у тебя одна реализация и вторая не планируется, фабрика - лишний слой. Просто создай объект напрямую.

Тривиальное создание - если конструктор не содержит логики (&MyStruct{field: val}), оборачивать его в фабрику бессмысленно.

Фабрика ради фабрики - если switch по типам встречается в одном месте и не растёт, можно оставить как есть. Фабрика нужна когда создание объектов дублируется или усложняется.

Не создавай `NewLogger(kind string)` если у тебя только `ConsoleLogger`. Фабрика оправдана при 2+ реализациях. До этого - YAGNI (You Ain't Gonna Need It).

Factory + Registry: расширяемая фабрика

Если типы добавляются часто, можно вместо switch использовать реестр:

var registry = map[string]func(Config) (Payment, error){}

func Register(name string, fn func(Config) (Payment, error)) {
    registry[name] = fn
}

func NewPayment(name string, cfg Config) (Payment, error) {
    fn, ok := registry[name]
    if !ok {
        return nil, fmt.Errorf("unknown payment: %s", name)
    }
    return fn(cfg)
}

func init() {
    Register("stripe", func(c Config) (Payment, error) {
        return StripePayment{apiKey: c.StripeKey}, nil
    })
}

Именно так работает database/sql - драйверы регистрируют себя через sql.Register() в init().

final class PaymentRegistry
{
    /** @var array<string, callable(Config): Payment> */
    private array $factories = [];

    public function register(string $name, callable $factory): void
    {
        $this->factories[$name] = $factory;
    }

    public function make(string $name, Config $cfg): Payment
    {
        if (!isset($this->factories[$name])) {
            throw new InvalidArgumentException("unknown payment: $name");
        }

        return ($this->factories[$name])($cfg);
    }
}

$registry = new PaymentRegistry();
$registry->register('stripe', fn (Config $c) => new StripePayment($c->stripeKey));
$registry->register('yookassa', fn (Config $c) => new YooKassaPayment($c->yooKassaShop));

Похожая идея - в Symfony DI: сервисы помечают #[AsTaggedItem('payment')], и контейнер собирает их в коллекцию автоматически.

class PaymentRegistry {
  #factories = new Map();

  register(name, factory) {
    this.#factories.set(name, factory);
  }

  make(name, cfg) {
    const factory = this.#factories.get(name);
    if (!factory) {
      throw new Error(`unknown payment: ${name}`);
    }
    return factory(cfg);
  }
}

const registry = new PaymentRegistry();
registry.register('stripe',   (c) => new StripePayment(c.stripeKey));
registry.register('yookassa', (c) => new YooKassaPayment(c.yooKassaShop));

const payment = registry.make('stripe', { stripeKey: 'sk_...' });

В JS Map идиоматичнее обычного объекта - у неё нет конфликтов с прототипом и она оптимизирована под частые set/get. Регистрация при загрузке модуля похожа на init() в Go: импортируешь файл - стратегия зарегистрировалась.

Связь с другими паттернами

Factory + Strategy - фабрика создаёт стратегию по конфигу
Factory + Singleton - фабрика может кэшировать единственный экземпляр
Factory + DI - в Clean Architecture фабрика живёт в composition root

Подробнее о роли фабрики в правильном направлении зависимостей - в уроке Dependency Inversion.

Мини-задание

  • Найди в проекте 2-3 места, где создаётся один и тот же тип с разной конфигурацией - собери в одну фабрику
  • Посмотри database/sql.Open() - как он выбирает драйвер? Найди sql.Register()
  • Попробуй реализовать фабрику с registry (map + Register) для своего use case

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.