The ticket says “Black Friday carts are blowing the heap; the same tent SKU appears on forty thousand rows and each row copies the name, tax class, and image URL.” You open LineItem and find a 2 KB CDN URL sitting next to quantity. Checkout does not need forty thousand copies of that URL. It needs forty thousand counts and one catalog row. OrderProcessor still charges one BigDecimal.
That is Flyweight’s entire complaint. From the Design Patterns Roadmap: many similar objects should share the large immutable payload and keep only the per-row facts on themselves.
This post uses a second tiny example on purpose. The shared Order / PaymentGateway / DiscountPolicy / OrderProcessor lab still charges at the end. We do not re-lecture the three families. We share catalog metadata because a million cart rows are not a million products — not because an order of two SKUs looked lonely.
The line item that clones the catalog
Here is a cart row after someone inlined the product record “so the template has everything.” Honest, and now every quantity-3 tent carries its own copy of the PNG path:
public final class LineItem {
private final String skuId;
private final String name;
private final String taxClass;
private final String imageUrl;
private final int quantity;
private final boolean giftWrap;
public BigDecimal lineTotal(BigDecimal listPrice) {
BigDecimal goods = listPrice.multiply(BigDecimal.valueOf(quantity));
return giftWrap ? goods.add(new BigDecimal("4.00")) : goods;
}
}
Order holds a List<LineItem>. Each item repeats name / taxClass / imageUrl for the same skuId. Now price the heap — and the next two tickets:
40_000 rows of SKU TENT-9 -> 40_000 copies of the same image URL string graph
Change the tax class on TENT-9 -> find every in-flight cart row, or ship stale tax
Unit-test PercentOff -> construct image URLs you never assert on
Three costs, and none of them are about gateway.charge. Per-row identity has been fused with catalog copy that does not change per row.
Note: The problem is not that LineItem exists. Someone has to remember quantity. The problem is storing intrinsic catalog facts on every extrinsic row. Two rows of the same SKU should not be two copies of the CDN URL.
What Flyweight actually is
Two kinds of state, one factory that hands out the shared piece.
| Part | In this lab | Job |
|---|---|---|
| Intrinsic | CatalogSku | Name, tax class, image URL. Immutable. Shared. |
| Extrinsic | LineItem quantity, gift wrap | Per cart row. Not stored on the catalog type. |
| Factory | SkuCatalog | One CatalogSku per id. Callers do not new it. |
Intrinsic state lives once. Extrinsic state is passed in (or held on the row) every time you use the shared object. If CatalogSku grows a quantity field, you have not shared a flyweight; you have put one shopper’s cart on every other shopper’s cart.
You do not need a class named Flyweight. You need the catalog noun the row already talks about, frozen, and a lookup:
public final class CatalogSku {
private final String id;
private final String name;
private final String taxClass;
private final String imageUrl;
private final BigDecimal listPrice;
public CatalogSku(
String id, String name, String taxClass, String imageUrl, BigDecimal listPrice) {
this.id = id;
this.name = name;
this.taxClass = taxClass;
this.imageUrl = imageUrl;
this.listPrice = listPrice;
}
public String id() { return id; }
public String name() { return name; }
public String taxClass() { return taxClass; }
public String imageUrl() { return imageUrl; }
public BigDecimal listPrice() { return listPrice; }
}
That is the shared payload. Everything about this row stays off it.
The row holds a reference, not a copy
SkuCatalog is the factory. LineItem keeps quantity and gift wrap — the facts that differ when two customers buy the same tent:
public final class SkuCatalog {
private final Map<String, CatalogSku> byId = new HashMap<>();
public CatalogSku sku(String id) {
CatalogSku existing = byId.get(id);
if (existing == null) {
throw new UnknownSkuException(id);
}
return existing;
}
public void register(CatalogSku sku) {
byId.put(sku.id(), sku);
}
}
public final class LineItem {
private final CatalogSku sku;
private final int quantity;
private final boolean giftWrap;
public LineItem(CatalogSku sku, int quantity, boolean giftWrap) {
this.sku = sku;
this.quantity = quantity;
this.giftWrap = giftWrap;
}
public BigDecimal lineTotal() {
BigDecimal goods = sku.listPrice().multiply(BigDecimal.valueOf(quantity));
return giftWrap ? goods.add(new BigDecimal("4.00")) : goods;
}
public CatalogSku sku() {
return sku;
}
}
Load the catalog at the edge — app start, a read model, a file. Cart code calls catalog.sku("TENT-9"), not new CatalogSku(...). Two line items with the same id are two rows pointing at one object:
CatalogSku tent = catalog.sku("TENT-9");
Order order = new Order(
"o-1",
"a@b.com",
List.of(new LineItem(tent, 2, false), new LineItem(tent, 1, true)),
tent.listPrice().multiply(new BigDecimal("3")).add(new BigDecimal("4.00")));
Order.total() still sums lineTotal(). DiscountPolicy.payable(order) still returns a BigDecimal. OrderProcessor.process still charges and marks paid. None of them need the image URL.
Note: Keep CatalogSku immutable. A setter on taxClass is a cross-cart bug: every in-memory row of TENT-9 changes tax in the same instant, including carts you are not checking out. If tax must change, register a new catalog version and point new rows at it.
What the diff looks like now
Same feature request, both designs:
Before — 40_000 rows of TENT-9
LineItem copies name, taxClass, imageUrl, listPrice each time
tax-class fix = rewrite rows or live with stale copies
After — 40_000 rows share one CatalogSku
A CatalogSku.java intrinsic only, no quantity
A SkuCatalog.java one instance per id
M LineItem.java holds CatalogSku + quantity + giftWrap
Heap cost of the PNG path is one object, not one per row. OrderProcessor is unchanged. Adding a second image size on the catalog type does not retouch process.
Proving the share without charging a card
The seam pays a second dividend: you can assert identity of the shared payload, and you can still test payable math without a CDN.
@Test
void twoRowsOfSameSkuShareOneCatalogObject() {
SkuCatalog catalog = new SkuCatalog();
catalog.register(new CatalogSku(
"TENT-9", "Trail tent", "TAX-STD", "https://cdn.example/tent.png",
new BigDecimal("199.00")));
LineItem a = new LineItem(catalog.sku("TENT-9"), 2, false);
LineItem b = new LineItem(catalog.sku("TENT-9"), 1, true);
assertSame(a.sku(), b.sku());
assertEquals(new BigDecimal("398.00"), a.lineTotal());
assertEquals(new BigDecimal("203.00"), b.lineTotal());
}
PercentOff still tests with an Order and a BigDecimal total — no imageUrl. A declined-charge test still uses FakePaymentGateway. If a processor test constructs forty thousand CatalogSku copies, you have mixed “does charge run” with “did we intern the catalog.”
A template that needs the image reads line.sku().imageUrl(). That is extrinsic use of intrinsic data, not a reason to copy the URL onto LineItem again.
OrderProcessor never sees the catalog type. It still asks for a payable and a charge — the same three collaborators as Strategy:
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());
}
Composite was the other catalog-shaped post: a tree of SKUs and categories that share subtotal(). Flyweight is not a tree. It is many rows pointing at one frozen product. Do not wrap CatalogSku in a Category so both patterns appear in the same cart.
When Flyweight is the wrong move
Wave 3 is easy to over-apply. Skip the shared payload when:
- You have a few dozen objects. A cart of eight SKUs, a catalog of two hundred products that fit in a small heap, a test fixture with three lines —
newis fine. Flyweight is for many instances of the same intrinsic data, not for “we modeled a product.” - The shared object is mutable. A
CatalogSkuwithsetListPricefrom a flash sale, applied to a singleton the whole JVM holds, silently reprices every open cart. That is not a flyweight; that is a global. Either freeze the snapshot at add-to-cart (listPricecopied ontoLineItemas extrinsic) or version the catalog. - Identity of the row is the identity of the product. If two lines with the same SKU must not compare equal because they are different gift-wrap choices, do not put
equalsonCatalogSkuintoLineItem.equals. Share the payload; keep row equality on quantity + wrap + line id. - You are intern-ing strings and calling it a pattern.
Stringis already shared when interned. Wrapping every SKU name inNameFlyweightis ceremony. The trigger is a cluster of fields that travel together (name + tax class + image), not oneString. - “We might have a million rows someday.” Might is not a heap dump. Measure. A flyweight factory, a register API, and immutability rules are cost you pay on every
add to cartfor a problem you do not have.
The healthy trigger is a profiler or a ticket that names duplication of an immutable cluster across many instances. Forty thousand copies of one CDN URL is that ticket. Forty LineItem records in a session are not.
Flyweight also is not Singleton. Singleton is one instance of a service (and usually a mistake). Flyweight is one instance per intrinsic key, many keys, many clients. SkuCatalog may be a long-lived cache of those instances; it is not “the” SKU. A singleton CatalogSku TENT would be one product in the whole shop.
It is also not a cache of HTTP responses. A gateway result cache is Proxy (or a map next to the client). Flyweight shares immutable domain data that many live objects need at once, not “we already called Stripe.”
Cheat sheet
Intrinsic CatalogSku name, tax class, image URL, listPrice; frozen
Extrinsic LineItem quantity, giftWrap; passed or stored on the row
Factory SkuCatalog.sku(id) one CatalogSku per id; callers do not new it
Caller Order / DiscountPolicy sums lineTotal(); never copies imageUrl
Trigger to apply: many instances share a large immutable cluster of fields
Trigger to stop: dozens of objects, or anything on the shared type can mutate
Scoreboard: 40_000 rows of TENT-9 = 40_000 LineItems + 1 CatalogSku
Not Singleton: one instance per key vs one instance for the whole process
Do:
- Name the flyweight after the catalog noun (
CatalogSku), notSkuFlyweightImpl. - Put quantity, wrap, line id, and per-order price overrides on
LineItem. - Test
assertSameon the shared instance; testpayablewithout image URLs. - Freeze intrinsic state. Version the catalog when tax or price must change.
Don’t:
- Copy
imageUrlonto everyLineItemso the Mustache template has a flat object. - Add
setTaxClassto a sharedCatalogSkuthe cart still holds. - Introduce a flyweight factory for a cart of eight lines because the catalog “might grow.”
- Call a singleton service or a charge-result cache a flyweight.
Wrap-up
Flyweight is a shared immutable payload plus per-use facts the payload must not store. Checkout still charges one payable; it never needed a million product objects. CatalogSku holds name, tax class, and image URL once, LineItem holds quantity and gift wrap, and forty thousand rows of TENT-9 are forty thousand counts — not forty thousand copies of a PNG path. Eight lines in a cart stay eight ordinary objects.
Wave 3 is the set that is easy to over-apply. A few dozen instances stay new.