本章探討**自動販賣機(Vending Machine)**系統的設計:使用者選購商品、分發商品、管理庫存、處理付款。雖然真實機器涉及硬體(投幣口、讀卡機、觸控螢幕),但本章將聚焦於系統的狀態、資料與核心功能。

Vending Machine

需求蒐集#

題目情境#

想像你站在販賣機前想買零食。投入現金、選擇商品,幾秒後商品掉到取物口;機器還會找零。背景中,系統正流暢追蹤庫存、處理付款、確保運作順暢。請設計這樣一台販賣機。

需求釐清對話#

Candidate:是否支援多種商品類型?

Interviewer:是的,零食、飲料皆可。

Candidate:商品如何組織?我假設每個商品需有獨一識別碼與價格。

Interviewer:商品放在特定貨架(rack)上,每個貨架只放一種商品。每個商品有獨一的 product code 與價格。

Candidate:付款如何處理?

Interviewer:只接受現金,必要時找零。

Candidate:商品缺貨時怎麼處理?

Interviewer:系統應檢查可用性,缺貨時顯示錯誤訊息。

Candidate:若使用者投入金額不足,可以繼續加幣嗎?

Interviewer:本題假設使用者一次投入足額。若不足則退錢並顯示錯誤。

Candidate:是否有存取控制?

Interviewer:使用者與管理員權限不同。使用者可選購;管理員負責新增/移除商品。

Candidate:是否有庫存追蹤需求?

Interviewer:是。販賣機需追蹤庫存,且僅管理員可新增/移除商品。

需求整理#

功能性需求#

  • 商品選擇:使用者可從一組商品中選購,每個商品有獨一的 product code、描述與價格
  • 庫存管理:商品儲存在特定貨架中,系統追蹤每個貨架的庫存量
  • 付款處理:僅接受現金,必要時可計算找零

非功能性需求#

  • 使用者介面必須直觀,最少指令即可完成購買
  • 系統必須防止未授權存取,確保僅管理員可變更商品,並安全處理現金交易

Use Case Diagram#

Use case diagram 描繪不同 actor(使用者或系統)如何與販賣機互動以達成目標。

Use Case Diagram of a Vending Machine

User actor 的 use cases#

  • Insert Money:投入現金啟動購買
  • Select Product:輸入 product code 選擇商品
  • Receive Product:收取分發的商品
  • Receive Change:收取找零

Admin actor 的 use cases#

  • Add Product:新增商品
  • Remove Product:移除商品
  • Update Inventory:更新貨架庫存

System actor 的 use cases#

  • Process Payment:驗證現金並計算找零
  • Dispense Product:從合適貨架釋出商品
  • Check Inventory:分發前確認可用性
  • Display Message:向使用者顯示訊息或錯誤

辨識核心物件#

  • VendingMachine:協調販賣機運作的中心實體,作為使用者互動的主要入口。我們會透過 Facade 設計模式適當委派職責,避免淪為「god object」反模式
  • Product:販賣機中的商品,包含 ID、價格、描述等屬性。Product 連結到其所在的 Rack
  • Rack:販賣機中指定的貨架槽位,存放單一商品類型的多個單位,並包含釋出商品的硬體機制

Design Choice:Product 連結 Rack 而非反向,因為 Rack 代表物理儲存位置;Product 是靜態實體,不應管理自身儲存。這符合單一職責原則。

  • InventoryManager:追蹤販賣機內的庫存
  • PaymentProcessor:處理付款、追蹤餘額並計算找零

類別圖設計#

Product#

Product 類別代表販賣機內的單一商品,包含 product code、描述與價格。

Product class diagram

Design Choice:Product 不包含庫存量。Product 封裝固有屬性(code、description、price),而貨架庫存量會持續變動。我們以獨立的 InventoryManager 管理庫存,符合單一職責原則。

Rack#

每個 Rack 對應單一 Product,可放多個單位。多個 Rack 透過**組合(composition)**形成販賣機的庫存空間。

Rack class diagram

Design Choice:Rack 不包含 dispenseProductFromRack 等方法。Rack 專注於庫存與商品資訊,分發動作委派給更高層的 InventoryManager,符合單一職責原則。

InventoryManager#

