本章探討雜貨店(Grocery Store)系統的設計。這套系統服務雜貨店員工,協助管理商品目錄、設定價格、套用折扣等日常作業。
需求蒐集#
題目情境#
想像你在雜貨店挑選蔬果、零食與生活用品。結帳時,收銀員逐項掃描,系統即時追蹤訂單、套用折扣、顯示總額。背景中,系統管理商品目錄、隨進貨與銷售更新庫存,確保每筆交易順暢精準。請設計這樣一套系統。
需求釐清對話#
Candidate:系統支援哪些主要操作?
Interviewer:應支援店員(出貨員、收銀員)管理商品目錄、追蹤庫存、處理顧客結帳與折扣。
Candidate:結帳流程是這樣嗎——收銀員掃描或輸入商品碼,系統追蹤訂單、計算小計、套用折扣、更新總額;輸入完成後收銀員看見最終金額、收款找零、產生收據?
Interviewer:是的。
Candidate:庫存如何管理?
Interviewer:系統應追蹤所有商品庫存,進貨時增加、結帳時自動扣除。
Candidate:商品需要分類嗎(食品、飲料等)?
Interviewer:是個好主意。
Candidate:折扣呢——系統追蹤折扣活動,可套用於特定商品或分類;若多個折扣同時適用,自動套用最高折扣?
Interviewer:聽起來不錯。
需求整理#
商品目錄管理#
- 管理員可新增、更新、移除商品
- 目錄追蹤名稱、分類、價格、條碼等細節
庫存管理#
- 出貨員可在進貨時更新庫存
- 系統在售出時自動扣減庫存
結帳流程#
- 收銀員可掃描條碼或手動輸入商品碼建立訂單
- 收銀員可檢視目前訂單細節(商品、折扣、小計)
- 系統自動套用相關折扣
- 收銀員可結帳、計算總額、處理付款與找零
- 產生詳細收據
折扣活動#
- 管理員可為特定商品或分類定義折扣活動
- 多個折扣同時適用時,系統選擇最高者
非功能性需求#
- 提供清晰、友善的錯誤訊息(例如無效條碼、庫存不足)
- 元件(catalog、inventory、checkout、discounts)需模組化,支援獨立更新與替換
辨識核心物件#
- Item:店內單一商品,封裝名稱、條碼、分類、價格
- Catalog:商品的中央存放處,管理新增、更新、移除等操作
- Inventory:追蹤每個商品的庫存量,進貨時增加、銷售時扣減
- Order:追蹤進行中的結帳流程,管理商品、折扣、小計與總額
- DiscountCampaign:定義折扣的促銷規則
類別圖設計#
Item#
Item 代表店內商品,封裝名稱、條碼、分類、價格等屬性。
Catalog#
Catalog 維護所有商品的結構化清單,每個商品由條碼唯一識別。
Inventory#
Inventory 管理庫存量,維護條碼與庫存量的對應。
Design Choice:將靜態商品資訊與動態庫存量分離:
- 靜態資料(Catalog):產品名稱、分類、價格等不常變動的中繼資料
- 動態資料(Inventory):因銷售與進貨頻繁變動的庫存量
這種分離簡化兩個類別並符合單一職責原則。
DiscountCriteria#
DiscountCriteria 介面封裝「商品是否符合折扣條件」的邏輯,提供彈性可擴充框架。
具體類別:
- CategoryBasedCriteria:依分類比對。例如折扣鎖定「Beverages」分類時,只要商品分類為 Beverages 就適用
- ItemBasedCriteria:依特定商品比對,適合針對個別商品的促銷或清倉折扣
DiscountCalculationStrategy#
DiscountCalculationStrategy 介面封裝計算折扣的邏輯,採用 Strategy Pattern 提供彈性。
- AmountBasedStrategy:套用固定折扣金額。例如 $100 - $20 = $80
- PercentageBasedStrategy:套用百分比折扣。例如 $100 × (1 - 20%) = $80
Design Choice:此設計高度可擴充——新折扣策略(如分級折扣、買 X 送 Y)可無痛加入而不修改既有實作,符合開放/封閉原則。
DiscountCampaign#
DiscountCampaign 模擬進行中的折扣活動,運用 Strategy Pattern 封裝計算策略,並用 DiscountCriteria 指定哪些商品符合條件。
Design Choice:分離適用性邏輯(criteria)與計算策略,使類別更模組化、易擴充。新增 criteria 或計算策略不需修改既有實作。
OrderItem#
OrderItem 代表訂單中單一商品與其數量,封裝計算總價的邏輯。
Design Choice:將商品層級細節從更高層的 Order 分離,確保每個商品的數量與價格邏輯獨立封裝。
Order#
Order 代表結帳期間的進行中交易,追蹤商品清單與套用的折扣,並提供計算小計與總額的方法。
Design Choice:
Order專注於管理交易資料,把單一商品的數量處理委派給OrderItem。
Receipt#
Receipt 是已完成交易的最終紀錄,將訂單摘要、付款細節、找零等資訊整理為對顧客友善的格式。
Design Choice:
Receipt只負責呈現資料,把計算與折扣邏輯委派給Order與OrderItem,保持輕量且專注。
Checkout#
Checkout 封裝結帳流程邏輯,維護一個進行中的 Order,並套用相關折扣活動。
Design Choice:儘管
Checkout角色關鍵,但仍保持輕量——商品、折扣、計算等職責皆委派給Order、DiscountCampaign等元件。
GroceryStoreSystem#
GroceryStoreSystem 作為 facade 簡化客戶端與系統元件(Catalog、Inventory、Checkout)的互動。在面試中,這個 facade 也讓我們能透過映射方法來驗證需求覆蓋情況。
完整類別圖#
程式實作#
Item#
public class Item {
private final String name;
private final String barcode;
private final String category;
private BigDecimal price;
public Item(String name, String barcode, String category, BigDecimal price) {
this.name = name;
this.barcode = barcode;
this.category = category;
this.price = price;
}
// getter and setter methods are omitted for brevity
}Catalog#
public class Catalog {
// Map of barcodes to their corresponding items
private final Map<String, Item> items = new HashMap<>();
public void updateItem(Item item) {
items.put(item.getBarcode(), item);
}
public void removeItem(String barcode) {
items.remove(barcode);
}
public Item getItem(String barcode) {
return items.get(barcode);
}
}Implementation Note:使用
Map<String, Item>,以條碼為 key 提供高效查找、更新、刪除。
Inventory#
public class Inventory {
// Map of barcodes to their stock quantities
private final Map<String, Integer> stock = new HashMap<>();
public void addStock(String barcode, int count) {
stock.put(barcode, stock.getOrDefault(barcode, 0) + count);
}
public void reduceStock(String barcode, int count) {
stock.put(barcode, stock.getOrDefault(barcode, 0) - count);
}
public int getStock(String barcode) {
return stock.getOrDefault(barcode, 0);
}
}Implementation Choice:用
HashMap將條碼對應到庫存量,提供 O(1) 平均時間複雜度的更新與查詢。
DiscountCampaign#
public class DiscountCampaign {
// Unique identifier for the discount campaign
private final String discountId;
// Name of the discount campaign
private final String name;
// Criteria that determines if the discount applies to an item or category
private final DiscountCriteria criteria;
// Strategy for calculating the discounted price
private final DiscountCalculationStrategy calculationStrategy;
// Creates a new discount campaign with the specified details
public DiscountCampaign(
String discountId,
String name,
DiscountCriteria criteria,
DiscountCalculationStrategy calculationStrategy) {
this.discountId = discountId;
this.name = name;
this.criteria = criteria;
this.calculationStrategy = calculationStrategy;
}
// Checks if this discount applies to the given item
public boolean isApplicable(Item item) {
return criteria.isApplicable(item);
}
// Calculates the discounted price for the given order item
public BigDecimal calculateDiscount(OrderItem item) {
return calculationStrategy.calculateDiscountedPrice(item.calculatePrice());
}
// getter and setter methods are omitted for brevity
}Implementation Choice:
DiscountCampaign用組合持有單一DiscountCriteria與DiscountCalculationStrategy,運用多型實現彈性折扣設定。為節省篇幅,省略DiscountCriteria與DiscountCalculationStrategy的程式碼。
OrderItem#
public class OrderItem {
// The item being ordered
private final Item item;
// Quantity of the item
private final int quantity;
// Creates a new order item with the specified item and quantity
public OrderItem(Item item, int quantity) {
this.item = item;
this.quantity = quantity;
}
// Calculates the total price for this order item without any discount
public BigDecimal calculatePrice() {
return item.getPrice().multiply(BigDecimal.valueOf(quantity));
}
// Calculates the total price for this order item with the given discount
public BigDecimal calculatePriceWithDiscount(DiscountCampaign newDiscount) {
return newDiscount.calculateDiscount(this);
} // getter and setter methods are omitted for brevity
}Order#
public class Order {
// Unique identifier for the order
private final String orderId;
// List of items in the order
private final List<OrderItem> items = new ArrayList<>();
// Map of items to their applied discounts
private final Map<OrderItem, DiscountCampaign> appliedDiscounts = new HashMap<>();
// Amount paid by the customer
private BigDecimal paymentAmount = BigDecimal.ZERO;
// Creates a new order with a random UUID
public Order() {
this.orderId = String.valueOf(UUID.randomUUID());
}
// Adds an item to the order
public void addItem(OrderItem item) {
items.add(item);
}
// Calculates the subtotal of all items without discounts
public BigDecimal calculateSubtotal() {
return items.stream()
.map(OrderItem::calculatePrice)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
// Calculates the total price including all applied discounts
public BigDecimal calculateTotal() {
return items.stream()
.map(
item -> {
DiscountCampaign discount = appliedDiscounts.get(item);
return discount != null
? item.calculatePriceWithDiscount(discount)
: item.calculatePrice();
})
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
// Applies a discount to a specific item in the order
public void applyDiscount(OrderItem item, DiscountCampaign discount) {
appliedDiscounts.put(item, discount);
}
// Calculates the change to be returned to the customer
public BigDecimal calculateChange() {
return paymentAmount.subtract(calculateTotal());
}
// getter and setter methods are omitted for brevity
}Implementation Choice:
ArrayList提供 O(1) 的商品新增與 O(n) 的迭代計算,適合小型訂單。HashMap<OrderItem, DiscountCampaign>追蹤每商品折扣,提供 O(1) 平均查找。
Checkout#
public class Checkout {
// Current order being processed
private Order currentOrder;
// List of active discount campaigns
private final List<DiscountCampaign> activeDiscounts;
// Creates a new checkout with the given active discounts
public Checkout(List<DiscountCampaign> activeDiscounts) {
this.activeDiscounts = activeDiscounts;
startNewOrder();
}
// Starts a new order
public void startNewOrder() {
this.currentOrder = new Order();
}
// Processes the payment and returns the change
public BigDecimal processPayment(BigDecimal paymentAmount) {
currentOrder.setPayment(paymentAmount);
return currentOrder.calculateChange();
}
// Adds an item to the current order and applies applicable discounts
public void addItemToOrder(Item item, int quantity) {
OrderItem orderItem = new OrderItem(item, quantity);
currentOrder.addItem(orderItem);
for (DiscountCampaign newDiscount : activeDiscounts) {
if (newDiscount.isApplicable(item)) {
// if there are multiple newDiscount that apply to item, apply the higher one
if (currentOrder.getAppliedDiscounts().containsKey(orderItem)) {
DiscountCampaign existingDiscount =
currentOrder.getAppliedDiscounts().get(orderItem);
if (orderItem
.calculatePriceWithDiscount(newDiscount)
.compareTo(
orderItem.calculatePriceWithDiscount(existingDiscount))
> 0) {
currentOrder.applyDiscount(orderItem, newDiscount);
}
} else {
currentOrder.applyDiscount(orderItem, newDiscount);
}
}
}
}
// Generates a receipt for the current order
public Receipt getReceipt() {
return new Receipt(currentOrder);
}
// Calculates the total amount for the current order
public BigDecimal getOrderTotal() {
return currentOrder.calculateTotal();
}
}GroceryStoreSystem#
public class GroceryStoreSystem {
// Product catalog containing all available items
private final Catalog catalog;
// Inventory tracking system
private final Inventory inventory;
// List of active discount campaigns
private List<DiscountCampaign> activeDiscounts = new ArrayList<>();
// Checkout system for processing orders
private final Checkout checkout;
public GroceryStoreSystem() {
this.catalog = new Catalog();
this.inventory = new Inventory();
this.checkout = new Checkout(activeDiscounts);
}
// Adds or updates an item in the catalog
public void addOrUpdateItem(Item item) {
catalog.updateItem(item);
}
// Updates the inventory count for an item
public void updateInventory(String barcode, int count) {
inventory.addStock(barcode, count);
}
// Adds a new discount campaign to the system
public void addDiscountCampaign(DiscountCampaign discount) {
activeDiscounts.add(discount);
}
// Retrieves an item from the catalog by its barcode
public Item getItemByBarcode(String barcode) {
return catalog.getItem(barcode);
}
// Removes an item from the catalog
public void removeItem(String barcode) {
catalog.removeItem(barcode);
}
}深度討論#
彈性的折扣條件#
目前設計把折扣邏輯封裝成兩個元件:
- Criteria:判斷商品是否符合折扣
- Price Calculation Strategy:計算折扣後價格
若面試官要求實作更複雜的組合折扣呢?例如:
- 「電子類商品總額超過 $100 給 10%、超過 $200 給 20%」
- 「同款食品買至少 3 件享特價」
這些情境需要組合多個條件與計算。我們可以用 Composite Pattern(criteria 用)與 Decorator Pattern(依序計算用)來增強設計。
想了解 Composite Pattern,請參考 Unix File Search 章節。
組合多個條件#
用 Composite Pattern 處理巢狀或組合條件,能用 AND、OR 等邏輯運算組合多個規則而不需硬編碼。
// Composite criteria that combines multiple discount criteria
public class CompositeCriteria implements DiscountCriteria {
// List of criteria to be combined
private final List<DiscountCriteria> criteriaList;
// Creates a new composite criteria with the given list of criteria
public CompositeCriteria(List<DiscountCriteria> criteriaList) {
this.criteriaList = new ArrayList<>(criteriaList);
}
// Checks if the item satisfies all the criteria in the list
@Override
public boolean isApplicable(Item item) {
return criteriaList.stream().allMatch(criteria -> criteria.isApplicable(item));
}
// Adds a new criteria to the composite
public void addCriteria(DiscountCriteria criteria) {
criteriaList.add(criteria);
}
// Removes a criteria from the composite
public void removeCriteria(DiscountCriteria criteria) {
criteriaList.remove(criteria);
}
}分層折扣計算#
用 Decorator Pattern 管理依序計算的折扣。透過包裝多個策略,可在不修改核心邏輯的前提下依特定順序套用折扣。例如:
- 先套用固定折扣
- 再對剩餘金額套用百分比折扣
想了解 Decorator Pattern,請參考章末**延伸閱讀**。
FixedDiscountDecorator#
public class FixedDiscountDecorator implements DiscountCalculationStrategy {
// The strategy being decorated
private final DiscountCalculationStrategy strategy;
// The fixed amount to be added to the discount
private final BigDecimal fixedAmount;
public FixedDiscountDecorator(DiscountCalculationStrategy strategy, BigDecimal fixedAmount) {
this.strategy = strategy;
this.fixedAmount = fixedAmount;
}
// Calculates the discounted price by applying both the base strategy and the fixed amount
@Override
public BigDecimal calculateDiscountedPrice(BigDecimal originalPrice) {
return strategy.calculateDiscountedPrice(originalPrice).subtract(fixedAmount);
}
}PercentageDiscountDecorator#
public class PercentageDiscountDecorator implements DiscountCalculationStrategy {
// The strategy being decorated
private final DiscountCalculationStrategy strategy;
// The additional percentage to be discounted
private final BigDecimal additionalPercentage;
public PercentageDiscountDecorator(
DiscountCalculationStrategy strategy, BigDecimal additionalPercentage) {
this.strategy = strategy;
this.additionalPercentage = additionalPercentage;
}
// Calculates the discounted price by applying both the base strategy and the additional
// percentage
@Override
public BigDecimal calculateDiscountedPrice(BigDecimal originalPrice) {
BigDecimal baseDiscountedPrice = strategy.calculateDiscountedPrice(originalPrice);
return baseDiscountedPrice.multiply(
BigDecimal.ONE.subtract(additionalPercentage.divide(BigDecimal.valueOf(100))));
}
}結合巢狀條件與依序計算策略,能設計出高度彈性的折扣系統,處理最複雜的真實情境。這展現對抽象與可擴充性原則的深度理解,是 OOD 面試的關鍵亮點。
本章小結#
主要收穫:
- 清晰的關注點分離——每個元件(
Catalog、Inventory、Order、DiscountCampaign)各司其職- 模組化簡化了個別元件,並確保它們無痛整合
深度討論中我們探討了組合折扣與分層計算策略,展現抽象與可擴充性如何處理真實世界的複雜情境。
延伸閱讀:Decorator Design Pattern#
Decorator Design Pattern#
Decorator 是一種結構型設計模式,讓你能透過將物件包裝在另一個提供額外功能的物件中來新增行為,而不修改原物件的程式碼。
在雜貨店設計中,我們以 Decorator 包裝 DiscountCalculationStrategy,讓系統能依序套用折扣(如先固定金額再百分比)而不更動核心策略。
問題情境:文字格式化系統#
開發文字編輯器,使用者可套用粗體、斜體、底線等樣式。若用條件邏輯或為每種組合建立子類別(如 BoldText、BoldItalicText),會導致程式爆量或子類別爆炸。
解法#
Decorator 模式建立實作相同介面的 decorator 類別,包裝 Text 物件以新增行為。例如 BoldDecorator 包裝 Text 提供粗體格式,ItalicDecorator 提供斜體。Decorator 可堆疊組合樣式(如粗體+斜體),無需修改 Text 類別。
何時使用#
- 需在執行期動態新增功能或行為而不修改物件程式碼
- 子類別組合過多時,組合是更簡單的替代方案