本章設計類似 UPS、FedEx 或 Amazon Locker 的Shipping Locker(取貨櫃)系統。它讓顧客能方便、安全地取得線上訂單。系統管理 locker 可用性、為包裹分派合適的 locker、並確保流暢的取件體驗。

Shipping Locker System

需求蒐集#

題目情境#

想像你收到通知:線上訂單已送達附近的取貨櫃。你前往取貨櫃,輸入安全代碼,櫃門隨即彈開。背景中,系統已管理可用性、分派合適大小的格位,並確保流暢取件。請設計這樣的系統。

需求釐清對話#

Candidate:支援多種 locker 大小嗎?

Interviewer:是的,包裹應分派到能容納的最小可用 locker,最佳化空間使用。

Candidate:包裹送達到取件的流程?

Interviewer:包裹到達時,系統找出合適大小的空 locker 並分派。放入後通知顧客取件位置與獨一存取碼。輸入正確存取碼即可取件並釋放 locker。

Candidate:是否有時間限制或費用?

Interviewer:有 locker policy。提供Free Period——一段免費天數;之後依 locker 大小開始按日收費。若超過Maximum Period,員工會清出包裹。

Candidate:系統會處理付款嗎?

Interviewer:為簡化起見,由外部服務處理金流。

需求整理#

功能性需求#

  • 系統追蹤所有 locker 並支援不同大小
  • 將包裹分派到能容納的最小可用 locker
  • 顧客以獨一存取碼開啟自己的 locker
  • 依顧客的 locker policy 監控儲存費用

非功能性需求#

  • 系統需在高量操作下維持效能
  • 高可用性,全時段可供顧客與員工存取

辨識核心物件#

  • Locker:單一物理 locker
  • Site:包含多個不同大小 locker 的設施
  • ShippingPackage:包裹標準介面,BasicShippingPackage 為具體實作
  • Account:顧客帳戶,含 policy 與餘額

類別圖設計#

Locker#

Locker 代表物理儲存單位,屬性包括:

  • LockerSize size:locker 大小
  • ShippingPackage currentPackage:目前存放的包裹
  • Date assignmentDate:包裹放入日期
  • String accessCode:取件用的獨一安全碼

Locker class diagram

提供功能:分派包裹、取件後釋放、依使用時長計算費用、查詢可用性、透過存取碼驗證安全存取。

Design Choice:Locker 為獨立實體,封裝單一 locker 的狀態與行為,保持模組化。

LockerSize#

LockerSize enum 代表預定義的 locker 大小,每個大小附帶:

  • String sizeName:標籤(如 Small、Medium、Large)
  • BigDecimal dailyCharge:每日費用
  • BigDecimal width, height, depth:實體尺寸

LockerSize enum

Design Choice:用 enum 是因為 locker 大小是執行期不變的固定集合,提供型別安全與簡潔。

Site#

Site 模擬包含多個 locker 的物理位置,依大小組織。

關鍵方法:

  • findAvailableLocker(LockerSize size):尋找指定大小的空 locker
  • placePackage(ShippingPackage pkg, Date date):找到合適 locker、放入、追蹤日期、更新狀態

Site class diagram

每個 site 的 lockers 用 map 結構管理:key 為 LockerSize、value 為該大小的 locker 集合,便於快速依大小存取。

ShippingPackage#

ShippingPackage 為介面,定義所有包裹類型的標準。BasicShippingPackage 為標準包裹的具體實作。

ShippingStatus enum 定義生命週期狀態(PENDINGSTOREDRETRIEVED 等)。

ShippingPackage interface and ShippingStatus enum

Design Choice:把 ShippingPackage 設為介面提升可擴充性,能支援不同包裹類型(易碎、易腐)而不修改核心邏輯。

Account#

Account 代表使用者,維護費用餘額並關聯 AccountLockerPolicy

Account class diagram

Design Choice:透過關聯 AccountLockerPolicy 支援彈性計費規則。將顧客資料與政策資料分離,提升維護性並支援個人化條件。

AccountLockerPolicy#

定義帳戶的 locker 使用規則:

  • freePeriodDays:免費使用天數
  • maximumPeriodDays:包裹最多可放置的天數

AccountLockerPolicy class diagram

NotificationInterface#

定義發送通知的契約,包含 sendNotification 方法。

NotificationInterface diagram

最佳實務:在 OOD 面試中,外部系統(如通知)常以介面表示,以維持設計彈性與可擴展性。

LockerManager#

LockerManager 管理特定 site 的儲存與取件,使用:

  • Site:物理位置
  • NotificationInterface:發送通知
  • Map<String, Account>:帳戶 ID 對應使用者
  • Map<String, Locker>:存取碼對應 locker,便於取件時快速查找

