Reading Complex Cart Logic in Java — Branching, APIs, and Delivery Fees
Every large e-commerce codebase has one. A service method that's 150+ lines long, with branching three levels deep, external API calls buried inside a loop, and variables you have to scroll up to find. You didn't write it — you inherited it.
This post breaks down exactly that kind of method: a Java cart DTO assembly function. The goal isn't to mock the code. It's to understand the logic structure, identify the non-obvious decisions, and spot the spots where things could quietly go wrong.
What the Method Actually Does
In one sentence: it takes a list of cart products, queries their pricing, delivery, and payment data from external services, then assembles and returns a response DTO.
Input: List<CartProduct>. Output: CartDto. The complexity lives in between.
The flow breaks down into five stages:
1. Pre-loop: special product type handling
2. Main loop: payment method resolution
3. Main loop: product data fetching and price calculation
4. Delivery fee calculation (delegated)
5. Payment method filtering + DTO assembly
Stage 1: Pre-Loop Special Case Handling
Before the main loop runs, there's a standalone block that handles a specific product type — one that requires a different external service for price data (think: quote-based or third-party-integrated products).
// Pre-loop: handle special product type if present
if (!CollectionUtils.isEmpty(cartProductList)
&& !StringUtils.isEmpty(cartProductList.get(0).getSpecialProdNo())) {
List<CartProductItem> items = cartProductList.get(0).getCartProductItems();
// Case A: identifier exists at item level → loop through items
if (!StringUtils.isEmpty(items.get(0).getExternalId())) {
for (CartProductItem cartItem : items) {
ProductItem prodItem = externalService.fetchItem(
cartItem.getProductItem().getExternalId(), ...
);
cartItem.setProductItem(prodItem);
// calculate price: quantity × unit price
}
}
// Case B: identifier is on the parent → apply to first item only
else {
ProductItem prodItem = externalService.fetchItem(
cartProductList.get(0).getExternalId(), ...
);
items.get(0).setProductItem(prodItem);
}
}
Why is this outside the loop? The business rule appears to be that this product type always occupies the first position in the list. That constraint is embedded in the code rather than enforced explicitly — a classic sign of requirements that evolved without a schema update.
Bug risk:
items.get(0)is called without first checking whetheritemsis empty. If it is, you get anIndexOutOfBoundsExceptionat runtime. A simpleif (!items.isEmpty())guard would fix it.
Stage 2: Payment Method Resolution (Inside the Main Loop)
The main loop starts, and the first thing it does for each cart product is figure out which payment methods are available — but only when the display category ID (dispCatNo) is valid.
De-duplicating "Simple Pay" methods
Multiple payment codes may map to the same UX concept — a single "quick pay" button. A boolean flag handles the deduplication:
boolean simplePayChk = false;
for (PayWay payWay : payWayList) {
if (isSimplePayType(payWay.getCode())) {
if (!simplePayChk) {
realPayWayList.add(payWay); // only the first one makes it through
simplePayChk = true;
}
// subsequent simple pay codes are silently dropped
} else {
realPayWayList.add(payWay); // all other types pass through
}
}
Counting payment method availability per product
Each payment code gets a running count across products. This count powers the filtering logic in Stage 5.
// Some types (loyalty points, special credits) are intentionally excluded
if (!isExcludedPayType(payWay.getCode())) {
payWayCntMap.merge(payWay.getCode(), 1, Integer::sum);
}
Fetching partner payment support data
If a partner payment type is enabled on a product, the method does an additional lookup to retrieve the support amount (a discount or subsidy applied at checkout):
if (hasPartnerPayType && cartProduct.getCartNo() != 0) {
SupportInfo support = supportService.fetchSupport(cartNo);
if (support != null) {
// certain status codes → zero out the support amount
// approved status → accumulate into totalSupportAmount
}
}
Stage 3: Product Data Fetching and Price Calculation
Still inside the main loop, the second major branch is regular product vs. quote-based product.
| Type | Trigger | How |
|---|---|---|
| Regular | no qttnProdNo | fetch base info + delivery + image + items separately |
| Quote-based | qttnProdNo present | single dedicated API call |
Regular product path
This is where an N+1 pattern lives. Four service calls per product:
ProductBase prodDto = productService.fetchBase(prodParam);
if (prodDto == null) { continue; } // skip this product entirely if null
DlprcDtl deliveryFee = deliveryService.fetchDeliveryFee(prodNo);
DlvPolcDtl deliveryPolicy = deliveryService.fetchDeliveryPolicy(prodNo);
ProductImage image = productService.fetchImage(prodNo);
Then a nested loop over each item:
for (CartProductItem cartItem : cartProduct.getCartProductItems()) {
ProductItem item = productItemService.fetchItem(itemParam);
cartItem.setProductItem(item);
Long price = item.getRealPrice();
// If the product doesn't use options, override with the base product price
if ("N".equals(cartProduct.getProductBase().getOptUseYn())) {
price = cartProduct.getProductBase().getRealPrice();
}
cartItem.setProdPrc(cartItem.getOrdQty() * price);
}
The option-flag branch is easy to miss. Skip it and you'll silently calculate wrong prices for products without option variants.
Quote-based product path
Cleaner — one call, one price:
ProductBase prodDto = productService.fetchQuote(qttnProdNo, qttnReqNo, bidProdNo);
cartProduct.setProductBase(prodDto);
cartProduct.setProdPrc(prodDto.getUnitBuyQty() * prodDto.getUnitPrice());
quoteMode = true; // signals Stage 4 to use a different delivery fee calculation
Duplicate grouping code (refactoring candidate)
Both branches end with the same productMap grouping block — identical code, copy-pasted. Java 8 makes this a one-liner:
// Current (duplicated in both branches)
if (productMap.get(relcopNo) != null && productMap.get(relcopNo).size() > 0) {
productMap.get(relcopNo).add(cartProduct);
} else {
List<CartProduct> list = new ArrayList<>();
list.add(cartProduct);
productMap.put(relcopNo, list);
}
// Cleaner version
productMap.computeIfAbsent(relcopNo, k -> new ArrayList<>()).add(cartProduct);
Stage 4: Delegating Delivery Fee Calculation
Once the loop finishes, delivery fee calculation is handed off to a dedicated class. The structure is Strategy pattern — the adapter decouples the domain model from the calculator interface.
DlvrPriceCalculator calc = new DlvrPriceCalculator();
DlvrCalcResult<CartProduct> result = calc.calculate(
cartProductList,
new CartProductDlvrAdapter(), // maps CartProduct → calculator's expected interface
DlvrCalcContext.builder()
.memberType(mbrTpCd)
.quoteMode(quoteMode) // true → different calculation path for quote products
.build()
);
totalDeliveryFee = result.getBaseDlprcTotal();
totalJejuFee = result.getJejuDlprcTotal(); // island surcharge
totalRemoteAreaFee = result.getRemtpDlprcTotal(); // remote area surcharge
prodByRelcopList = result.getProdByRelcopListSafe(); // reconstructed product list
The three separate delivery fee totals matter — they're displayed separately in the UI and invoiced differently.
Stage 5: Payment Method Filtering and Final Assembly
Here's where Stage 2's count map gets used. The rule: only payment methods available for every product appear at checkout.
List<String> finalPayWayList = new ArrayList<>();
for (Map.Entry<String, Integer> entry : payWayCntMap.entrySet()) {
if (entry.getValue() == prodByRelcopList.size()) { // appeared on ALL products
finalPayWayList.add(entry.getKey());
}
}
The intent is clear: if Product A supports credit card + bank transfer, and Product B only supports credit card, the cart should only show credit card.
Subtle gotcha: the comparison is against
prodByRelcopList.size(), notcartProductList.size(). That list is reconstructed by the delivery calculator internally. If any products were skipped earlier (viacontinuewhenprodDto == null), the count mismatch can silently produce wrong payment options.
Final DTO assembly:
cartDto.setPayWayCdList(finalPayWayList);
cartDto.setCartProductList(prodByRelcopList);
cartDto.setTotalProdPrc(totalProdPrc);
cartDto.setTotalDlvPrc(totalDeliveryFee);
cartDto.setTotalPayPrc(totalProdPrc + totalDeliveryFee - totalSupportPrc); // subsidy applied
cartDto.setTotalJejuDlvPrc(totalJejuFee);
cartDto.setTotalRemtpDlvPrc(totalRemoteAreaFee);
What to Take Away
This method works. But it has a few structural weaknesses worth knowing if you ever need to modify it:
Missing null guards on index access. The get(0) calls in the pre-loop block assume the list is non-empty. Add an isEmpty() check before each one.
N+1 query structure. Four service calls per product in the main loop means performance degrades linearly with cart size. If this becomes a bottleneck, batch-fetching product data before the loop is the obvious lever.
Duplicate grouping code. Two identical productMap population blocks exist in the regular and quote-based branches. computeIfAbsent consolidates both to one line.
Implicit coupling in payment filtering. The filter compares against a list size that's determined inside the delivery calculator — not the input list. This is easy to miss when debugging payment option issues.
The broader lesson: when reading legacy service code, the question to ask isn't "why is this so messy?" — it's "what business constraint made each of these decisions look reasonable at the time?" Most of the time, the answer is in the domain, not the code.