本章探討雜貨店(Grocery Store)系統的設計。這套系統服務雜貨店員工,協助管理商品目錄、設定價格、套用折扣等日常作業。

Grocery Store System

需求蒐集#

題目情境#

想像你在雜貨店挑選蔬果、零食與生活用品。結帳時,收銀員逐項掃描,系統即時追蹤訂單、套用折扣、顯示總額。背景中,系統管理商品目錄、隨進貨與銷售更新庫存,確保每筆交易順暢精準。請設計這樣一套系統。

需求釐清對話#

Candidate:系統支援哪些主要操作?

Interviewer:應支援店員(出貨員、收銀員)管理商品目錄、追蹤庫存、處理顧客結帳與折扣。

Candidate:結帳流程是這樣嗎——收銀員掃描或輸入商品碼,系統追蹤訂單、計算小計、套用折扣、更新總額;輸入完成後收銀員看見最終金額、收款找零、產生收據?

Interviewer:是的。

Candidate:庫存如何管理?

Interviewer:系統應追蹤所有商品庫存,進貨時增加、結帳時自動扣除。

Candidate:商品需要分類嗎(食品、飲料等)?

Interviewer:是個好主意。

Candidate:折扣呢——系統追蹤折扣活動,可套用於特定商品或分類;若多個折扣同時適用,自動套用最高折扣?

Interviewer:聽起來不錯。

需求整理#

商品目錄管理#

  • 管理員可新增、更新、移除商品
  • 目錄追蹤名稱、分類、價格、條碼等細節

庫存管理#

  • 出貨員可在進貨時更新庫存
  • 系統在售出時自動扣減庫存

結帳流程#

  • 收銀員可掃描條碼或手動輸入商品碼建立訂單
  • 收銀員可檢視目前訂單細節(商品、折扣、小計)
  • 系統自動套用相關折扣
  • 收銀員可結帳、計算總額、處理付款與找零
  • 產生詳細收據

折扣活動#

  • 管理員可為特定商品或分類定義折扣活動
  • 多個折扣同時適用時,系統選擇最高者

非功能性需求#

  • 提供清晰、友善的錯誤訊息(例如無效條碼、庫存不足)
  • 元件(catalog、inventory、checkout、discounts)需模組化,支援獨立更新與替換

辨識核心物件#

  • Item:店內單一商品,封裝名稱、條碼、分類、價格
  • Catalog:商品的中央存放處,管理新增、更新、移除等操作
  • Inventory:追蹤每個商品的庫存量,進貨時增加、銷售時扣減
  • Order:追蹤進行中的結帳流程,管理商品、折扣、小計與總額
  • DiscountCampaign:定義折扣的促銷規則

類別圖設計#

Item#

Item 代表店內商品,封裝名稱、條碼、分類、價格等屬性。

Item class diagram

Catalog#

Catalog 維護所有商品的結構化清單,每個商品由條碼唯一識別。

Catalog class diagram

Inventory#

Inventory 管理庫存量,維護條碼與庫存量的對應。

Inventory class diagram

Design Choice:將靜態商品資訊與動態庫存量分離:

  • 靜態資料(Catalog):產品名稱、分類、價格等不常變動的中繼資料
  • 動態資料(Inventory):因銷售與進貨頻繁變動的庫存量

這種分離簡化兩個類別並符合單一職責原則

DiscountCriteria#

DiscountCriteria 介面封裝「商品是否符合折扣條件」的邏輯,提供彈性可擴充框架。

DiscountCriteria interface and concrete classes

具體類別:

  • CategoryBasedCriteria:依分類比對。例如折扣鎖定「Beverages」分類時,只要商品分類為 Beverages 就適用
  • ItemBasedCriteria:依特定商品比對,適合針對個別商品的促銷或清倉折扣

DiscountCalculationStrategy#

DiscountCalculationStrategy 介面封裝計算折扣的邏輯,採用 Strategy Pattern 提供彈性。

DiscountCalculationStrategy interface and concrete classes

  • AmountBasedStrategy:套用固定折扣金額。例如 $100 - $20 = $80
  • PercentageBasedStrategy:套用百分比折扣。例如 $100 × (1 - 20%) = $80