LockerManager class diagram

Design ChoiceLockerManager 設為 facade,提供包裹分派與取件的單一控制點。

完整類別圖#

Class Diagram of Locker Service

程式實作#

Locker 與 LockerSize#

public class Locker {
    // Size of the locker
    private final LockerSize size;
    // Currently stored package
    private ShippingPackage currentPackage;
    // Date when the current package was assigned
    private Date assignmentDate;
    // Access code for retrieving the package
    private String accessCode;

    public Locker(LockerSize size) {
        this.size = size;
    }

    // Assigns a package to this locker and generates an access code
    public void assignPackage(ShippingPackage pkg, Date date) {
        this.currentPackage = pkg;
        this.assignmentDate = date;
        this.accessCode = generateAccessCode();
    }

    // Releases the locker by removing the current package and its details
    public void releaseLocker() {
        this.currentPackage = null;
        this.assignmentDate = null;
        this.accessCode = null;
    }

    // Calculates storage charges based on usage duration and policy
    public BigDecimal calculateStorageCharges() {
        if (currentPackage == null || assignmentDate == null) {
            return BigDecimal.ZERO;
        }

        AccountLockerPolicy policy = currentPackage.getUser().getLockerPolicy();
        long totalDaysUsed =
                (new Date().getTime() - assignmentDate.getTime()) / (1000 * 60 * 60 * 24);

        // Check if exceeds maximum period
        if (totalDaysUsed > policy.getMaximumPeriodDays()) {
            currentPackage.updateShippingStatus(ShippingStatus.EXPIRED);
            throw new MaximumStoragePeriodExceededException(
                    "Package has exceeded maximum allowed storage period of "
                            + policy.getMaximumPeriodDays()
                            + " days");
        }

        // Calculate chargeable days (excluding free period)
        long chargeableDays = Math.max(0, totalDaysUsed - policy.getFreePeriodDays());
        return size.dailyCharge.multiply(new BigDecimal(chargeableDays));
    }

    // Checks if the locker is available for new packages
    public boolean isAvailable() {
        return currentPackage == null;
    }

    // Verifies if the provided access code matches the locker's code
    public boolean checkAccessCode(String code) {
        return this.accessCode != null && accessCode.equals(code);
    }

    // getter and setter methods are omitted for brevity
}
public enum LockerSize {
    // Small locker with 10x10x10 dimensions and $5 daily charge
    SMALL(
            "Small",
            new BigDecimal("5.00"),
            new BigDecimal("10.00"),
            new BigDecimal("10.00"),
            new BigDecimal("10.00")),
    // Medium locker with 20x20x20 dimensions and $10 daily charge
    MEDIUM(
            "Medium",
            new BigDecimal("10.00"),
            new BigDecimal("20.00"),
            new BigDecimal("20.00"),
            new BigDecimal("20.00")),
    // Large locker with 30x30x30 dimensions and $15 daily charge
    LARGE(
            "Large",
            new BigDecimal("15.00"),
            new BigDecimal("30.00"),
            new BigDecimal("30.00"),
            new BigDecimal("30.00"));

    // Name of the locker size
    final String sizeName;
    // Daily charge for using this size locker
    final BigDecimal dailyCharge;
    // Width of the locker in inches
    final BigDecimal width;
    // Height of the locker in inches
    final BigDecimal height;
    // Depth of the locker in inches
    final BigDecimal depth;

    // Creates a new locker size with specified dimensions and charges
    LockerSize(
            String sizeName,
            BigDecimal dailyCharge,
            BigDecimal width,
            BigDecimal height,
            BigDecimal depth) {
        this.sizeName = sizeName;
        this.dailyCharge = dailyCharge;
        this.width = width;
        this.height = height;
        this.depth = depth;
    }
    // getter methods are omitted for brevity
}

Implementation Choice:用 BigDecimal 處理 dailyCharge 與尺寸以確保金額計算精度,避免 float/double 的捨入錯誤。

Site#

public class Site {
    // Map of locker sizes to sets of lockers of that size
    final Map<LockerSize, Set<Locker>> lockers = new HashMap<>();

    // Creates a new site with specified number of lockers for each size
    public Site(Map<LockerSize, Integer> lockers) {
        for (Map.Entry<LockerSize, Integer> entry : lockers.entrySet()) {
            Set<Locker> lockerSet = new HashSet<>();
            for (int i = 0; i < entry.getValue(); i++) {
                lockerSet.add(new Locker(entry.getKey()));
            }
            this.lockers.put(entry.getKey(), lockerSet);
        }
    }

