The ticket says “EU checkout uses Adyen and a VAT-inclusive percent-off.” You grep new PercentOff and new StripeGateway. CheckoutController has them. So does RecurringChargeJob, AdminRebill, MobileCheckout, the webhook retry path, and a “test helper” that production now calls. Six call sites, six imports of a concrete type, six merge conflicts waiting for whoever is adding Net-30 B2B rates on another branch.
Strategy already cleaned OrderProcessor. The processor asks discount.payable(order) and gateway.charge(...) and names neither formula nor vendor. Construction never moved. From the Design Patterns Roadmap: a creator defines how to produce the object; subclasses decide which class to instantiate.
This post is only about who is allowed to say new. The shared Order / PaymentGateway / DiscountPolicy lab, the three families, and when patterns are cargo-cult live on that hub. We pick up where Strategy left the mapper.
The new that leaked into six controllers
After Strategy, a controller still has to turn a request into objects. Honest code looks like this — and then it is copied:
public class CheckoutController {
private final OrderRepository orders;
public CheckoutController(OrderRepository orders) {
this.orders = orders;
}
public void checkout(Order order, String promoCode, boolean goldMember) {
DiscountPolicy discount = DiscountPolicies.from(promoCode, goldMember);
PaymentGateway gateway = new StripeGateway(new StripeClient(env("STRIPE_KEY")));
new OrderProcessor(gateway, discount, orders).process(order);
}
}
DiscountPolicies.from is the static map from the Strategy post. It is better than six copies of the promo switch. It is still one class that names every concrete policy, and the gateway line still names Stripe. Price the EU ticket — and the B2B one behind it:
Add Adyen for EU -> edit every controller that said new StripeGateway
Add Net-30 contract rates -> DiscountPolicies.from grows a case it does not own
Change Stripe test-mode key -> six constructors, one of them in a job that is not checkout
Test AdminRebill in isolation -> construct Stripe, a promo map, and a repository
Four costs, and none of them are about charging a card. Every caller that says new has become a co-owner of which implementation exists.
Note: The problem is not the new keyword. A composition root that builds one StripeGateway and one PercentOff is doing its job. The problem is construction copied into every workflow that happens to need a policy or a gateway, sitting next to HTTP, SQL, and retry logic you cannot afford to break when marketing changes a rate.
Simple factory is not Factory Method
The fair objection after Strategy is “we already have a factory — DiscountPolicies.from.” You do. It is a simple factory: one class, usually static, that names every product and returns the interface.
public final class DiscountPolicies {
public static DiscountPolicy from(String promoCode, boolean goldMember) {
DiscountPolicy promo = switch (promoCode) {
case "PERCENT10" -> new PercentOff(new BigDecimal("10"));
case "FLAT50" -> new FlatOff(new BigDecimal("50"));
case "BLACKFRIDAY" -> new PercentOff(new BigDecimal("30"));
case null, default -> new NoDiscount();
};
if (!goldMember) {
return promo;
}
return new StackedDiscount(promo, new PercentOff(new BigDecimal("5")));
}
}
Keep it. Catalog checkout that maps promo codes to policies should have one mapper. Factory Method is a different seam. The operation that produces the object is itself polymorphic. Catalog checkout, B2B contract checkout, and a test harness do not share a promo-code switch. They share a creator type. Each implementation decides what to build.
If the only variation you have is “this string becomes that policy,” stop at DiscountPolicies.from. You need a second kind of construction — a second reason the mapper itself is the wrong owner — before a polymorphic creator pays.
What Factory Method actually is
Two parts, one promise:
| Part | Job |
|---|---|
| Creator | Declares “give me a product.” Does not name PercentOff or StripeGateway. |
| Concrete creator | One implementation per construction policy. Owns new. |
The Gang of Four drawing uses inheritance: an abstract Creator with factoryMethod(), overridden in a subclass that also owns the workflow. You will see that slide in interviews. In application Java the same idea is a small interface the processor (or the controller) already holds. OrderProcessor should not become PromoOrderProcessor just so construction can vary.
Name the method after the product the caller already wants:
public interface DiscountCreator {
DiscountPolicy createFor(Order order);
}
That is the seam. Everything else is an implementation. A PaymentGatewayCreator with create() is the same shape on the other lab type. One product per creator. Two products that must vary together is Abstract Factory — a later post, a different pain.
One class per construction policy
Catalog checkout still uses the simple factory. The creator calls it. It does not replace it, and it does not live in OrderProcessor. Promo codes stay off the lab Order record — a PromoCodes lookup keyed by id and email is enough:
public final class CatalogPromoCreator implements DiscountCreator {
private final PromoCodes promos;
public CatalogPromoCreator(PromoCodes promos) {
this.promos = promos;
}
@Override
public DiscountPolicy createFor(Order order) {
return DiscountPolicies.from(promos.codeFor(order), promos.goldMember(order));
}
}
B2B checkout does not have a promo code. Pretending it does would stuff contract rates into DiscountPolicies.from and make every catalog test know about Net-30. That is a second construction policy, so it is a second creator. Contract identity is the email the lab Order already carries:
public final class B2BContractCreator implements DiscountCreator {
private final ContractRates rates;
public B2BContractCreator(ContractRates rates) {
this.rates = rates;
}
@Override
public DiscountPolicy createFor(Order order) {
return new ContractRate(rates.forCustomer(order.customerEmail()));
}
}
Gateways get the same treatment when the vendor is chosen by channel, not by a string the processor should parse. US web checkout builds Stripe. EU checkout builds Adyen. Neither OrderProcessor nor a controller names the SDK:
public interface PaymentGatewayCreator {
PaymentGateway create();
}
public final class StripeCheckoutCreator implements PaymentGatewayCreator {
private final StripeClient client;
public StripeCheckoutCreator(StripeClient client) {
this.client = client;
}
@Override
public PaymentGateway create() {
return new StripeGateway(client);
}
}
AdyenCheckoutCreator is another class, another constructor, another secret. Adding it does not edit StripeCheckoutCreator. Do not invent a creator per campaign name when the construction policy is the same and only a parameter changes — new PercentOff(new BigDecimal("12")) is still PercentOff.
The processor still does not construct
OrderProcessor already took a DiscountPolicy and a PaymentGateway in Strategy. Leave it that way when the objects are known at request start. The creators belong at the edge — the controller, the job, the composition root — so the paid-order workflow still never says new PercentOff:
public class CheckoutController {
private final DiscountCreator discounts;
private final PaymentGatewayCreator gateways;
private final OrderRepository orders;
public CheckoutController(
DiscountCreator discounts,
PaymentGatewayCreator gateways,
OrderRepository orders) {
this.discounts = discounts;
this.gateways = gateways;
this.orders = orders;
}
public void checkout(Order order) {
OrderProcessor processor = new OrderProcessor(
gateways.create(), discounts.createFor(order), orders);
processor.process(order);
}
}
US web wiring hands in CatalogPromoCreator and StripeCheckoutCreator. B2B API wiring hands in B2BContractCreator and whatever gateway that contract names. The controller file does not change when EU adds Adyen. The composition root does.
Sometimes the product depends on data you only have inside process — a retry that must build a second gateway, a split tender that needs a second policy. Then the processor holds the creator, not the product:
public void process(Order order) {
DiscountPolicy discount = discounts.createFor(order);
BigDecimal payable = discount.payable(order);
PaymentResult result = gateways.create().charge(order, payable);
// ...
}
That is still Factory Method. The processor asks a seam to produce; it does not import AdyenGateway. If process starts with if (discount instanceof ContractRate), the swap was a lie — same test as Strategy.
Note: Keep the creator dumb. The moment createFor charges a card, writes SQL, or asks “if it’s B2B, skip fraud checks,” construction has swallowed the workflow. Creating is data and config. Charging is process. Math is payable.
What the diff looks like now
Same feature request, both designs:
Before — add EU / Adyen
M CheckoutController.java new StripeGateway becomes a branch
M RecurringChargeJob.java same branch, copied
M AdminRebill.java same branch, copied
M MobileCheckout.java same branch, copied
After — add EU / Adyen
A AdyenCheckoutCreator.java new class, existing callers untouched
A AdyenGateway.java vendor wrap, isolated
M PaymentConfig.java one wiring line at the edge
One added creator plus one line of wiring. OrderProcessor stays closed against “a new vendor or a new construction policy appears.” It remains open to editing when the workflow changes — a refund step, a fraud check, a second charge attempt. Those belong in process, because they are not construction.
The B2B ticket is the same scoreboard: B2BContractCreator plus ContractRate, and DiscountPolicies.from is not edited. Catalog tests do not re-run because a contract rate appeared.
Proving it with a fake creator
The seam pays a second dividend: a controller or a processor that holds a creator is testable without promo maps or vendor SDKs.
class FixedDiscountCreator implements DiscountCreator {
private final DiscountPolicy policy;
FixedDiscountCreator(DiscountPolicy policy) {
this.policy = policy;
}
@Override
public DiscountPolicy createFor(Order order) {
return policy;
}
}
A unit test can assert that checkout still declines without marking paid, using a payable amount that never went through percent math or Stripe:
@Test
void declinedChargeDoesNotMarkPaid() {
FakePaymentGateway gateway = FakePaymentGateway.alwaysDeclined("card_declined");
RecordingRepository orders = new RecordingRepository();
CheckoutController checkout = new CheckoutController(
new FixedDiscountCreator(new FixedDiscount(new BigDecimal("42.00"))),
() -> gateway,
orders);
assertThrows(PaymentDeclinedException.class, () -> checkout.checkout(order()));
assertTrue(orders.markPaidCalls().isEmpty());
}
And CatalogPromoCreator can be tested by feeding it an order with a promo code and asserting the type — no HTTP, no charge:
@Test
void percent10BuildsPercentOff() {
DiscountCreator creator = new CatalogPromoCreator(PromoCodes.fixed("PERCENT10", false));
Order order = new Order("o-1", "a@b.com", List.of(), new BigDecimal("100.00"));
assertEquals(new BigDecimal("90.00"), creator.createFor(order).payable(order));
}
If a test for charging needs a real construction policy, you have mixed two reasons to change. Split the tests the same way you split the types.
When Factory Method is the wrong move
Skip the creator when:
- There is one concrete type and no second on the roadmap.
new StripeGateway(client)at the composition root is the whole design. AStripeGatewayCreatorwhose only implementer is itself is two files and a method that never varies. - The variation is a parameter, not a construction policy. 10% and 30% are not two creators. They are
PercentOffwith two arguments, still selected byDiscountPolicies.from. - You already have one mapper that is the truth. Promo codes in, policies out, one catalog, one team. That is a simple factory. Promoting it to
DiscountCreatorplus a singleCatalogPromoCreatoris a rename. - The caller must know which concrete ran. If checkout starts with
if (creator instanceof B2BContractCreator)to skip a coupon audit, either extend the product interface with an honest method or keep the branch in a place that is allowed to know.
The healthy trigger is a second construction policy you can paste from a ticket. Catalog promo codes vs B2B contract rates are two policies. Stripe vs Adyen chosen by region are two policies. Stripe vs Stripe-in-test-mode is a constructor argument — or a StripeClient you pass in — not a new creator type.
Factory Method also is not Abstract Factory. Abstract Factory builds a family that must vary together (gateway + notifier + receipt format for “EU retail”). Factory Method builds one product. If you need both a discount and a gateway to switch as a pair, wait for that post; do not grow DiscountCreator until it returns three objects.
The GoF inheritance version — subclass OrderProcessor and override createDiscount() — is Template Method wearing a construction hat. Prefer a creator you pass in. Inheritance is the bigger blast radius, and Strategy already taught this lab to compose.
Cheat sheet
Creator DiscountCreator / PaymentGatewayCreator produce the product, do not use it
Product DiscountPolicy / PaymentGateway what Strategy already swappable
Concrete CatalogPromoCreator, StripeCheckoutCreator one class per construction policy
Simple factory DiscountPolicies.from one static map; not polymorphic
Trigger to apply: a second construction policy is on a ticket (B2B vs catalog, EU vs US)
Trigger to stop: one concrete, or two values of the same constructor
Scoreboard: new vendor / new construction path = new file; controllers do not grow `new`
Do:
- Name the creator after the product (
DiscountCreator,PaymentGatewayCreator), notAbstractFactoryImpl. - Keep
DiscountPolicies.frominside the catalog creator. Do not delete a simple factory that is still the right mapper. - Wire concrete creators at the composition root. Controllers depend on the interface.
- Test each creator without charging; test checkout with a fake creator.
Don’t:
- Extract a Factory Method for a single
new StripeGatewaythat has not moved in two years. - Put charge, SQL, or HTTP inside
createFor— that isprocess, a gateway, or a repository. - Subclass
OrderProcessoronly so a factory method has a home. - Call a static map “Factory Method” because the class name contains Factory. Polymorphism is the test.
Wrap-up
Factory Method is a small creator whose implementations own new, and callers that refuse to name the product. Strategy made OrderProcessor stop knowing percent-off math. It did not stop six controllers from constructing PercentOff and StripeGateway. Moving construction behind DiscountCreator and PaymentGatewayCreator makes the next region or the next B2B path an added class, keeps the simple promo map where it belongs, and leaves process free to change when the workflow changes.
If the next pain is “after payment succeeds, process() emails, writes audit, and pushes SMS,” that is Observer — who is allowed to react.