Syllabus · B3
Builder for wide construction
- Tier 2
- Seams
- 7 min read
- after A2
The idea
Builder for wide construction
A shopping cart is built from a catalogue, a coupon book, any number of standing offers and a delivery rule. Write that as a constructor and the parameter list grows with every new offer, and every existing caller has to restate three things it never meant to change. corpus/shopping-cart/reference/src/CartBuilder.java takes a different shape. The one collaborator a cart cannot work without is a constructor argument. The rest have working defaults, and plusPromotion(Promotion) accumulates: call it as many times as there are offers, at one call site, with every other cart's construction untouched.
That is the whole test for whether this pattern earns its file. Not how many fields — three properties, together: one required part, several optional parts with real defaults, and something that grows. Sku and LineRequest, in the same corpus, have two or three required fields and nothing that accumulates. Neither has a builder anywhere near it. Giving one a builder would be more code for no compile-time guarantee, plus a window where a half-built value exists.
Measured on this corpus's own curveballs: a second voucher that stacks costs one added line in CartFactory. Buy-two-get-one costs one more, for the same reason: the new behaviour is a new Promotion file, not an edit to Cart. A whole tier of member pricing costs zero lines in any file that already existed. CartFactory exposes its parts individually, and a variation reuses what it is not changing instead of copying it.
Worked walkthrough
NOTES — CartBuilder, and the two questions that decide whether you need one
Run it first
..\..\..\..\.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out src\*.java
..\..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main
javac -Xlint:all prints nothing and exits 0. Real output, verbatim:
1. the required argument: a cart cannot be built without a catalogue
new CartBuilder(catalog).build() -> CartTotal[subtotalMinor=1250, discountMinor=0, shippingMinor=0, payableMinor=1250] (no coupons, no promotions, no delivery charge: every optional part defaulted)
2. the standard cart: every optional part named, one call each
3 TEA, buy-two-get-one active: CartTotal[subtotalMinor=1350, discountMinor=450, shippingMinor=499, payableMinor=1399]
+ FIVER, typed first: CartTotal[subtotalMinor=1350, discountMinor=950, shippingMinor=499, payableMinor=899]
+ SAVE10, typed second: CartTotal[subtotalMinor=1350, discountMinor=1040, shippingMinor=499, payableMinor=809] (percentage still runs before the flat amount — DiscountStage, not typing order)
coupons applied, in the order typed: [FIVER, SAVE10]
3. a member cart: reuses what it is not changing, replaces one part
1 SOCK at 899, gold shipping: CartTotal[subtotalMinor=899, discountMinor=0, shippingMinor=0, payableMinor=899] (a non-member cart would owe 499 delivery here — see when-not.md for the line)
4. two builds from the same builder do not share state
first cart after adding a LAMP: [LineItem[sku=Sku[code=LAMP, name=Desk lamp, unitPriceMinor=4599], quantity=1, lineTotalMinor=4599]]
second cart, same builder: [] <- empty, so build() did not hand back the same cart twice
Block 2 is worth sitting with before reading any code. FIVER is typed before SAVE10, and yet the totals come out exactly as if SAVE10 ran first. The arithmetic is 900 owed after the multi-buy, then 90 off for the 10%, then 500 off the remaining 810. DiscountStage decides the order, not the sequence you call applyCoupon in. Block 2's own printed order, [FIVER, SAVE10], is the order they were typed, and the arithmetic disagrees with it on purpose.
CartBuilder.java — the seam
private final Catalog catalog;
private final List<Promotion> promotions = new ArrayList<>();
private CouponBook coupons = new CouponBook(Map.of());
private ShippingPolicy shipping = NO_DELIVERY_CHARGE;
public CartBuilder(Catalog catalog) {
this.catalog = Objects.requireNonNull(catalog, "catalog");
}
The one field with no default is a constructor argument, and the ones with defaults are setters. A cart with no catalogue cannot answer a single question, not even itemCount(), so new CartBuilder(catalog) demands one and the compiler enforces it. Leave this half out and a builder degrades into "a constructor you can forget to finish." Every other field quietly defaults to something, including the one that makes the object usable at all.
public CartBuilder plusPromotion(Promotion promotion) {
promotions.add(Objects.requireNonNull(promotion, "promotion"));
return this;
}
This method is the whole argument for a builder over a constructor. A constructor parameter is a fixed slot. This is a slot that grows. CartFactory.standard() calls it once today, for the multi-buy on tea. The day a "spend 3000, get free socks" offer arrives, that is a second call to this same method, at the one call site that builds a standard cart. It is not a fifth constructor parameter that every existing caller of new Cart(...) would have had to restate. Block 1's output has no promotions in it at all, which is the point: nothing calls plusPromotion, and nothing about that call not happening needs stating anywhere.
public ShoppingCartApi build() {
return new Cart(catalog, coupons, List.copyOf(promotions), new Totals(shipping));
}
List.copyOf, not the live list. Without it, two carts built from the same CartBuilder, block 4's first and second, would share one promotions list. A plusPromotion call made after the first build() would silently reach into a cart that already exists. Block 4's own output is the check: first holds a LAMP, second does not, because build() hands back a new Cart with its own CartLines each time, not a reference to the one already configured.
Three properties, together, are what earned this a file instead of a constructor. The required part is a constructor argument. The optional parts have real defaults: Map.of(), an empty list, NO_DELIVERY_CHARGE. A caller who wants a plain cart states nothing beyond the catalogue, which is exactly block 1. And one of the optional parts accumulates. A type with three or four required fields and nothing that grows does not want this. See when-not.md for the corpus's own case in point: Sku and LineRequest, records with no builder anywhere near them.
CartFactory.java — construction has an owner, and its parts are public
public static ShoppingCartApi standard() {
return new CartBuilder(standardCatalog())
.coupons(standardCoupons())
.shipping(standardShipping())
.plusPromotion(new MultiBuyPromotion("TEA", 3))
.build();
}
Every line here names one thing that changes for its own reason. The file's job is to be the one place that knows what a standard cart is made of, so nothing else has to. Two of the four lines exist because a requirement showed up after the base design. .coupons(standardCoupons()) grew a second entry, FIVER, when a stacking voucher was requested, and the .plusPromotion(...) line did not exist until three-for-two arrived.
Measured on the reference this file is drawn from: corpus/shopping-cart/curveballs/01-coupons-that-stack/budget.json records reference_diff: 1 for the coupon. corpus/shopping-cart/curveballs/02-buy-two-get-one/budget.json records reference_diff: 1 for the multi-buy. One added line each, because FixedAmountCoupon.java and MultiBuyPromotion.java are new files, and a new file costs nothing on this instrument.
public static Catalog standardCatalog() { ... }
public static CouponBook standardCoupons() { ... }
public static ShippingPolicy standardShipping() { ... }
Public, and callable on their own, not only through standard(). This is what makes Main.member's construction possible without touching this file:
ShoppingCartApi member = new CartBuilder(CartFactory.standardCatalog())
.coupons(CartFactory.standardCoupons())
.shipping((amountAfterDiscountMinor, itemCount) -> 0)
.build();
Block 3 shows what that buys: a cart with the same five skus and the same two coupons as every other cart in this run, and one part swapped. corpus/shopping-cart/curveballs/03-member-free-shipping/budget.json records reference_diff: 0 for the real version of this. The reference solution's MemberCartFactory is two new files, and CartFactory, Cart, CartLines and Totals are not opened by any of the three curveballs this problem measures. The three lines above are that same zero, written by hand instead of in a second factory class. This lesson only needs to show the reuse, not build a whole tier system around it.
Cart.java — package-private, and the reason
Cart(Catalog catalog, CouponBook coupons, List<Promotion> standingPromotions, Totals totals) {
No modifier is a modifier. In the default package this is documentation rather than an enforced boundary. A real layout would put Cart and CartBuilder in the same package and everything else outside it. But the intent is what matters here: there is no public constructor that assembles a working cart, so CartBuilder is not one way in, it is the only way in. A caller cannot construct a half-configured Cart even by accident, because there is nothing to call.
When not to
When a builder is not worth its file
A builder earns its place on three properties together: one required collaborator, several optional ones with real defaults, and something that accumulates. Drop the third and you are left with two properties that a plain constructor already has for free.
The bad example, built from types already in this corpus
Sku takes three fields — code, name, unitPriceMinor. All three are required, and there is no sensible default for a price. LineRequest takes two, both required. Give either one a builder:
public final class SkuBuilder {
private String code;
private String name;
private long unitPriceMinor;
public SkuBuilder code(String code) { this.code = code; return this; }
public SkuBuilder name(String name) { this.name = name; return this; }
public SkuBuilder unitPriceMinor(long price) { this.unitPriceMinor = price; return this; }
public Sku build() { return new Sku(code, name, unitPriceMinor); }
}
and three things get worse, not better.
A Sku can now exist half-built. new SkuBuilder().code("TEA").build() compiles and hands back new Sku("TEA", null, 0), and the record's own constructor throws NullPointerException on the name. new Sku("TEA", null, 0) written directly throws in the same place, so the builder added a class without adding a check that was not already there.
Nothing accumulates. code, name and unitPriceMinor are each set exactly once, on exactly one object, for the entire lifetime of a Sku. CartBuilder.plusPromotion earns its existence because it is called a variable number of times; a setter called exactly once is a constructor parameter wearing a longer name.
It costs a second file to answer the same question new Sku("TEA", "Loose leaf tea", 450) already answers in one line, and every call site is now four lines longer for no compile-time guarantee gained. over-engineered (premature interface) is the tag this move earns under STANDARD v1.0's D3 anchor for a speculative seam. The builder version of the same mistake is a class that exists so a caller can type field names it was always going to supply.
The threshold has three tests. Reach for a builder when a real collaborator is optional, when something can be added more than once, or when a second way to assemble the object is plausible. Sku and LineRequest meet none of the three. Both stay plain constructors.
Worked source
The 21 files of the worked design
Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.
worked/src/Cart.java105 linesworked/src/CartBuilder.java39 linesworked/src/CartFactory.java47 linesworked/src/CartLines.java84 linesworked/src/CartPage.java32 linesworked/src/CartTotal.java30 linesworked/src/Catalog.java35 linesworked/src/CouponBook.java22 linesworked/src/DiscountStage.java12 linesworked/src/FixedAmountCoupon.java28 linesworked/src/LineItem.java24 linesworked/src/LineRequest.java18 linesworked/src/MultiBuyPromotion.java35 linesworked/src/PercentageCoupon.java27 linesworked/src/Promotion.java19 linesworked/src/ShippingPolicy.java16 linesworked/src/ShoppingCartApi.java29 linesworked/src/Sku.java28 linesworked/src/ThresholdShipping.java25 linesworked/src/Totals.java41 linesworked/src/Main.java46 lines
worked/src/Cart.java105 lines
// Cart.java — verbatim from corpus/shopping-cart/reference/src/Cart.java.
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* One customer's cart. Package-private constructor on purpose: CartBuilder is the way in, so a
* cart cannot be built half-configured.
*/
public final class Cart implements ShoppingCartApi {
private final Catalog catalog;
private final CouponBook coupons;
private final List<Promotion> standingPromotions;
private final Totals totals;
private final CartLines lines = new CartLines();
private final Map<String, Promotion> appliedCoupons = new LinkedHashMap<>();
Cart(Catalog catalog, CouponBook coupons, List<Promotion> standingPromotions, Totals totals) {
this.catalog = Objects.requireNonNull(catalog, "catalog");
this.coupons = Objects.requireNonNull(coupons, "coupons");
this.standingPromotions = List.copyOf(standingPromotions);
this.totals = Objects.requireNonNull(totals, "totals");
}
@Override
public LineItem add(String skuCode, int quantity) {
return lines.add(catalog.require(skuCode), quantity);
}
@Override
public List<LineItem> addAll(List<LineRequest> requests) {
Objects.requireNonNull(requests, "requests");
Map<Sku, Integer> wanted = new LinkedHashMap<>();
for (LineRequest request : requests) {
Sku sku = catalog.require(request.skuCode());
if (request.quantity() <= 0) {
throw new IllegalArgumentException("a quantity must be positive, not "
+ request.quantity() + " for " + sku.code());
}
wanted.merge(sku, request.quantity(), Integer::sum);
}
List<LineItem> affected = new ArrayList<>();
wanted.forEach((sku, quantity) -> affected.add(lines.add(sku, quantity)));
return List.copyOf(affected);
}
@Override
public LineItem setQuantity(String skuCode, int quantity) {
return lines.setQuantity(catalog.require(skuCode), quantity);
}
@Override
public void remove(String skuCode, int quantity) {
lines.remove(catalog.require(skuCode), quantity);
}
@Override
public void clear() {
lines.clear();
}
@Override
public List<LineItem> lines() {
return lines.snapshot();
}
@Override
public CartPage page(int pageIndex, int pageSize) {
return lines.page(pageIndex, pageSize);
}
@Override
public int itemCount() {
return lines.itemCount();
}
@Override
public CartTotal total() {
return totals.price(lines.snapshot(), activePromotions());
}
@Override
public void applyCoupon(String couponCode) {
Promotion promotion = coupons.require(couponCode);
if (appliedCoupons.containsKey(couponCode)) {
throw new IllegalStateException("coupon already applied: " + couponCode);
}
appliedCoupons.put(couponCode, promotion);
}
@Override
public List<String> appliedCoupons() {
return List.copyOf(appliedCoupons.keySet());
}
private List<Promotion> activePromotions() {
List<Promotion> active = new ArrayList<>(standingPromotions);
active.addAll(appliedCoupons.values());
return active;
}
}
worked/src/CartBuilder.java39 lines
// CartBuilder.java — verbatim from corpus/shopping-cart/reference/src/CartBuilder.java.
// This is the seam this lesson is about. See worked/NOTES.md.
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class CartBuilder {
private static final ShippingPolicy NO_DELIVERY_CHARGE = (amountAfterDiscountMinor, itemCount) -> 0;
private final Catalog catalog;
private final List<Promotion> promotions = new ArrayList<>();
private CouponBook coupons = new CouponBook(Map.of());
private ShippingPolicy shipping = NO_DELIVERY_CHARGE;
public CartBuilder(Catalog catalog) {
this.catalog = Objects.requireNonNull(catalog, "catalog");
}
public CartBuilder coupons(CouponBook coupons) {
this.coupons = Objects.requireNonNull(coupons, "coupons");
return this;
}
public CartBuilder plusPromotion(Promotion promotion) {
promotions.add(Objects.requireNonNull(promotion, "promotion"));
return this;
}
public CartBuilder shipping(ShippingPolicy shipping) {
this.shipping = Objects.requireNonNull(shipping, "shipping");
return this;
}
public ShoppingCartApi build() {
return new Cart(catalog, coupons, List.copyOf(promotions), new Totals(shipping));
}
}
worked/src/CartFactory.java47 lines
// CartFactory.java — corpus/shopping-cart/reference/src/CartFactory.java, plus the two one-line
// additions that its own curveballs make: FIVER in standardCoupons() (curveball 01, "a second
// voucher that stacks", reference_diff 1) and .plusPromotion(...) in standard() (curveball 02,
// "buy two teas get one free", reference_diff 1). Folded into one file here so the demo can show
// both landing in the same seam without pretending the base problem shipped with a multi-buy
// offer. The one place that knows what a standard cart is made of; its parts are exposed
// individually (standardCatalog, standardCoupons, standardShipping) so a variation reuses what
// it is not changing — see Main.memberCart() and worked/NOTES.md.
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class CartFactory {
private static final long FLAT_DELIVERY_MINOR = 499;
private static final long FREE_DELIVERY_FROM_MINOR = 5000;
public static ShoppingCartApi standard() {
return new CartBuilder(standardCatalog())
.coupons(standardCoupons())
.shipping(standardShipping())
.plusPromotion(new MultiBuyPromotion("TEA", 3))
.build();
}
public static Catalog standardCatalog() {
return new Catalog(List.of(
new Sku("TEA", "Loose leaf tea", 450),
new Sku("MUG", "Enamel mug", 1250),
new Sku("SOCK", "Wool socks", 899),
new Sku("BEAN", "Coffee beans 1kg", 1899),
new Sku("LAMP", "Desk lamp", 4599)));
}
public static CouponBook standardCoupons() {
Map<String, Promotion> coupons = new LinkedHashMap<>();
coupons.put("SAVE10", new PercentageCoupon(10));
coupons.put("FIVER", new FixedAmountCoupon(500));
return new CouponBook(coupons);
}
public static ShippingPolicy standardShipping() {
return new ThresholdShipping(FLAT_DELIVERY_MINOR, FREE_DELIVERY_FROM_MINOR);
}
private CartFactory() {}
}
worked/src/CartLines.java84 lines
// CartLines.java — verbatim from corpus/shopping-cart/reference/src/CartLines.java.
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** The lines of one cart: at most one per sku, in the order the skus were first added. */
public final class CartLines {
private final Map<String, LineItem> byCode = new LinkedHashMap<>();
public LineItem add(Sku sku, int quantity) {
requirePositive(quantity);
LineItem existing = byCode.get(sku.code());
return put(sku, existing == null ? quantity : existing.quantity() + quantity);
}
public LineItem setQuantity(Sku sku, int quantity) {
if (quantity < 0) {
throw new IllegalArgumentException("a quantity cannot be negative: " + quantity);
}
return put(sku, quantity);
}
public void remove(Sku sku, int quantity) {
requirePositive(quantity);
LineItem existing = byCode.get(sku.code());
if (existing == null) {
return;
}
put(sku, Math.max(0, existing.quantity() - quantity));
}
public void clear() {
byCode.clear();
}
public List<LineItem> snapshot() {
return List.copyOf(byCode.values());
}
public int size() {
return byCode.size();
}
public int itemCount() {
int items = 0;
for (LineItem line : byCode.values()) {
items += line.quantity();
}
return items;
}
public CartPage page(int pageIndex, int pageSize) {
if (pageIndex < 0) {
throw new IllegalArgumentException("page indexes count from 0, not " + pageIndex);
}
if (pageSize < 1) {
throw new IllegalArgumentException("a page holds at least one line, not " + pageSize);
}
List<LineItem> all = new ArrayList<>(byCode.values());
int from = (int) Math.min((long) pageIndex * pageSize, all.size());
int to = (int) Math.min((long) from + pageSize, all.size());
return new CartPage(all.subList(from, to), pageIndex, pageSize, all.size(), to < all.size());
}
private LineItem put(Sku sku, int quantity) {
Objects.requireNonNull(sku, "sku");
LineItem line = new LineItem(sku, quantity, sku.unitPriceMinor() * quantity);
if (quantity == 0) {
byCode.remove(sku.code());
} else {
byCode.put(sku.code(), line);
}
return line;
}
private static void requirePositive(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("a quantity must be positive, not " + quantity);
}
}
}
worked/src/CartPage.java32 lines
// CartPage.java — verbatim from corpus/shopping-cart/contract/CartPage.java.
import java.util.List;
import java.util.Objects;
/**
* One window onto the cart's lines.
*
* @param lines the lines on this page, in the cart's iteration order
* @param pageIndex which page this is, counting from 0
* @param pageSize the size that was asked for
* @param totalLines how many lines the cart has altogether
* @param hasMore whether another page follows this one
*/
public record CartPage(List<LineItem> lines, int pageIndex, int pageSize, int totalLines, boolean hasMore) {
public CartPage {
lines = List.copyOf(Objects.requireNonNull(lines, "lines"));
if (pageIndex < 0) {
throw new IllegalArgumentException("page indexes count from 0, not " + pageIndex);
}
if (pageSize < 1) {
throw new IllegalArgumentException("a page holds at least one line, not " + pageSize);
}
if (totalLines < 0) {
throw new IllegalArgumentException("a cart cannot have " + totalLines + " lines");
}
if (lines.size() > pageSize) {
throw new IllegalArgumentException("a page of size " + pageSize
+ " cannot carry " + lines.size() + " lines");
}
}
}
worked/src/CartTotal.java30 lines
// CartTotal.java — verbatim from corpus/shopping-cart/contract/CartTotal.java.
/**
* What the cart comes to, broken into the four numbers a checkout page shows. All four are minor
* units in a long. The constructor enforces payableMinor == subtotalMinor - discountMinor +
* shippingMinor, so a total that does not add up cannot exist.
*
* @param subtotalMinor the lines added up at catalogue prices
* @param discountMinor everything knocked off, as a positive number, never more than the subtotal
* @param shippingMinor what delivery costs
* @param payableMinor what the customer actually owes
*/
public record CartTotal(long subtotalMinor, long discountMinor, long shippingMinor, long payableMinor) {
public CartTotal {
if (subtotalMinor < 0 || discountMinor < 0 || shippingMinor < 0) {
throw new IllegalArgumentException("a total has no negative parts: subtotal "
+ subtotalMinor + ", discount " + discountMinor + ", shipping " + shippingMinor);
}
if (discountMinor > subtotalMinor) {
throw new IllegalArgumentException("a discount of " + discountMinor
+ " is more than the subtotal of " + subtotalMinor);
}
if (payableMinor != subtotalMinor - discountMinor + shippingMinor) {
throw new IllegalArgumentException("payable must be subtotal - discount + shipping: "
+ subtotalMinor + " - " + discountMinor + " + " + shippingMinor
+ " is not " + payableMinor);
}
}
}
worked/src/Catalog.java35 lines
// Catalog.java — verbatim from corpus/shopping-cart/reference/src/Catalog.java.
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** What can be bought, and what one of it costs. Read-only: built once, never edited. */
public final class Catalog {
private final Map<String, Sku> byCode;
public Catalog(List<Sku> skus) {
Objects.requireNonNull(skus, "skus");
Map<String, Sku> mutable = new LinkedHashMap<>();
for (Sku sku : skus) {
if (mutable.putIfAbsent(sku.code(), sku) != null) {
throw new IllegalArgumentException("two skus share the code " + sku.code());
}
}
this.byCode = mutable;
}
public Sku require(String skuCode) {
Objects.requireNonNull(skuCode, "skuCode");
Sku sku = byCode.get(skuCode);
if (sku == null) {
throw new IllegalArgumentException("no such sku: " + skuCode);
}
return sku;
}
public List<Sku> skus() {
return List.copyOf(byCode.values());
}
}
worked/src/CouponBook.java22 lines
// CouponBook.java — verbatim from corpus/shopping-cart/reference/src/CouponBook.java.
import java.util.Map;
import java.util.Objects;
/** Which codes a customer may type, and what each one does. */
public final class CouponBook {
private final Map<String, Promotion> byCode;
public CouponBook(Map<String, Promotion> coupons) {
this.byCode = Map.copyOf(Objects.requireNonNull(coupons, "coupons"));
}
public Promotion require(String couponCode) {
Objects.requireNonNull(couponCode, "couponCode");
Promotion promotion = byCode.get(couponCode);
if (promotion == null) {
throw new IllegalArgumentException("no such coupon: " + couponCode);
}
return promotion;
}
}
worked/src/DiscountStage.java12 lines
// DiscountStage.java — verbatim from corpus/shopping-cart/reference/src/DiscountStage.java.
/**
* When a discount is worked out, relative to the other discounts. The requirement fixes this
* order: item promotions, then percentage coupons, then fixed amounts, each on what is still
* owed after the earlier ones.
*/
public enum DiscountStage {
ITEM_PROMOTION,
PERCENTAGE_COUPON,
FIXED_AMOUNT_COUPON
}
worked/src/FixedAmountCoupon.java28 lines
// FixedAmountCoupon.java — verbatim from
// corpus/shopping-cart/curveballs/01-coupons-that-stack/reference-patch/FixedAmountCoupon.java.
// Added to absorb curveball 01: a second voucher that stacks with SAVE10. No existing file was
// opened to add this promotion; see CartFactory.standardCoupons() for the one added line that
// registers its code.
import java.util.List;
public final class FixedAmountCoupon implements Promotion {
private final long amountMinor;
public FixedAmountCoupon(long amountMinor) {
if (amountMinor < 0) {
throw new IllegalArgumentException("a voucher takes money off, not on: " + amountMinor);
}
this.amountMinor = amountMinor;
}
@Override
public DiscountStage stage() {
return DiscountStage.FIXED_AMOUNT_COUPON;
}
@Override
public long discountMinor(List<LineItem> lines, long amountOwedMinor) {
return amountMinor;
}
}
worked/src/LineItem.java24 lines
// LineItem.java — verbatim from corpus/shopping-cart/contract/LineItem.java.
import java.util.Objects;
/**
* A snapshot of one line in the cart: what it is, how many, and what the line comes to.
*
* @param sku what is on the line. Never null
* @param quantity how many. Zero or more
* @param lineTotalMinor unit price times quantity, in minor units. The constructor refuses
* anything else, so a wrong line total cannot exist
*/
public record LineItem(Sku sku, int quantity, long lineTotalMinor) {
public LineItem {
Objects.requireNonNull(sku, "sku");
if (quantity < 0) {
throw new IllegalArgumentException("a line cannot hold " + quantity + " items");
}
if (lineTotalMinor != sku.unitPriceMinor() * quantity) {
throw new IllegalArgumentException("a line total must be unit price times quantity: "
+ sku.unitPriceMinor() + " x " + quantity + " is not " + lineTotalMinor);
}
}
}
worked/src/LineRequest.java18 lines
// LineRequest.java — verbatim from corpus/shopping-cart/contract/LineRequest.java.
import java.util.Objects;
/**
* One item of a bulk add: a sku code and how many of it.
*
* @param skuCode the catalogue code. Never null, never blank
* @param quantity how many are wanted. addAll refuses zero and below; this record does not
*/
public record LineRequest(String skuCode, int quantity) {
public LineRequest {
Objects.requireNonNull(skuCode, "skuCode");
if (skuCode.isBlank()) {
throw new IllegalArgumentException("a line request needs a sku code");
}
}
}
worked/src/MultiBuyPromotion.java35 lines
// MultiBuyPromotion.java — verbatim from
// corpus/shopping-cart/curveballs/02-buy-two-get-one/reference-patch/MultiBuyPromotion.java.
// Added to absorb curveball 02. The only other change that curveball needed was one line in
// CartFactory.standard(): .plusPromotion(new MultiBuyPromotion("TEA", 3)).
import java.util.List;
import java.util.Objects;
public final class MultiBuyPromotion implements Promotion {
private final String skuCode;
private final int groupSize;
public MultiBuyPromotion(String skuCode, int groupSize) {
this.skuCode = Objects.requireNonNull(skuCode, "skuCode");
if (groupSize < 2) {
throw new IllegalArgumentException("a multi-buy needs at least two to buy, not " + groupSize);
}
this.groupSize = groupSize;
}
@Override
public DiscountStage stage() {
return DiscountStage.ITEM_PROMOTION;
}
@Override
public long discountMinor(List<LineItem> lines, long amountOwedMinor) {
for (LineItem line : lines) {
if (line.sku().code().equals(skuCode)) {
return (long) (line.quantity() / groupSize) * line.sku().unitPriceMinor();
}
}
return 0;
}
}
worked/src/PercentageCoupon.java27 lines
// PercentageCoupon.java — verbatim from corpus/shopping-cart/reference/src/PercentageCoupon.java.
import java.util.List;
/** A percentage off what is owed. SAVE10 is one of these with 10. */
public final class PercentageCoupon implements Promotion {
private static final long HALF_UP = 50;
private final long percent;
public PercentageCoupon(long percent) {
if (percent < 0 || percent > 100) {
throw new IllegalArgumentException("a percentage off is between 0 and 100, not " + percent);
}
this.percent = percent;
}
@Override
public DiscountStage stage() {
return DiscountStage.PERCENTAGE_COUPON;
}
@Override
public long discountMinor(List<LineItem> lines, long amountOwedMinor) {
return (amountOwedMinor * percent + HALF_UP) / 100;
}
}
worked/src/Promotion.java19 lines
// Promotion.java — verbatim from corpus/shopping-cart/reference/src/Promotion.java.
import java.util.List;
/**
* Something that takes money off a cart: a coupon, a multi-buy, a seasonal offer. The interface
* is about discount, not coupon — a coupon is one way a discount gets attached to a cart (a code
* the customer typed); a multi-buy is another (it is simply always on).
*/
public interface Promotion {
DiscountStage stage();
/**
* @param lines the cart's lines, for a promotion that cares what is in the basket
* @param amountOwedMinor what is still owed after the earlier stages — never negative
* @return zero or more; Totals caps it, so a promotion never has to think about the others
*/
long discountMinor(List<LineItem> lines, long amountOwedMinor);
}
worked/src/ShippingPolicy.java16 lines
// ShippingPolicy.java — verbatim from corpus/shopping-cart/reference/src/ShippingPolicy.java.
/**
* What delivery costs for a cart. Note the two arguments it does not take: the subtotal, and the
* cart. It is given the amount after discount, because the free-delivery threshold is measured on
* what the customer actually pays.
*/
public interface ShippingPolicy {
/**
* @param amountAfterDiscountMinor what is owed for the goods, discounts already taken off
* @param itemCount how many items are being shipped; zero for an empty cart
* @return zero or more, in minor units
*/
long shippingMinor(long amountAfterDiscountMinor, int itemCount);
}
worked/src/ShoppingCartApi.java29 lines
// ShoppingCartApi.java — the method signatures, from corpus/shopping-cart/contract/ShoppingCartApi.java.
// The javadoc here is trimmed to what this lesson needs; the full contract (money convention,
// paging rules, coupon-stacking order) is at that path and is not this lesson's subject.
import java.util.List;
public interface ShoppingCartApi {
LineItem add(String skuCode, int quantity);
List<LineItem> addAll(List<LineRequest> requests);
LineItem setQuantity(String skuCode, int quantity);
void remove(String skuCode, int quantity);
void clear();
List<LineItem> lines();
CartPage page(int pageIndex, int pageSize);
int itemCount();
CartTotal total();
void applyCoupon(String couponCode);
List<String> appliedCoupons();
}
worked/src/Sku.java28 lines
// Sku.java — verbatim from corpus/shopping-cart/contract/Sku.java.
import java.util.Objects;
/**
* One thing that can be bought: its code, its name and what one of it costs.
*
* A value, not an entity. Two skus with the same code and a different price are two different
* values.
*
* @param code the catalogue code, e.g. "TEA". Never null, never blank. Compared
* exactly — codes are case-sensitive
* @param name what a human calls it, e.g. "Loose leaf tea". Never null
* @param unitPriceMinor the price of one, in minor units. Zero or more; a free gift is a
* legitimate sku
*/
public record Sku(String code, String name, long unitPriceMinor) {
public Sku {
Objects.requireNonNull(code, "code");
Objects.requireNonNull(name, "name");
if (code.isBlank()) {
throw new IllegalArgumentException("a sku needs a code");
}
if (unitPriceMinor < 0) {
throw new IllegalArgumentException("a price cannot be negative: " + unitPriceMinor);
}
}
}
worked/src/ThresholdShipping.java25 lines
// ThresholdShipping.java — verbatim from corpus/shopping-cart/reference/src/ThresholdShipping.java.
/** A flat delivery charge, waived once the cart is big enough. The standard policy is 499, free
* at 5000 and above. */
public final class ThresholdShipping implements ShippingPolicy {
private final long flatChargeMinor;
private final long freeAboveMinor;
public ThresholdShipping(long flatChargeMinor, long freeAboveMinor) {
if (flatChargeMinor < 0 || freeAboveMinor < 0) {
throw new IllegalArgumentException("delivery charges and thresholds are not negative");
}
this.flatChargeMinor = flatChargeMinor;
this.freeAboveMinor = freeAboveMinor;
}
@Override
public long shippingMinor(long amountAfterDiscountMinor, int itemCount) {
if (itemCount == 0) {
return 0;
}
return amountAfterDiscountMinor >= freeAboveMinor ? 0 : flatChargeMinor;
}
}
worked/src/Totals.java41 lines
// Totals.java — verbatim from corpus/shopping-cart/reference/src/Totals.java.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
/** The money pipeline: lines in, CartTotal out. */
public final class Totals {
private final ShippingPolicy shipping;
public Totals(ShippingPolicy shipping) {
this.shipping = Objects.requireNonNull(shipping, "shipping");
}
public CartTotal price(List<LineItem> lines, List<Promotion> promotions) {
long subtotal = 0;
int itemCount = 0;
for (LineItem line : lines) {
subtotal += line.lineTotalMinor();
itemCount += line.quantity();
}
long owed = subtotal;
long discount = 0;
for (Promotion promotion : inStageOrder(promotions)) {
long off = Math.min(Math.max(0, promotion.discountMinor(lines, owed)), owed);
discount += off;
owed -= off;
}
long delivery = shipping.shippingMinor(owed, itemCount);
return new CartTotal(subtotal, discount, delivery, owed + delivery);
}
private static List<Promotion> inStageOrder(List<Promotion> promotions) {
List<Promotion> ordered = new ArrayList<>(promotions);
ordered.sort(Comparator.comparing(Promotion::stage));
return ordered;
}
}
worked/src/Main.java46 lines
// Main.java — the driver. Adapted from corpus/shopping-cart/reference/src/Demo.java, narrowed to
// what this lesson is about: three carts, built three different ways, off one CartBuilder.
public final class Main {
public static void main(String[] args) {
System.out.println("1. the required argument: a cart cannot be built without a catalogue");
ShoppingCartApi bare = new CartBuilder(CartFactory.standardCatalog()).build();
bare.add("MUG", 1);
System.out.println(" new CartBuilder(catalog).build() -> " + bare.total()
+ " (no coupons, no promotions, no delivery charge: every optional part defaulted)");
System.out.println();
System.out.println("2. the standard cart: every optional part named, one call each");
ShoppingCartApi standard = CartFactory.standard();
standard.add("TEA", 3);
System.out.println(" 3 TEA, buy-two-get-one active: " + standard.total());
standard.applyCoupon("FIVER");
System.out.println(" + FIVER, typed first: " + standard.total());
standard.applyCoupon("SAVE10");
System.out.println(" + SAVE10, typed second: " + standard.total()
+ " (percentage still runs before the flat amount — DiscountStage, not typing order)");
System.out.println(" coupons applied, in the order typed: " + standard.appliedCoupons());
System.out.println();
System.out.println("3. a member cart: reuses what it is not changing, replaces one part");
ShoppingCartApi member = new CartBuilder(CartFactory.standardCatalog())
.coupons(CartFactory.standardCoupons())
.shipping((amountAfterDiscountMinor, itemCount) -> 0) // gold tier: always free
.build();
member.add("SOCK", 1);
System.out.println(" 1 SOCK at 899, gold shipping: " + member.total()
+ " (a non-member cart would owe 499 delivery here — see when-not.md for the line)");
System.out.println();
System.out.println("4. two builds from the same builder do not share state");
CartBuilder sharedBuilder = new CartBuilder(CartFactory.standardCatalog());
ShoppingCartApi first = sharedBuilder.build();
first.add("LAMP", 1);
ShoppingCartApi second = sharedBuilder.build();
System.out.println(" first cart after adding a LAMP: " + first.lines());
System.out.println(" second cart, same builder: " + second.lines()
+ " <- empty, so build() did not hand back the same cart twice");
}
private Main() {}
}
The faded stage is not here, on purpose
In the app, the third stage of a lesson hands you the worked design with a few lines
replaced by // GAP: markers, then compiles your completion and runs a JUnit suite
against it. That needs javac, and a static site has no compiler — so rather than show a
control that cannot work, this page stops at the worked source.
Run the app for the drill: it is the download in the header, and it works offline once unpacked.