Design Choice:此設計高度可擴充——新折扣策略(如分級折扣、買 X 送 Y)可無痛加入而不修改既有實作,符合開放/封閉原則

DiscountCampaign#

DiscountCampaign 模擬進行中的折扣活動,運用 Strategy Pattern 封裝計算策略,並用 DiscountCriteria 指定哪些商品符合條件。

DiscountCampaign class diagram

Design Choice:分離適用性邏輯(criteria)與計算策略,使類別更模組化、易擴充。新增 criteria 或計算策略不需修改既有實作。

OrderItem#

OrderItem 代表訂單中單一商品與其數量,封裝計算總價的邏輯。

OrderItem class diagram

Design Choice:將商品層級細節從更高層的 Order 分離,確保每個商品的數量與價格邏輯獨立封裝。

Order#

Order 代表結帳期間的進行中交易,追蹤商品清單與套用的折扣,並提供計算小計與總額的方法。

Order class diagram

Design ChoiceOrder 專注於管理交易資料,把單一商品的數量處理委派給 OrderItem

Receipt#

Receipt 是已完成交易的最終紀錄,將訂單摘要、付款細節、找零等資訊整理為對顧客友善的格式。

Receipt class diagram

Design ChoiceReceipt 只負責呈現資料,把計算與折扣邏輯委派給 OrderOrderItem,保持輕量且專注。

Checkout#

Checkout 封裝結帳流程邏輯,維護一個進行中的 Order,並套用相關折扣活動。

Checkout class diagram

Design Choice:儘管 Checkout 角色關鍵,但仍保持輕量——商品、折扣、計算等職責皆委派給 OrderDiscountCampaign 等元件。

GroceryStoreSystem#

GroceryStoreSystem 作為 facade 簡化客戶端與系統元件(Catalog、Inventory、Checkout)的互動。在面試中,這個 facade 也讓我們能透過映射方法來驗證需求覆蓋情況。

GroceryStoreSystem class diagram

完整類別圖#

Class Diagram of Grocery Store System

程式實作#

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 ChoiceDiscountCampaign組合持有單一 DiscountCriteriaDiscountCalculationStrategy,運用多型實現彈性折扣設定。為節省篇幅,省略 DiscountCriteriaDiscountCalculationStrategy 的程式碼。

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 ChoiceArrayList 提供 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 處理巢狀或組合條件,能用 ANDOR 等邏輯運算組合多個規則而不需硬編碼。

// 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 面試的關鍵亮點。

本章小結#

主要收穫

  • 清晰的關注點分離——每個元件(CatalogInventoryOrderDiscountCampaign)各司其職
  • 模組化簡化了個別元件,並確保它們無痛整合

深度討論中我們探討了組合折扣分層計算策略,展現抽象與可擴充性如何處理真實世界的複雜情境。

延伸閱讀:Decorator Design Pattern#

Decorator Design Pattern#

Decorator 是一種結構型設計模式,讓你能透過將物件包裝在另一個提供額外功能的物件中來新增行為,而不修改原物件的程式碼

在雜貨店設計中,我們以 Decorator 包裝 DiscountCalculationStrategy,讓系統能依序套用折扣(如先固定金額再百分比)而不更動核心策略。

問題情境:文字格式化系統#

開發文字編輯器,使用者可套用粗體、斜體、底線等樣式。若用條件邏輯或為每種組合建立子類別(如 BoldTextBoldItalicText),會導致程式爆量或子類別爆炸。

解法#

Decorator 模式建立實作相同介面的 decorator 類別,包裝 Text 物件以新增行為。例如 BoldDecorator 包裝 Text 提供粗體格式,ItalicDecorator 提供斜體。Decorator 可堆疊組合樣式(如粗體+斜體),無需修改 Text 類別。

Text interface and concrete classes

何時使用#

  • 需在執行期動態新增功能或行為而不修改物件程式碼
  • 子類別組合過多時,組合是更簡單的替代方案