InventoryManager 處理商品的追蹤與儲存,支援新增、移除、分發等操作,並與硬體機制介接。

InventoryManager class diagram

關鍵方法:

  • dispenseProductFromRack:執行從貨架分發商品並遞減庫存。注意命名要避免歧義——「get」前綴僅保留給回傳屬性的 getter
  • updateRack:允許管理員編輯整個貨架配置

Design Choice:管理 InventoryManager 內的貨架集合時,需在彈性與控制之間取捨。updateRack(Map racks) 適合大量更新;多數情況偏好 addRack(Rack rack)removeRack(Rack rack) 等粒度方法。為提升安全性,可考慮 immutable collection 或 defensive copy。

PaymentProcessor#

PaymentProcessor 管理付款接受、追蹤目前餘額並回傳找零。

PaymentProcessor class diagram

Transaction#

販賣機購買流程涉及商品選擇、付款、確認等多步驟。Transaction 類別作為資料結構追蹤目前購買狀態。

優點:

  • 封裝關鍵細節:選擇的商品、所在 rack、所需總金額
  • 追蹤進行中的交易:在最終完成或取消前維持結構化紀錄
  • 協調各元件:PaymentProcessor 負責扣款、InventoryManager 負責分發、Transaction 把所有細節集中

Transaction class diagram

VendingMachine#

VendingMachine 是系統核心元件,模擬販賣機行為、處理付款並管理庫存。

VendingMachine class diagram

設計模式:VendingMachine 使用 Facade 模式為客戶端提供單一介面。「客戶端」指的是販賣機的軟體或硬體介面,而非個別使用者。

Design Choice:為避免 VendingMachine 淪為 god object,facade 應保持輕量並把任務委派給遵循單一職責原則的其他類別。例如商品管理委派給 InventoryManager、付款處理委派給 PaymentProcessor。

完整類別圖#

Class Diagram of Vending Machine

程式實作#

Product#

class Product {
    final String productCode;
    final String description;
    final BigDecimal unitPrice;

    public Product(String productCode, String description, BigDecimal unitPrice) {
        this.productCode = productCode;
        this.description = description;
        this.unitPrice = unitPrice;
    }
}

Implementation Choice

  • 金額用 BigDecimal 確保精度與捨入控制
  • 面試時為節省時間,亦可用 integer 表示最小單位(如美分)
  • 絕不要用 floatdouble 表示金額,會引入精度錯誤
  • 識別碼如 productCode 用 string 而非數值型別,因多半執行字串操作而非運算

InventoryManager 與 Rack#

兩個類別協作管理庫存。我們用 HashMap<String, Rack> 儲存貨架,提供以 rack code 查找的 O(1) 存取。

InventoryManager#

public class InventoryManager {
    // Maps rack codes to their corresponding rack objects
    private Map<String, Rack> racks;

    public InventoryManager() {
        racks = new HashMap<>();
    }

    // Retrieves the product from a specific rack using its code
    public Product getProductInRack(String rackCode) {
        return racks.get(rackCode).getProduct();
    }

    // Dispenses a product from the specified rack and decrements its count
    public void dispenseProductFromRack(Rack rack) {
        if (rack.getProductCount() > 0) {
            rack.setCount(rack.getProductCount() - 1);
        } else {
            throw new IllegalStateException("Cannot dispense product. Rack is empty.");
        }
    }

    public void updateRack(Map<String, Rack> racks) {
        this.racks = racks;
    }

    public Rack getRack(String name) {
        return racks.get(name);
    }
}

Rack#

public class Rack {
    private final String rackCode;
    private final Product product;
    private int count;

    public Rack(final String rackCode, final Product product, final int count) {
        this.rackCode = rackCode;
        this.product = product;
        this.count = count;
    }

    public Product getProduct() {
        return product;
    }

    public int getProductCount() {
        return count;
    }
}

PaymentProcessor#

public class PaymentProcessor {
    // Tracks the current balance in the payment processor
    private BigDecimal currentBalance = BigDecimal.ZERO;

    // Adds the specified amount to the current balance
    public void addBalance(BigDecimal amount) {
        currentBalance = currentBalance.add(amount);
    }

    // Deducts the specified amount from the current balance
    public void charge(BigDecimal amount) {
        currentBalance = currentBalance.subtract(amount);
    }