    // Finds an available locker of the specified size
    public Locker findAvailableLocker(LockerSize size) {
        for (Locker locker : lockers.get(size)) {
            if (locker.isAvailable()) {
                return locker;
            }
        }
        return null;
    }

    // Places a package in an available locker of appropriate size
    public Locker placePackage(ShippingPackage pkg, Date date) {
        // Determine the smallest locker size that can fit this package
        LockerSize size = pkg.getLockerSize();
        Locker locker = findAvailableLocker(size);
        if (locker != null) {
            locker.assignPackage(pkg, date);
            pkg.updateShippingStatus(ShippingStatus.IN_LOCKER);
            return locker;
        }
        throw new NoLockerAvailableException(
                "No locker of size " + size + " is currently available");
    }
}

Implementation ChoiceplacePackage() 等輔助方法將「找+放」封裝成單一呼叫,提供類似原子交易的行為,確保執行緒安全並避免不一致狀態。

BasicShippingPackage#

public class BasicShippingPackage implements ShippingPackage {
    // Unique identifier for the order
    private final String orderId;
    // User account associated with this package
    private final Account user;
    private final BigDecimal width;
    private final BigDecimal height;
    private final BigDecimal depth;
    // Current status of the package
    private ShippingStatus status;

    // Creates a new shipping package with specified dimensions
    public BasicShippingPackage(
            String orderId, Account user, BigDecimal width, BigDecimal height, BigDecimal depth) {
        this.orderId = orderId;
        this.user = user;
        this.width = width;
        this.height = height;
        this.depth = depth;
        this.status = ShippingStatus.CREATED;
    }

    // Returns the current package status
    @Override
    public ShippingStatus getStatus() {
        return status;
    }

    // Updates the package status
    @Override
    public void updateShippingStatus(ShippingStatus status) {
        this.status = status;
    }

    // Determines the smallest locker size that can fit this package
    @Override
    public LockerSize getLockerSize() {
        for (LockerSize size : LockerSize.values()) {
            if (size.getWidth().compareTo(width) >= 0
                    && size.getHeight().compareTo(height) >= 0
                    && size.getDepth().compareTo(depth) >= 0) {
                return size;
            }
        }
        throw new PackageIncompatibleException("No locker size available for the package");
    } // getter methods are omitted for brevity
}

關鍵方法 getLockerSize() 評估所有尺寸,回傳能容納包裹的最小合適 locker

Account 與 AccountLockerPolicy#

public class Account {
    private final String accountId;
    private final String ownerName;
    // Policy defining locker usage rules for this account
    private final AccountLockerPolicy lockerPolicy;
    // Total charges accumulated for locker usage
    private BigDecimal usageCharges = new BigDecimal("0.00");

    // Creates a new account with specified details and policy
    public Account(String accountId, String ownerName, AccountLockerPolicy lockerPolicy) {
        this.accountId = accountId;
        this.ownerName = ownerName;
        this.lockerPolicy = lockerPolicy;
    }

    // Adds a charge to the account's total usage charges
    public void addUsageCharge(BigDecimal amount) {
        usageCharges = usageCharges.add(amount);
    }

    // getter methods are omitted for brevity
}
public class AccountLockerPolicy {
    // Number of days of free storage
    final int freePeriodDays;
    // Maximum number of days a package can be stored
    final int maximumPeriodDays;

    // Creates a new locker policy with specified free and maximum periods
    public AccountLockerPolicy(int freePeriodDays, int maximumPeriodDays) {
        this.freePeriodDays = freePeriodDays;
        this.maximumPeriodDays = maximumPeriodDays;
    }
    // getter methods are omitted for brevity
}

LockerManager#

public class LockerManager {
    // The site being managed
    private final Site site;
    // Service for sending notifications
    private final NotificationInterface notificationService;
    // Map of account IDs to account objects
    private final Map<String, Account> accounts;
    // Map of access codes to lockers
    private final Map<String, Locker> accessCodeMap = new HashMap<>();

    // Creates a new locker manager for a site
    public LockerManager(
            Site site, Map<String, Account> accounts, NotificationInterface notificationService) {
        this.site = site;
        this.accounts = accounts;
        this.notificationService = notificationService;
    }

    // Assigns a package to an available locker
    public Locker assignPackage(ShippingPackage pkg, Date date) {
        Locker locker = site.placePackage(pkg, date);
        if (locker != null) {
            accessCodeMap.put(locker.getAccessCode(), locker);
            notificationService.sendNotification(
                    "Package assigned to locker" + locker.getAccessCode(), pkg.getUser());
        }
        return locker;
    }

