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 - функция (или структура), которая инкапсулирует создание объектов. Вызывающий код не знает, как именно создаётся реализация - он получает интерфейс.
Выбор платёжного провайдера
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
Это два разных паттерна, хотя название похоже:
В 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 по зарегистрированным драйверам.
Когда НЕ использовать
Один тип - если у тебя одна реализация и вторая не планируется, фабрика - лишний слой. Просто создай объект напрямую.
Тривиальное создание - если конструктор не содержит логики (&MyStruct{field: val}), оборачивать его в фабрику бессмысленно.
Фабрика ради фабрики - если switch по типам встречается в одном месте и не растёт, можно оставить как есть. Фабрика нужна когда создание объектов дублируется или усложняется.
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