    // Returns the current balance as change and resets the balance to zero
    public BigDecimal returnChange() {
        BigDecimal change = currentBalance;
        currentBalance = BigDecimal.ZERO;
        return change;
    }

    // Returns the current balance
    public BigDecimal getCurrentBalance() {
        return currentBalance;
    }
}

VendingMachine#

class VendingMachine {
    // Stores the history of all completed transactions
    private final List<Transaction> transactionHistory;
    // Manages the inventory of products in the vending machine
    private final InventoryManager inventoryManager;
    // Handles all payment-related operations
    private final PaymentProcessor paymentProcessor;

    // Tracks the current ongoing transaction
    private Transaction currentTransaction;
    // Represents the current state of the vending machine
    private VendingMachineState currentState;
    // Tracks the current balance in the machine
    private double balance;
    // Stores the currently selected product code
    private String selectedProduct;

    public VendingMachine() {
        transactionHistory = new ArrayList<>();
        currentTransaction = new Transaction();
        inventoryManager = new InventoryManager();
        paymentProcessor = new PaymentProcessor();
        this.currentState = new NoMoneyInsertedState();
        this.balance = 0.0;
        this.selectedProduct = null;
    }

    // Updates the rack configuration with new product racks
    void setRack(Map<String, Rack> rack) {
        inventoryManager.updateRack(rack);
    }

    // Adds money to the payment processor
    void insertMoney(final BigDecimal amount) {
        paymentProcessor.addBalance(amount);
    }

    // Selects a product from a specific rack
    void chooseProduct(String rackId) {
        final Product product = inventoryManager.getProductInRack(rackId);
        currentTransaction.setRack(inventoryManager.getRack(rackId));
        currentTransaction.setProduct(product);
    }

    // Processes and completes the current transaction
    Transaction confirmTransaction() throws InvalidTransactionException {
        // Step 1: Validate the transaction before processing
        validateTransaction();

        // Step 2: Charge the customer for the product
        paymentProcessor.charge(currentTransaction.getProduct().getUnitPrice());

        // Step 3: Dispense the product from the rack
        inventoryManager.dispenseProductFromRack(currentTransaction.getRack());

        // Step 4: Return the change to the customer
        currentTransaction.setTotalAmount(paymentProcessor.returnChange());

        // Step 5: Add the completed transaction to the history
        transactionHistory.add(currentTransaction);
        Transaction completedTransaction = currentTransaction;

        // Reset the current transaction for the next purchase.
        currentTransaction = new Transaction();
        return completedTransaction;
    }

    // Validates the current transaction for product availability and sufficient funds
    private void validateTransaction() throws InvalidTransactionException {
        if (currentTransaction.getProduct() == null) {
            throw new InvalidTransactionException("Invalid product selection");
        } else if (currentTransaction.getRack().getProductCount() == 0) {
            throw new InvalidTransactionException("Insufficient inventory for product.");
        } else if (paymentProcessor
                        .getCurrentBalance()
                        .compareTo(currentTransaction.getProduct().getUnitPrice())
                < 0) {
            throw new InvalidTransactionException("Insufficient fund");
        }
    }

    // Returns an unmodifiable list of all completed transactions
    public List<Transaction> getTransactionHistory() {
        return Collections.unmodifiableList(transactionHistory);
    }

    // Cancels the current transaction and returns any inserted money
    public void cancelTransaction() {
        paymentProcessor.returnChange();
        currentTransaction =
                new Transaction(); // Reset the current transaction for the next purchase.
    }

    // Returns the inventory manager instance
    public InventoryManager getInventoryManager() {
        return inventoryManager;
    }
}

購買流程方法說明:

  • insertMoney(BigDecimal amount) — 透過 PaymentProcessor 增加機器餘額
  • chooseProduct() — 從 InventoryManager 取得商品與貨架,與目前 transaction 連結
  • confirmTransaction() — 驗證、扣款、分發、更新庫存與交易歷史

深度討論#

強制任務順序#

「如何確保使用者必須先投幣才能選商品?」

販賣機常見需求是防止無效操作。我們需強制以下順序:

  1. 使用者必須先投入現金
  2. 系統檢查金額是否足夠
  3. 若足夠,使用者選擇商品
  4. 機器分發商品