    // Processes package pickup using an access code
    public Locker pickUpPackage(String accessCode) {
        Locker locker = accessCodeMap.get(accessCode);
        if (locker != null && locker.checkAccessCode(accessCode)) {
            try {
                BigDecimal charge = locker.calculateStorageCharges();
                ShippingPackage pkg = locker.getPackage();
                locker.releaseLocker();
                pkg.getUser().addUsageCharge(charge);
                pkg.updateShippingStatus(ShippingStatus.RETRIEVED);
                return locker;
            } catch (MaximumStoragePeriodExceededException e) {
                locker.releaseLocker();
                return locker;
            }
        }
        return null;
    }
    // getter methods are omitted for brevity
}

最佳實務:在複雜系統中常用 manager 類別集中操作。但要保持 manager 類別輕量,必須將低階操作(找 locker、儲存、套用 policy)委派給專屬類別(如 SiteLocker)。

深度討論#

設計可擴展的 Locker 建立系統#

目前 locker 直接依預定大小建立,使系統僵化且難擴充。例如新增 XLARGE 大小或溫控 locker 都需修改多處程式碼。

我們可用 Factory 設計模式集中建立邏輯。

class LockerFactory {
    // Creates a new locker of the specified size
    public static Locker createLocker(LockerSize size) {
        return switch (size) {
            case SMALL -> new Locker(LockerSize.SMALL);
            case MEDIUM -> new Locker(LockerSize.MEDIUM);
            case LARGE -> new Locker(LockerSize.LARGE);
            case XLARGE -> new Locker(LockerSize.XLARGE);
        };
    }
}

LockerFactory 的優點

  • 集中物件建立:locker 建立的變動侷限於此類別
  • 可擴充性:新增 locker 大小或類型不需修改核心業務邏輯
  • 可讀性與維護性:將建立與使用邏輯分離,符合單一職責原則

用事件驅動解耦事件處理#

目前 LockerManager 直接呼叫 NotificationInterface.sendNotification(),與通知系統緊密耦合。

想了解 Observer Pattern,請參考 Elevator System 章節。

我們以 Observer 模式讓 locker 系統維護觀察者清單,而非硬編碼通知邏輯。觀察者可以是不同通知服務(email、SMS)或其他系統(分析、指標)。

SubjectLockerManagerChange

class LockerManagerChange {
    // List of observers that will be notified of locker events
    private final List<LockerEventObserver> observers = new ArrayList<>();

    public void addObserver(LockerEventObserver observer) {
        observers.add(observer);
    }

    public void removeObserver(LockerEventObserver observer) {
        observers.remove(observer);
    }

    private void notifyObservers(String message, Account account) {
        for (LockerEventObserver observer : observers) {
            observer.update(message, account);
        }
    }

    // Assigns a package to an available locker and notifies observers.
    public void assignPackage(ShippingPackage pkg) {
        Locker locker = assignLockerToPackage(pkg);
        if (locker != null) {
            notifyObservers("Package assigned to locker", pkg.getUser());
        }
    }

    private Locker assignLockerToPackage(ShippingPackage pkg) {
        return null;
    }
}

Observer 介面

// Interface for objects that need to be notified of locker events
public interface LockerEventObserver {
    // Updates the observer with a message and the affected account
    void update(String message, Account account);
}

具體觀察者

// Implementation of LockerEventObserver that sends email notifications
class EmailNotification implements LockerEventObserver {
    // Updates the observer with a message and sends it to the account owner
    public void update(String message, Account account) {
        // send email to account owner
    }
}

本章小結#

我們透過結構化問答釐清需求、辨識核心物件、建立類別圖、實作關鍵元件。

深度討論中我們探討:

  • Factory Pattern:簡化 locker 實例化以利未來擴展
  • Observer Pattern:解耦事件處理以提供彈性通知

延伸閱讀:Factory Design Pattern#

Factory Design Pattern#

Factory Method 是一種創建型設計模式,提供建立類別實例的介面,但允許子類別決定要建立的物件型別。

問題情境:電商支付系統#

初始僅支援信用卡,當需要新增數位錢包或加密貨幣時,須改動多處緊密耦合的程式碼。

解法#

Factory design pattern

Factory Method 將直接物件建立替換為封裝實例化的工廠方法。新增支付類型只需建立新子類別,不需更動既有程式碼。

何時使用#

  • 程式所需物件的特定類型與依賴在執行前未知
  • 允許函式庫使用者擴充內部元件而不修改既有程式
  • 透過重用既有物件最佳化資源使用