The ticket says “EU checkout tests need the same ChargeRequest as US — gift wrap, loyalty, stacked gold discount — except currency and tax region.” You open the test fixture and paste the Builder chain a second time. Or you clone() a FakePaymentGateway that already has twelve canned responses, and the EU test mutates the list the US test still holds. Reconstruction is slow to read. The shallow copy is worse: two tests, one guts.
That is Prototype’s entire complaint. From the Design Patterns Roadmap: copying a configured object should be cheaper than rebuilding it.
This post stays on the shared Order / PaymentGateway / DiscountPolicy / OrderProcessor lab. We do not re-lecture the three families. We copy a finished checkout command, a stacked policy, and a fake gateway because construction already happened — not because Cloneable looked like a pattern.
The fixture you keep rebuilding
Here is the US checkout everyone copies. Builder already retired the telescope. The next region still repeats every step:
ChargeRequest us = ChargeRequest.builder(order())
.currency("USD")
.taxRegion("US")
.giftWrap()
.loyaltyId("GOLD-9")
.build();
DiscountPolicy usDiscount = new StackedDiscount(
new PercentOff(new BigDecimal("10")),
new PercentOff(new BigDecimal("5")));
FakePaymentGateway gateway = FakePaymentGateway.alwaysApproved("ref-us");
gateway.onDecline("o-expired", "expired_card");
gateway.onDecline("o-stolen", "stolen_card");
EU needs the same wrap, the same loyalty, the same stacked percents, the same decline map — then "EUR" and "DE". Paste. Drift. Or the “clever” copy:
ChargeRequest eu = (ChargeRequest) us.clone();
ChargeRequest was a record with a List<LineItem> on Order. Object.clone() is shallow. The EU test adds a line item; the US assertion fails. Now price the next region — and the fake:
Rebuild the builder for DE -> four optional steps to change two fields
clone() the ChargeRequest -> Order.items still shared
clone() the FakePaymentGateway -> decline map shared; tests contaminate
clone() OrderProcessor -> copies a live gateway into the next test
Four costs, and none of them are about charging a card. Reconstruction duplicates configuration. A shallow clone shares the mutable parts you were trying to isolate.
Note: The problem is not new. new PercentOff(new BigDecimal("10")) is cheap and honest. The problem is an object that already took work to configure, when the next caller needs that work plus one change. If construction is one line, skip to when this is the wrong move.
What Prototype actually is
Two parts, one promise:
| Part | In this lab | Job |
|---|---|---|
| Prototype | ChargeRequest, DiscountPolicy, FakePaymentGateway | Already configured. Knows how to copy itself. |
| Client | test, region factory, catalog edge | Asks for a copy. Does not rebuild the constructor list. |
The object that has the fields is the object that copies them. If a CheckoutFixtures class reads every getter and calls builder again, you have not prototyped; you have hidden a reconstruction behind a helper. Helpers are fine. They are not this pattern.
You do not need Cloneable. In Java, Object.clone() is protected, shallow, and easy to get wrong. A copy() method or a copy constructor is Prototype:
public ChargeRequest copy() {
return new ChargeRequest(order.copy(), currency, taxRegion, giftWrap, loyaltyId);
}
That is the seam. Everything else is deep vs shallow, and when new is enough.
Copy the product, not the builder
Builder assembled ChargeRequest once. Prototype starts after build(). The product knows its fields; it can emit another product with the same values, then the caller changes the two that differ:
public record ChargeRequest(
Order order,
String currency,
String taxRegion,
boolean giftWrap,
String loyaltyId) {
public ChargeRequest copy() {
return new ChargeRequest(order.copy(), currency, taxRegion, giftWrap, loyaltyId);
}
public ChargeRequest withCurrency(String currency) {
return new ChargeRequest(order.copy(), currency, taxRegion, giftWrap, loyaltyId);
}
public ChargeRequest withTaxRegion(String taxRegion) {
return new ChargeRequest(order.copy(), currency, taxRegion, giftWrap, loyaltyId);
}
}
EU from US is two withers, not a second builder chain:
ChargeRequest eu = us.copy().withCurrency("EUR").withTaxRegion("DE");
Order.copy() must copy items, not alias them. A record of immutable LineItem records can share the list if the list itself is unmodifiable. A mutable ArrayList cannot:
public Order copy() {
return new Order(id, customerEmail, List.copyOf(items), total);
}
DiscountPolicy grows the same verb when a stacked tree is expensive to rebuild and a test needs the same tree with one child swapped. A one-argument PercentOff does not need copy(). new PercentOff(TEN) already is the copy:
public interface DiscountPolicy {
BigDecimal payable(Order order);
DiscountPolicy copy();
}
public final class PercentOff implements DiscountPolicy {
private final BigDecimal factor;
@Override
public DiscountPolicy copy() {
return this;
}
}
public final class StackedDiscount implements DiscountPolicy {
private final DiscountPolicy first;
private final DiscountPolicy then;
@Override
public DiscountPolicy copy() {
return new StackedDiscount(first.copy(), then.copy());
}
}
PercentOff can return this because it is immutable. StackedDiscount copies the structure so a later Decorator or a mutable child cannot leak. Do not add copy() to DiscountPolicy until a second stacked tree actually exists. Wave 3 will tempt you to put it on the interface “for consistency.” That is a method every implementer must lie about.
Note: Keep copy dumb. The moment ChargeRequest.copy starts charging a card, looking up tax, or asking OrderProcessor for “the current request,” you have rebuilt construction inside a clone. Copy fields. Return. Stop.
The fake that must not share guts
Test doubles accumulate canned behavior. That configuration is the prototype. A shallow clone() of a map of decline reasons is how US and EU tests flake together:
public final class FakePaymentGateway implements PaymentGateway {
private final Map<String, String> declines;
private final String defaultReference;
private BigDecimal lastAmount;
public FakePaymentGateway(String defaultReference, Map<String, String> declines) {
this.defaultReference = defaultReference;
this.declines = new LinkedHashMap<>(declines);
}
public FakePaymentGateway copy() {
return new FakePaymentGateway(defaultReference, declines);
}
public void onDecline(String orderId, String reason) {
declines.put(orderId, reason);
}
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
lastAmount = amount;
String reason = declines.get(order.id());
if (reason != null) {
return PaymentResult.declined(reason);
}
return PaymentResult.approved(defaultReference);
}
}
The copy constructor takes the map and wraps it in a new LinkedHashMap. copy() therefore does not share declines. The EU test can onDecline("o-iban", "iban_invalid") without teaching the US fixture a new key.
OrderProcessor is not a prototype. It holds collaborators, not configuration you want twelve of. Construct another processor with the copied gateway and the copied policy. Do not clone() the workflow.
What the diff looks like now
Same feature request, both designs:
Before — rebuild or Object.clone()
M CheckoutFixtures.java second builder chain, drifts from US
M FakePaymentGateway.java clone() aliases the decline map
M OrderProcessorTest.java EU test fails US assertions
After — copy() that owns the deep copy
M ChargeRequest.java copy / withCurrency / withTaxRegion; Order.copy()
M FakePaymentGateway.java copy constructor, new map
M EU tests us.copy().withCurrency("EUR").withTaxRegion("DE")
The next region changes two fields on a copy. OrderProcessor still takes a ChargeRequest it did not build. Construction stays at the edge — a fixture, a catalog, main. Prototype does not move new into process.
Proving the copy does not alias
The seam pays a second dividend: you can assert independence without charging a card.
@Test
void copyDoesNotShareDeclineMap() {
FakePaymentGateway us = FakePaymentGateway.alwaysApproved("ref-us");
us.onDecline("o-stolen", "stolen_card");
FakePaymentGateway eu = us.copy();
eu.onDecline("o-iban", "iban_invalid");
Order iban = new Order("o-iban", "a@b.com", List.of(), new BigDecimal("40.00"));
assertTrue(us.charge(iban, iban.total()).approved());
assertFalse(eu.charge(iban, iban.total()).approved());
}
And ChargeRequest.copy() can be tested with a mutable item list: add a line on the copy, assert the original order.items() size did not change. If a processor test needs to rebuild twelve builder steps to prove a declined charge, the prototype never made it into the fixture.
Prototype is not Builder, and it is not Cloneable
Builder assembles an object that did not exist. Prototype copies one that does. You can use both: builder for the first US request, copy for EU. Do not add copy() that internally calls ChargeRequest.builder(order).currency(...).build() field by field unless that is honestly simpler — then it is a factory helper, and the pattern name is doing no work.
Java’s Cloneable is a marker with no clone method. Object.clone() is shallow, throws CloneNotSupportedException, and breaks constructors. Do not implement Cloneable so the catalog looks complete. A public copy() that new-s the type is the lab’s Prototype. Interview answers that start with Cloneable should end with “and in this codebase we did not.”
Factory Method answers “which subtype?” Prototype answers “another instance like this one.” Abstract Factory is a family of news. A prototype registry of every DiscountPolicy keyed by promo code is usually DiscountPolicies.from from the Strategy post — a map of construction — not a map of clones.
When Prototype is the wrong move
Wave 3 is easy to over-apply. Skip copy() when:
- Construction is cheap.
new PercentOff(new BigDecimal("10")),new NoDiscount(),Order.draft(...). Acopythat returnsnew PercentOff(factor)is a second constructor with a confusing name. - The object is immutable and you do not need a variant. Share the instance.
PercentOffreturningthisfromcopy()is already a hint you did not need the method. - You are about to shallow-copy mutable guts. An
Orderwhoseitemsis a liveArrayList, a gateway whose decline map is the same object, aStackedDiscountthat aliasesfirst. The clone is cheaper thannewand wrong. Either deep-copy, or do not copy. - Builder (or a fixture method) is the call site you already have. Five tests that each call
usCheckout()which runs the builder are clear. Five tests that clone a mutable staticUS_REQUESTand tweak it will contaminate. Prefer a function that returns a fresh build over a hidden prototype in astaticfield. - You cloned
OrderProcessor. The processor is collaborators plusprocess. Copy theChargeRequestand the fake gateway; construct a new processor. Cloning policy-plus-I/O is how secrets and recorded calls leak between tests. - You wanted Flyweight. Sharing one immutable
PercentOff(10)across a million line items is not copying. Prototype makes another instance. Flyweight avoids the instance. Do notcopy()so you can pretend you shared. - A registry of prototypes for every SKU. That is a catalog in memory, usually loaded from data.
clone()as your entity constructor hides the real model. Build from the row.
The healthy trigger is configuration you can point at — stacked discounts, canned fake responses, a finished ChargeRequest — plus a caller that needs that configuration with a small delta, without mutating the original. EU from US is that trigger. new FlatOff(FIFTY) is not. Object.clone() on a type you did not override is not.
Do not put copy() on every type in the lab because Prototype is in Wave 3. PaymentGateway implementations that wrap SDKs should not clone the SDK. DiscountPolicy should not grow copy until a tree or a mutable policy exists. The Design Patterns Roadmap parked this name late because reconstructing is usually fine.
Cheat sheet
Prototype ChargeRequest.copy new instance, Order.copy() so items do not alias
StackedDiscount.copy copy the tree; PercentOff may return this
FakePaymentGateway.copy new map of declines
Client EU fixture, region edge withCurrency / withTaxRegion on the copy
Processor OrderProcessor takes the copy; is not cloned
Trigger to apply: configured object + small delta + must not mutate the original
Trigger to stop: cheap new, immutable share, or a clone that aliases a list/map
Scoreboard: new region = copy + two withers; US fixture does not change
Not Builder: copy after build vs assemble from optional steps
Not Cloneable: public copy() / copy constructor, not Object.clone()
Do:
- Name the method
copy(or a copy constructor). Put deep copies of lists and maps inside it. - Copy products (
ChargeRequest, fakes, stacked policies). Construct a newOrderProcessor. - Return
thisfromcopy()only when the type is immutable and you needed the method on an interface. - Test that mutating the copy leaves the original’s maps and item lists alone.
Don’t:
- Implement
Cloneableand callsuper.clone()on a type with a mutableListorMap. - Rebuild twelve constructor arguments when a finished instance already exists and only two fields change — unless
newis still one line. - Clone
OrderProcessoror a livePaymentGatewaySDK. - Add
DiscountPolicy.copy()forPercentOffandNoDiscountwith no stacked tree and no mutable policy. - Use a static mutable prototype as a test fixture; return a fresh copy from a method, or build fresh.
Wrap-up
Prototype is an object that already knows its configuration and can emit another instance that does not share mutable guts. The EU checkout fixture was expensive because every region rebuilt the builder chain or called clone() and shared a decline map. ChargeRequest.copy, Order.copy, and a fake gateway that copies its map make the next region two withers, keep tests from contaminating each other, and leave OrderProcessor as a constructor call. If new PercentOff(TEN) is the whole story, it is also the whole solution.