此外應在每個階段提供回饋。我們可使用 State Pattern 來建模販賣機的行為。

想了解 State Pattern,請參考章末**延伸閱讀**。

設計變更#

定義三個狀態:

NoMoneyInsertedState

  • 初始狀態,尚未投幣
  • 顯示「Insert money to proceed」
  • 僅允許投幣;嘗試選商品會拋例外
  • 成功投幣後轉為 MoneyInsertedState

MoneyInsertedState

  • 已投幣
  • 顯示「Select a product」
  • 啟用選商品;防止額外投幣以避免溢付
  • 驗證商品是否可用且金額足夠
  • 成功選商品後轉為 DispenseState

DispenseState

  • 準備分發商品
  • 顯示「Dispensing product…」或「Please collect your change」
  • 處理分發並重置回初始狀態
  • 防止流程中再做其他操作

State Pattern 明確定義狀態間的轉移,確保動作依序執行:Insert Money → Select Product → Dispense Product

VendingMachineState interface

程式變更#

public interface VendingMachineState {
    // Handles money insertion in the current state
    void insertMoney(VendingMachine VM, double amount);

    // Handles product selection in the current state
    void selectProductByCode(VendingMachine VM, String productCode)
            throws InvalidStateException;

    // Handles product dispensing in the current state
    void dispenseProduct(VendingMachine VM) throws InvalidStateException;

    // Returns a description of the current state
    String getStateDescription();
}

NoMoneyInsertedState 範例:

public class NoMoneyInsertedState implements VendingMachineState {
    // Adds money to the machine and transitions to MoneyInsertedState
    @Override
    public void insertMoney(VendingMachine VM, double amount) {
        VM.addBalance(amount);
        VM.setState(new MoneyInsertedState());
    }

    // Throws exception as product selection is not allowed without money
    @Override
    public void selectProductByCode(VendingMachine VM, String productCode)
            throws InvalidStateException {
        throw new InvalidStateException("Cannot select a product without inserting money.");
    }

    // Throws exception as product dispensing is not allowed without money
    @Override
    public void dispenseProduct(VendingMachine VM) throws InvalidStateException {
        throw new InvalidStateException("Cannot dispense product without inserting money.");
    }

    // Returns a description of the current state
    @Override
    public String getStateDescription() {
        return "No Money Inserted State - Please insert money to proceed";
    }
}

為節省篇幅,省略 MoneyInsertedState 與 DispenseState 的程式碼,結構類似。

本章小結#

主要收穫:將職責切分為 Product、Rack、InventoryManager、PaymentProcessor,並透過 facade 統一對外 API。

關鍵設計決策:

  • 遵循單一職責原則,每個元件聚焦特定職責
  • InventoryManager 管理庫存;PaymentProcessor 處理現金與找零
  • 深度討論中以 State Pattern 強制任務順序,防止「未付款即分發」等無效行為

面試中切記在實作核心功能後強調驗證與錯誤處理,特別是這類錯誤行為可能導致財務損失的系統。

延伸閱讀:State Design Pattern#

State Design Pattern#

State 模式是一種行為型設計模式,允許物件在內部狀態改變時改變其行為,看起來就像物件變成了不同的類別。

在販賣機設計中,我們以 State 模式管理 NoMoneyInsertedState、MoneyInsertedState、DispenseState,讓 VendingMachine 能在不更動核心邏輯的前提下動態切換行為。

問題情境:交通號誌#

TrafficLight 有三個狀態:RedYellowGreen,每個狀態行為不同:

  • Red — 持續亮紅燈一段時間
  • Yellow — 閃爍黃燈,提醒準備停車
  • Green — 亮綠燈讓車輛通行

若用條件式實作,會帶來下列問題:

  • 可擴展性:狀態增加時條件式爆炸式成長,新增狀態(如緊急車輛閃爍)需修改現有邏輯
  • 可維護性:邏輯重複、易產生 bug

解法#

State 模式將每個狀態的行為封裝為獨立類別。原物件(context)持有目前狀態物件的參考,將狀態相關任務委派給該物件。

例如 TrafficLight context 可委派給 RedLightState、GreenLightState、YellowLightState。要切換狀態時,context 只需替換目前狀態物件,無需複雜條件式。

State design pattern