Factory Method: wzorzec, którego nikt nie potrzebuje, dopóki nie potrzebuje go do wszystkiego
Podręcznikowe przykłady Factory Method dotyczą kształtów i zwierząt. ShapeFactory zwracające Circle lub Square na podstawie stringa. AnimalFactory konstruujące Dog lub Cat. Te przykłady są poprawne. Są też bezużyteczne jako wytyczne projektowe, bo w produkcji niczyja domena biznesowa nie dotyczy kształtów.
Wzorzec staje się ważny w momencie, gdy masz decyzję runtime o tym, którą implementację stworzyć, i punkt, w którym tę decyzję podejmujesz, nie powinien być rozsiany po całej bazie kodu.
Problem z bramką płatności
Obsługiwaliśmy cztery metody płatności: karta (Stripe), przelew bankowy (lokalny PSP), BLIK i finansowanie ratalne (integracja z podmiotem trzecim). Każda miała inne API, inne tryby błędów, inną semantykę retry, inne formaty webhooków.
Bez factory logika wyboru trafiła do kontrolera:
// Before: selection logic in the wrong place
class PaymentController
{
public function charge(Request $request): Response
{
$method = $request->input('payment_method');
if ($method === 'card') {
$gateway = new StripeGateway(config('stripe.secret'));
} elseif ($method === 'blik') {
$gateway = new BlikGateway(config('blik.merchant_id'), config('blik.api_key'));
} elseif ($method === 'transfer') {
$gateway = new BankTransferGateway(config('psp.endpoint'));
} else {
throw new \InvalidArgumentException("Unknown payment method: {$method}");
}
return $gateway->charge($request->input('amount'), $request->input('currency'));
}
}
To jest czytelne przy dwóch opcjach. Gdy dodaliśmy czwartą, ten sam łańcuch if-elseif istniał w kontrolerze, w handlerze zwrotów, w routerze webhooków i w adminowym jobie rekoncyliacji. Dodanie piątej bramki oznaczało znalezienie wszystkich czterech lokalizacji. Klasyczna architektura oparta na ślinie i trytytkach, gdzie trytyką był globalny search po elseif.
Wyekstrahowana factory
interface PaymentGatewayInterface
{
public function charge(Money $amount, string $currency, array $metadata): ChargeResult;
public function refund(string $chargeId, Money $amount): RefundResult;
public function parseWebhook(array $payload, string $signature): WebhookEvent;
}
final class PaymentGatewayFactory
{
private array $resolvers = [];
public function __construct(
private readonly ContainerInterface $container,
) {}
public function register(string $method, string $gatewayClass): void
{
$this->resolvers[$method] = $gatewayClass;
}
public function make(string $method): PaymentGatewayInterface
{
if (!isset($this->resolvers[$method])) {
throw new UnsupportedPaymentMethodException($method);
}
// Container resolves the gateway's own dependencies (credentials, HTTP client, logger)
return $this->container->make($this->resolvers[$method]);
}
}
// Registered in a service provider — one place, one time
$factory->register('card', StripeGateway::class);
$factory->register('blik', BlikGateway::class);
$factory->register('transfer', BankTransferGateway::class);
$factory->register('financing',FinancingGateway::class);
Kontroler staje się:
class PaymentController
{
public function __construct(
private readonly PaymentGatewayFactory $factory,
) {}
public function charge(Request $request): Response
{
$gateway = $this->factory->make($request->input('payment_method'));
return $gateway->charge(...);
}
}
Dodanie piątej bramki to: zaimplementuj interfejs, zarejestruj. Nie dotykasz niczego innego.
Dwa tryby awarii w implementacjach factory
Pierwszy to factory które konstruują zamiast rozwiązywać. Factory wywołujące new GatewayClass(config('...')) inline po cichu się psuje gdy bramka zyska nową zależność. Factory powinno delegować konstrukcję do kontenera DI. Jeśli nie jesteś na frameworku z kontenerem, minimum to żeby factory akceptowało instancje bramek przez konstruktor, nie budowało ich wewnętrznie.
Drugi to factory które zwracają złą abstrakcję. Powyższe factory zwraca PaymentGatewayInterface. Widziałem factory zwracające StripeGateway z interfejsem będącym de facto API Stripe (createPaymentIntent(), retrieveBalance()) z cienkimi wrapperami dla innych bramek, które sypią się pod obciążeniem, bo BLIK nie ma pojęcia o "payment intent". Interfejs powinien reprezentować słownik twojej domeny, nie API żadnego dostawcy.
Kiedy używać factory a kiedy strategy
Pomylenie Factory Method i Strategy jest wystarczająco częste, żeby to bezpośrednio zaadresować. Wyglądają podobnie, ale rozwiązują różne problemy. Factory Method dotyczy tego, że typ obiektu się różni (tworzysz różne klasy na podstawie inputu runtime, caller nie trzyma referencji do factory, po prostu wywołuje make() i dostaje abstrakcję. Strategy dotyczy tego, że algorytm się różni) wstrzykujesz inną implementację tego samego interfejsu do klasy, która jej używa, klasa trzyma referencję do strategy i bezpośrednio wywołuje na niej metody.
W praktyce: jeśli wybór następuje raz na początku requestu i wynik jest używany przez cały czas (to factory. Jeśli wybór następuje wielokrotnie w ramach jednego obliczenia) to strategy.
Testowanie factory
Sama factory ma trywialną powierzchnię testową: czy zwraca właściwy typ dla znanych inputów i czy rzuca dla nieznanych. Znaczące testy są na kontrakcie interfejsu, napisz współdzielony test, który każda bramka musi przejść:
abstract class PaymentGatewayContractTest extends TestCase
{
abstract protected function makeGateway(): PaymentGatewayInterface;
public function testChargeReturnsChargeResult(): void
{
$gateway = $this->makeGateway();
$result = $gateway->charge(
new Money(1000, 'PLN'),
'PLN',
['order_id' => 'test-123']
);
$this->assertInstanceOf(ChargeResult::class, $result);
$this->assertNotEmpty($result->chargeId);
}
}
class StripeGatewayTest extends PaymentGatewayContractTest
{
protected function makeGateway(): PaymentGatewayInterface
{
return new StripeGateway(apiKey: 'sk_test_fake', httpClient: $this->mockClient());
}
}
Na co zwracam uwagę w code review
Gdy widzę factory, moje pierwsze pytanie brzmi: co wyzwala decyzję?
Jeśli odpowiedź to string z inputu użytkownika lub kolumna w bazie danych (poprawne użycie. Jeśli to stała kompilacji lub zmienna środowiskowa, która nigdy nie zmienia się w runtime) złe narzędzie, użyj kontenera DI do zbindowania konkretnego typu raz i wstrzyknij go bezpośrednio. Jeśli to rosnący łańcuch if-elseif, o którym zespół już jest nerwowy, factory jest spóźnione, ale nadal to właściwa naprawa.