The ticket says “India checkout is Razorpay: different charge SDK, different webhook payload, amounts in INR paise.” US checkout already has StripeGateway, a StripeWebhookParser, and a UsdMoney that rounds to cents. You grep new StripeGateway. CheckoutConfig has it. The webhook controller has new StripeWebhookParser. Pricing still says new UsdMoney(payable). Three if (region) blocks, three chances to ship Stripe webhooks against a Razorpay charge. Last quarter someone did.
That is Abstract Factory’s entire complaint. From the Design Patterns Roadmap: a family of related objects must be created together so callers never mix one vendor’s gateway with another’s parser.
This post stays on the shared Order / PaymentGateway / DiscountPolicy / OrderProcessor lab. Factory Method already moved one new behind a creator. Here the pain is three products that must switch as a set. We do not re-lecture the three families.
The independent ifs that drift
After Factory Method, a composition root still has to pick concretes. Honest US wiring looks like this — and then India is copied beside it:
public final class CheckoutConfig {
public OrderProcessor processor(String region, Env env, DiscountPolicy discount, OrderRepository orders) {
PaymentGateway gateway;
if ("IN".equals(region)) {
gateway = new RazorpayGateway(new RazorpayClient(env.get("RAZORPAY_KEY")));
} else {
gateway = new StripeGateway(new StripeClient(env.get("STRIPE_KEY")));
}
return new OrderProcessor(gateway, discount, orders);
}
public WebhookController webhooks(String region, Env env, OrderRepository orders) {
WebhookParser parser = new StripeWebhookParser(env.get("STRIPE_WEBHOOK_SECRET"));
return new WebhookController(parser, orders);
}
}
webhooks never grew an India branch. Money still lives in a third class that always constructs UsdMoney. Now price the India ticket — and the mismatch behind it:
Add Razorpay charge -> one if in processor(), webhook still Stripe
Add Razorpay webhook parser -> second if, easy to forget, easy to invert
Add InrMoney rounding -> third if, pricing tests still assume cents
Test EU later (Adyen family) -> three switches, six ways to mix vendors
Four costs, and none of them are about capturing a payment. Each product has its own region switch, so the family is a coincidence, not a type.
Note: The problem is not that India is different. Regions are always different. The problem is constructing the siblings independently, then hoping every call site updates in the same commit. That hope is not a design.
Factory Method already solved one product
Factory Method is a creator that returns one thing: DiscountCreator.createFor(order) yields a DiscountPolicy. PaymentGatewayCreator.create() yields a PaymentGateway. Catalog vs B2B, Stripe vs Adyen — one product per interface.
That is the right seam when the products vary independently. US catalog can keep Stripe and still swap PercentOff for a campaign. Abstract Factory is the other pain: gateway, webhook parser, and money type are not independent; they are one region’s kit.
If you grow PaymentGatewayCreator until create() returns a record of three objects, you did not invent a clever Factory Method. You wrote an abstract factory and hid the name. Give the family a type. Leave DiscountCreator alone — discounts still vary per campaign, not per PSP.
What Abstract Factory actually is
Two parts, one promise:
| Part | In this lab | Job |
|---|---|---|
| Abstract factory | CheckoutFamily | Declares the products the region must supply. Names no vendor. |
| Concrete factory | StripeUsFamily, RazorpayIndiaFamily | Owns every new for that region. |
The products are types you already have (PaymentGateway) plus the two that must not drift (WebhookParser, Money). Callers depend on those product interfaces. They never mention StripeGateway next to RazorpayWebhookParser.
You do not need a class named AbstractFactory. You need a noun the composition root already understands — here, “the checkout kit for this region”:
public interface CheckoutFamily {
PaymentGateway gateway();
WebhookParser webhooks();
Money money(BigDecimal amount);
}
That is the seam. Everything else is one class per region.
One class per family
US checkout builds Stripe’s three types in one place. Currency and webhook secret stay here because they are Stripe-US problems, not OrderProcessor’s:
public final class StripeUsFamily implements CheckoutFamily {
private final StripeClient charges;
private final String webhookSecret;
public StripeUsFamily(StripeClient charges, String webhookSecret) {
this.charges = charges;
this.webhookSecret = webhookSecret;
}
@Override
public PaymentGateway gateway() {
return new StripeGateway(charges);
}
@Override
public WebhookParser webhooks() {
return new StripeWebhookParser(webhookSecret);
}
@Override
public Money money(BigDecimal amount) {
return UsdMoney.cents(amount);
}
}
India is a second class, not a second else in three files:
public final class RazorpayIndiaFamily implements CheckoutFamily {
private final RazorpayClient charges;
private final String webhookSecret;
public RazorpayIndiaFamily(RazorpayClient charges, String webhookSecret) {
this.charges = charges;
this.webhookSecret = webhookSecret;
}
@Override
public PaymentGateway gateway() {
return new RazorpayGateway(charges);
}
@Override
public WebhookParser webhooks() {
return new RazorpayWebhookParser(webhookSecret);
}
@Override
public Money money(BigDecimal amount) {
return InrMoney.paise(amount);
}
}
UsdMoney and InrMoney implement the same Money type — amount, currency code, rounding — so pricing display and discount.payable stay on BigDecimal until the edge that must print a label. The factory is the only place that picks cents vs paise.
Note: Keep the factory dumb. The moment StripeUsFamily.gateway() starts applying DiscountPolicy, writing SQL, or parsing a webhook, you have rebuilt the god service inside a constructor. Create. Return. Stop.
Callers take products, not a factory of factories
OrderProcessor does not change. It still takes a PaymentGateway and a DiscountPolicy. The family is resolved once at the edge; the processor never sees it:
public class OrderProcessor {
private final PaymentGateway gateway;
private final DiscountPolicy discount;
private final OrderRepository orders;
public OrderProcessor(
PaymentGateway gateway, DiscountPolicy discount, OrderRepository orders) {
this.gateway = gateway;
this.discount = discount;
this.orders = orders;
}
public void process(Order order) {
BigDecimal payable = discount.payable(order);
PaymentResult result = gateway.charge(order, payable);
if (!result.approved()) {
throw new PaymentDeclinedException(order.id(), result.failureReason());
}
orders.markPaid(order.id(), result.reference());
}
}
Selection of which family is allowed to be a switch. Selection of each sibling is not:
public final class CheckoutFamilies {
public static CheckoutFamily from(String region, Env env) {
return switch (region) {
case "IN" -> new RazorpayIndiaFamily(
new RazorpayClient(env.get("RAZORPAY_KEY")),
env.get("RAZORPAY_WEBHOOK_SECRET"));
default -> new StripeUsFamily(
new StripeClient(env.get("STRIPE_KEY")),
env.get("STRIPE_WEBHOOK_SECRET"));
};
}
}
CheckoutFamily family = CheckoutFamilies.from(region, env);
OrderProcessor processor = new OrderProcessor(family.gateway(), discount, orders);
WebhookController webhooks = new WebhookController(family.webhooks(), orders);
Money labeled = family.money(discount.payable(order));
Mixing Stripe charge with Razorpay webhooks now takes a deliberate second factory, not a missed if. The GoF drawing nests Factory Method inside the abstract factory (createGateway() overridden per subclass). In application Java the concrete family’s methods are ordinary new. Do not subclass OrderProcessor so a createGateway() has a home.
What the diff looks like now
Same feature request, both designs:
Before — add Razorpay as three switches
M CheckoutConfig.java processor if, webhook if, money if — easy to miss one
M *Test.java fixtures construct mismatched siblings
After — add Razorpay as a family
A RazorpayIndiaFamily.java three news in one type
A RazorpayIndiaFamilyTest.java asserts parser/gateway/money currency together
M CheckoutFamilies.from one case
One added class plus one case in the region map. OrderProcessor is closed against “a new PSP region appears.” Webhook and money cannot drift from charge unless someone adds a product to CheckoutFamily — which is a visible, reviewable change.
Proving the family without charging a card
The seam pays a second dividend: you can assert the kit is consistent with fakes, and you can keep processor tests on FakePaymentGateway.
@Test
void indiaFamilyUsesInrAndRazorpayParser() {
CheckoutFamily family = new RazorpayIndiaFamily(new RazorpayClient("k"), "whsec");
assertTrue(family.gateway() instanceof RazorpayGateway);
assertTrue(family.webhooks() instanceof RazorpayWebhookParser);
assertEquals("INR", family.money(new BigDecimal("10.00")).currency());
}
A declined-charge test still uses a FakePaymentGateway from Strategy. It should not construct a CheckoutFamily. If processor tests start asserting instanceof RazorpayGateway, the family leaked into a class that was supposed to ignore vendors.
When you do test wiring, assert the three products together — that is the invariant the pattern exists for. Three separate tests that each check one new will not catch a mix.
When Abstract Factory is the wrong move
Skip the kit when:
- One gateway and one notifier that never vary together. Three factory classes to produce one Stripe gateway, one email notifier, and one repository is the cargo-cult the Design Patterns Roadmap already names.
new StripeGateway(client)at the composition root is the whole design. - The products vary independently. Stripe vs Razorpay for charge, but webhooks are a single shared HMAC parser, and money is always
BigDecimalUSD. That is one Factory Method (or oneif) for the gateway — not a family. - You have one region. US-only Stripe, no India on the roadmap. A
CheckoutFamilywith a singleStripeUsFamilyis two types and three methods that never swap. - You are about to put discount policies in the kit. Promos still change per campaign. They are Strategy (+ Factory Method), not a sibling of
RazorpayGateway.
The healthy trigger is two regions (or two vendors) whose siblings must not mix. Stripe-US vs Razorpay-IN are two families. Stripe vs Stripe-in-test-mode is a StripeClient argument — not a second factory type.
Abstract Factory also is not Facade. Facade hides a subsystem you already have (several types, one simpler front) without promising a second family. Builder assembles one object with many optional parts. If Order needs gift wrap and loyalty flags, that is Builder; it is not a Razorpay kit.
Cheat sheet
Abstract factory CheckoutFamily gateway + webhooks + money; names no vendor
Concrete factory StripeUsFamily, RazorpayIndiaFamily one class per region
Products PaymentGateway, WebhookParser, Money callers depend on these
Selection CheckoutFamilies.from one switch, at the edge
Trigger to apply: two families whose siblings must switch together
Trigger to stop: one vendor, or products that vary independently
Scoreboard: new region = new family class; OrderProcessor tests do not change
Not Factory Method: one product vs a set that must not mix
Do:
- Name the factory after the family (
CheckoutFamily,StripeUsFamily), notAbstractFactoryImpl. - Resolve the family once at the composition root; inject the products into
OrderProcessorand the webhook controller. - Test that a concrete family returns matching siblings; test
processwith a fake gateway. - Keep
DiscountPolicyout of the kit unless a region truly owns its own discount types.
Don’t:
- Give each product its own region
ifand hope they stay in sync. - Put charge, SQL, or HTTP inside
gateway()beyondnew. - Build three factory classes for one Stripe gateway and one notifier that never vary together.
- Subclass
OrderProcessorsocreateGateway()can be overridden — pass aCheckoutFamily(or its products) instead.
Wrap-up
Abstract Factory is an interface that produces a set of related products, and concrete classes that own every new for one variant of that set. Stripe vs Razorpay was expensive because gateway, webhook parser, and money each had a private region switch. Moving those three news into StripeUsFamily and RazorpayIndiaFamily makes a missed mix a type error at the kit, keeps Factory Method for discounts that still vary alone, and leaves OrderProcessor on PaymentGateway.charge — which was never a vendor’s name to learn.
If the next pain is “OrderProcessor walks line items with get(i) on a leaked ArrayList,” that is Iterator — hide the structure, keep the walk.